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) {