fix: 작업실 채팅 크로스 프로젝트 파일 노출 코드 백스톱

이전 커밋(8866266)의 시스템 프롬프트 지시만으로는 절반만 먹혔음 — 경량모델이
"다른 프로젝트는 후보로 내놓지 말 것" 지시를 받고도 참고용으로 계속 언급함
(실측 확인). 로컬모델은 멀리 있는 프롬프트 지시보다 데이터 바로 옆 신호를
더 잘 따르는 기존 패턴(groundingResultReadingRule)과 동일한 원리로 코드
백스톱을 추가.

- getWorkshopCrossProjectWarning(): ws_ 세션에서 도구 인자/결과/데이터에
  활성 프로젝트가 아닌 workshop/<id>/ 경로가 섞여 있으면 경고를 결과 텍스트
  맨 앞에 직접 붙임(완전 차단은 아님 — 사용자가 명시적으로 다른 프로젝트나
  전체 워크스페이스를 요청하면 정상 동작)
- list 도구만이 아니라 python_eval(os.listdir 등, default 분기 공유)과
  shell 양쪽에 적용 — list만 막았더니 모델이 python_eval로 똑같이 다른
  프로젝트를 들여다보는 것을 확인해서 범위를 넓힘

3가지 시나리오로 실측 검증: 다른 프로젝트 명시 요청(정상 응답) / 전체
워크스페이스 명시 요청(정상 응답) / 모호한 질문(현재 프로젝트만 응답,
원래 버그 시나리오 해결).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-22 15:56:19 +09:00
co-authored by Claude Code
parent 88662663b1
commit 1f87651812
+35 -2
View File
@@ -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) {