From 1f87651812f0e72734385ccb83db4082d0fae7db Mon Sep 17 00:00:00 2001 From: kim Date: Tue, 22 Sep 2026 15:56:19 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9E=91=EC=97=85=EC=8B=A4=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=ED=81=AC=EB=A1=9C=EC=8A=A4=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B0=B1=EC=8A=A4=ED=86=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 커밋(8866266)의 시스템 프롬프트 지시만으로는 절반만 먹혔음 — 경량모델이 "다른 프로젝트는 후보로 내놓지 말 것" 지시를 받고도 참고용으로 계속 언급함 (실측 확인). 로컬모델은 멀리 있는 프롬프트 지시보다 데이터 바로 옆 신호를 더 잘 따르는 기존 패턴(groundingResultReadingRule)과 동일한 원리로 코드 백스톱을 추가. - getWorkshopCrossProjectWarning(): ws_ 세션에서 도구 인자/결과/데이터에 활성 프로젝트가 아닌 workshop// 경로가 섞여 있으면 경고를 결과 텍스트 맨 앞에 직접 붙임(완전 차단은 아님 — 사용자가 명시적으로 다른 프로젝트나 전체 워크스페이스를 요청하면 정상 동작) - list 도구만이 아니라 python_eval(os.listdir 등, default 분기 공유)과 shell 양쪽에 적용 — list만 막았더니 모델이 python_eval로 똑같이 다른 프로젝트를 들여다보는 것을 확인해서 범위를 넓힘 3가지 시나리오로 실측 검증: 다른 프로젝트 명시 요청(정상 응답) / 전체 워크스페이스 명시 요청(정상 응답) / 모호한 질문(현재 프로젝트만 응답, 원래 버그 시나리오 해결). Co-Authored-By: Claude Code --- src/gateway/chat/execute-tool.ts | 37 ++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/gateway/chat/execute-tool.ts b/src/gateway/chat/execute-tool.ts index 53cc48c..da5b5d5 100644 --- a/src/gateway/chat/execute-tool.ts +++ b/src/gateway/chat/execute-tool.ts @@ -3,6 +3,7 @@ import path from 'path'; import fs from 'fs'; import { getConfig } from '../../config/config'; import { getToolRegistry } from '../../tools/registry.js'; +import { loadWorkshop } from '../routes/workshop-storage'; import { getMCPManager } from '../infra/mcp-manager'; import { SubagentManager } from '../tasks/subagent-manager'; import { addMemoryVector, getMemoryStats } from '../memory/memory-vector'; @@ -40,6 +41,33 @@ export interface ToolResult { data?: any; } +// 작업실(ws_) 세션이 활성 프로젝트가 아닌 다른 프로젝트(workshop/<다른id>/)를 건드리면 경고를 +// 덧붙인다 — 2026-09-22: list 도구만 막았더니 모델이 python_eval(os.listdir)로 똑같이 다른 +// 프로젝트를 들여다보는 걸 확인해서(list 하나만으로는 안 됨), list/python_eval(default 분기)와 +// shell 양쪽에서 이 헬퍼를 공유한다. haystack에 도구 인자(args)까지 포함시키는 이유: python_eval +// 코드 안에 경로 문자열이 있고 그 실행 결과(파일명 목록)엔 경로 자체가 안 찍히는 경우가 있어서 +// (args만 봐야 잡힘). 완전 차단은 안 함 — 사용자가 명시적으로 다른 프로젝트를 요청하면 살아있어야 +// 하니, 데이터 바로 옆에 강한 신호만 붙인다(로컬모델은 멀리 있는 프롬프트 지시보다 인접 신호를 +// 더 잘 따름 — groundingResultReadingRule과 같은 원리). +// 반환값은 "경고 접두어"뿐(빈 문자열이면 해당 없음) — 호출부가 warning + text로 붙인다. +function getWorkshopCrossProjectWarning(haystackParts: any[], sessionId: string, workspacePath: string): string { + if (!/^ws_/.test(String(sessionId || '')) || !workspacePath) return ''; + try { + const wd = loadWorkshop(workspacePath); + const otherIds = wd.projects.map((p) => p.id).filter((id) => id !== wd.activeProjectId); + if (!otherIds.length) return ''; + const haystack = haystackParts.map((p) => (typeof p === 'string' ? p : JSON.stringify(p ?? ''))).join('\n'); + const found = otherIds.find((id) => new RegExp(`workshop/${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:/|\\b)`).test(haystack)); + if (!found) return ''; + const otherProj = wd.projects.find((p) => p.id === found); + const activeProj = wd.projects.find((p) => p.id === wd.activeProjectId); + return `⚠️ 이 결과엔 활성 프로젝트가 아닌 다른 프로젝트("${otherProj?.name || found}", ${found})의 경로가 섞여 있습니다. ` + + `지금 사용자가 보고 있는 프로젝트는 "${activeProj?.name || wd.activeProjectId}"(경로: workshop/${wd.activeProjectId}/)입니다. ` + + `사용자가 "${otherProj?.name || found}"을(를) 명시적으로 요청하지 않았다면 이 경로를 후보로 언급하지 말고, ` + + `workshop/${wd.activeProjectId}/ 안의 파일만 쓰세요.\n\n`; + } catch { return ''; } +} + type ScheduleJobAction = | 'list' | 'create' @@ -747,7 +775,8 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals imageLinks.length > 0 ? '\n\n' + imageLinks.join('\n') : '', downloadLinks.length > 0 ? `\n\nDownload:\n${downloadLinks.join('\n')}` : '', ].join(''); - return { name, args, result: (output || `(exit code ${result.code})`) + mediaSuffix, error: result.code !== 0 }; + const _shellCrossProjectWarning = getWorkshopCrossProjectWarning([args, output], sessionId, workspacePath); + return { name, args, result: _shellCrossProjectWarning + (output || `(exit code ${result.code})`) + mediaSuffix, error: result.code !== 0 }; } catch (err: any) { return { name, args, result: `Error: ${err.message}`, error: true }; } @@ -1389,6 +1418,10 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals const tr = await tool.execute({ ...args, _workspace: workspacePath, _workspacePath: workspacePath }); const _resultText = tr.stdout || tr.error || ''; const _hasImageMd = /!\[[^\]]*\]\(\/api\/files\/[^)]+\)/.test(_resultText); + // 작업실(ws_) 세션 크로스 프로젝트 경고 — list/python_eval 등 default 분기로 오는 모든 + // 도구가 대상. list는 결과가 tr.data(파일명만, 경로는 tr.data.path)에 있고 python_eval은 + // stdout엔 경로가 안 찍히고 args.code 안에만 있어서, 텍스트+data+args를 다 훑는다. + const _crossProjectWarning = getWorkshopCrossProjectWarning([args, _resultText, tr.data], sessionId, workspacePath); // Email attachments: push file cards via SSE so they always appear regardless of AI phrasing if (name === 'email_read' && Array.isArray(tr.data?.savedRelPaths) && tr.data.savedRelPaths.length > 0) { const links = (tr.data.savedRelPaths as string[]).map((p: string) => ({ @@ -1428,7 +1461,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals if (name === 'nhc_active_storms' && tr.success && typeof tr.data?.url === 'string') { sendSSE?.('token', { text: `\n${tr.data.url}\n` }); } - return { name, args, result: _resultText, error: !tr.success, ...(_hasImageMd ? { isImage: true } : {}), ...(tr.data !== undefined ? { data: tr.data } : {}) }; + return { name, args, result: _crossProjectWarning + _resultText, error: !tr.success, ...(_hasImageMd ? { isImage: true } : {}), ...(tr.data !== undefined ? { data: tr.data } : {}) }; } } } catch (err: any) {