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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user