diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index 8673428..0611ba8 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -47,6 +47,20 @@ export function isLedgerAppend(toolUse) { return LEDGER_PATH_RE.test(fp); } +// Окно research-read (№4): поимённо read-инструменты веб-разведки в разговорной фазе. Пин по суффиксу; +// тяжёлый/мутирующий firecrawl (crawl/agent/interact/monitor/map/feedback) исключён. egress-страж +// (enforce-mcp-classification) отдельно сканит payload — здесь снимаем только фазовый блок. +const RESEARCH_READ_SUFFIXES = [ + 'perplexity_search', 'perplexity_ask', 'perplexity_research', + 'web_search_exa', 'web_fetch_exa', + 'firecrawl_search', 'firecrawl_scrape', 'firecrawl_extract', 'firecrawl_parse', +]; +export function isResearchRead(toolUse) { + const n = String(toolUse?.name || ''); + if (!n.startsWith('mcp__')) return false; + return RESEARCH_READ_SUFFIXES.some((s) => n.endsWith('__' + s) || n.endsWith(s)); +} + // Узкий технический allowlist загрузки (НЕ «карта критического») — без него // нельзя создать первый план: writing-plans пишет план, AskUser/EnterPlanMode // открывают одобрение. Обоснование — D12/D13. @@ -419,14 +433,14 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k return { decision: 'allow', mode: 'conversational', finishPlan: true, reason: 'владелец завершил план досрочно (plan-done) — печать снята, возврат в разговор' }; } if (!frozenPlan) { - if (isSeed(toolUse) || isObserveOnly(toolUse) || isQueryOnly(toolUse) || isAuthoringWrite(toolUse) || isLedgerAppend(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/query/authoring (разговорный режим)' }; + if (isSeed(toolUse) || isObserveOnly(toolUse) || isQueryOnly(toolUse) || isAuthoringWrite(toolUse) || isLedgerAppend(toolUse) || isResearchRead(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/query/authoring (разговорный режим)' }; return { decision: 'block', mode: 'conversational', reason: 'разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана)' }; } if (!frozenArtifact || !verifyArtifactImpl(frozenArtifact, key)) { // F-B (аудит 2026-06-07): observe-only (Read/Grep/Glob/readonly-Bash/TodoWrite) пускаем // и в этом деградированном состоянии — инвариант finding 9 «смотрящие не душатся» + // согласованность с decide() (там observe-only allow безусловно). Бэкстоп держит только мутаторы. - if (isSeed(toolUse) || isObserveOnly(toolUse) || isQueryOnly(toolUse) || isAuthoringWrite(toolUse) || isLedgerAppend(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/query/authoring (бэкстоп: артефакт не опечатан)' }; + if (isSeed(toolUse) || isObserveOnly(toolUse) || isQueryOnly(toolUse) || isAuthoringWrite(toolUse) || isLedgerAppend(toolUse) || isResearchRead(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/query/authoring (бэкстоп: артефакт не опечатан)' }; return { decision: 'block', mode: 'conversational', reason: 'нет опечатанного артефакта разговорной фазы — вернись в разговор (бэкстоп C-10)' }; } // SE-2 (fail-closed whitelist): энфорсмент ТОЛЬКО при live-block на ОБЕИХ печатях. diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index 59b764e..91d7c50 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -10,6 +10,24 @@ import { resolveSessionId } from './enforce-supreme-gate.mjs'; import { signStepState, verifyStepState } from './enforce-supreme-gate.mjs'; import { stepStatePath } from './enforce-supreme-gate.mjs'; import { isLedgerAppend } from './enforce-supreme-gate.mjs'; +import { isResearchRead } from './enforce-supreme-gate.mjs'; + +describe('isResearchRead (окно research-read №4)', () => { + const t = (name) => isResearchRead({ name }); + it('пускает поимённо read-инструменты research', () => { + for (const n of ['mcp__perplexity__perplexity_search', 'mcp__perplexity__perplexity_ask', + 'mcp__perplexity__perplexity_research', 'mcp__exa__web_search_exa', 'mcp__exa__web_fetch_exa', + 'mcp__firecrawl__firecrawl_search', 'mcp__firecrawl__firecrawl_scrape', + 'mcp__firecrawl__firecrawl_extract', 'mcp__firecrawl__firecrawl_parse']) + expect(t(n)).toBe(true); + }); + it('НЕ пускает тяжёлый/мутирующий firecrawl и не-mcp', () => { + for (const n of ['mcp__firecrawl__firecrawl_crawl', 'mcp__firecrawl__firecrawl_agent', + 'mcp__firecrawl__firecrawl_interact', 'mcp__firecrawl__firecrawl_monitor_create', + 'mcp__firecrawl__firecrawl_map', 'mcp__firecrawl__firecrawl_search_feedback', 'WebSearch']) + expect(t(n)).toBe(false); + }); +}); describe('isLedgerAppend (окно журнала №3)', () => { const ok = (fp, name = 'Write') => isLedgerAppend({ name, input: { file_path: fp } });