diff --git a/tools/door-coverage.mjs b/tools/door-coverage.mjs index 3b94a490..c988fe9a 100644 --- a/tools/door-coverage.mjs +++ b/tools/door-coverage.mjs @@ -21,3 +21,31 @@ export function auditExempt({ exempt = [], isMutating }) { const flagged = exempt.filter((t) => isMutating(t)); return { ok: flagged.length === 0, flagged }; } + +/** Канонический набор мутирующих инструментов (по способности, выровнен с supreme-gate + * default-deny: всё, что не observe-only и не seed). MCP-писатели динамические — не статичны. */ +export const CANONICAL_MUTATING_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Task', 'Skill']; + +export function isMutatingTool(name) { + return CANONICAL_MUTATING_TOOLS.includes(name); +} + +/** + * Извлечь, какие инструменты покрывает matcher хука в settings.PreToolUse. + * '*' → ['*'] (покрывает всё); "A|B" → ['A','B']; хук не найден / пустой matcher → [] + * (пустой = аудит требует явного '*', иначе сигналим «дверь не покрыта»). + */ +export function extractGateMatcher(settings, hookBasename) { + const pre = settings && settings.hooks && settings.hooks.PreToolUse; + if (!Array.isArray(pre)) return []; + for (const entry of pre) { + const inner = entry && entry.hooks; + if (!Array.isArray(inner)) continue; + const has = inner.some((h) => h && typeof h.command === 'string' && h.command.includes(hookBasename)); + if (!has) continue; + const m = typeof entry.matcher === 'string' ? entry.matcher : ''; + if (m === '*') return ['*']; + return m.split('|').map((s) => s.trim()).filter(Boolean); + } + return []; +} diff --git a/tools/door-coverage.test.mjs b/tools/door-coverage.test.mjs index aac3b4b3..9f387ef0 100644 --- a/tools/door-coverage.test.mjs +++ b/tools/door-coverage.test.mjs @@ -37,3 +37,49 @@ describe('auditExempt (страховка зелёного прохода, F)', expect(r.flagged).toEqual([]); }); }); + +// ── R-24 (Блок B Класс 2): door-coverage helpers + anti-drift seeds ── +import { CANONICAL_MUTATING_TOOLS, isMutatingTool, extractGateMatcher } from './door-coverage.mjs'; +import { SEED_TOOLS as GATE_SEED_TOOLS } from './enforce-supreme-gate.mjs'; + +describe('CANONICAL_MUTATING_TOOLS / isMutatingTool (R-24)', () => { + it('канонический список включает ключевые мутирующие инструменты', () => { + for (const t of ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Task', 'Skill']) { + expect(CANONICAL_MUTATING_TOOLS).toContain(t); + } + }); + it('isMutatingTool: мутирующий → true, observe-only → false', () => { + expect(isMutatingTool('Write')).toBe(true); + expect(isMutatingTool('Read')).toBe(false); + expect(isMutatingTool('EnterPlanMode')).toBe(false); + }); +}); + +describe('extractGateMatcher (R-24)', () => { + it('хук не найден → []', () => { + expect(extractGateMatcher({}, 'enforce-supreme-gate.mjs')).toEqual([]); + expect(extractGateMatcher({ hooks: { PreToolUse: [] } }, 'enforce-supreme-gate.mjs')).toEqual([]); + }); + it('matcher "*" → ["*"]', () => { + const settings = { hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: 'node tools/enforce-supreme-gate.mjs' }] }] } }; + expect(extractGateMatcher(settings, 'enforce-supreme-gate.mjs')).toEqual(['*']); + }); + it('matcher "Edit|Write|Bash" → ["Edit","Write","Bash"]', () => { + const settings = { hooks: { PreToolUse: [{ matcher: 'Edit|Write|Bash', hooks: [{ command: 'node tools/enforce-supreme-gate.mjs' }] }] } }; + expect(extractGateMatcher(settings, 'enforce-supreme-gate.mjs')).toEqual(['Edit', 'Write', 'Bash']); + }); + it('пустой matcher "" → [] (аудит требует явного *)', () => { + const settings = { hooks: { PreToolUse: [{ matcher: '', hooks: [{ command: 'node tools/enforce-supreme-gate.mjs' }] }] } }; + expect(extractGateMatcher(settings, 'enforce-supreme-gate.mjs')).toEqual([]); + }); +}); + +describe('SEED_TOOLS export (R-24 anti-drift, инвариантность)', () => { + it('экспортирован и содержит seed-инструменты стены', () => { + expect(GATE_SEED_TOOLS.has('EnterPlanMode')).toBe(true); + expect(GATE_SEED_TOOLS.has('AskUserQuestion')).toBe(true); + }); + it('seed-инструменты НЕ числятся мутирующими (иначе auditExempt их флагует)', () => { + for (const t of GATE_SEED_TOOLS) expect(isMutatingTool(t)).toBe(false); + }); +}); diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index 711cf75c..bd5a5c5a 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -22,7 +22,7 @@ import { canonicalAction, escapeGrantOpen, escapeAllowsEvent, loadFloorEscapes, // нельзя вызвать). Это Skill-вызовы (не мутируют мир) → seed-allow безопасен. export const SEED_SKILLS = ['writing-plans', 'brainstorming', 'discovery-interview', 'systematic-debugging', 'test-driven-development', 'requesting-code-review', 'verification-before-completion']; -const SEED_TOOLS = new Set(['EnterPlanMode', 'AskUserQuestion']); +export const SEED_TOOLS = new Set(['EnterPlanMode', 'AskUserQuestion']); function skillSuffix(name) { const s = String(name || '').toLowerCase(); return s.includes(':') ? s.split(':').pop() : s; } diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index a3219c32..969d3465 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -441,3 +441,18 @@ describe('runGate — FIX-3: escape best-effort журнал без продви expect(r.block).toBe(false); }); }); + +// R-24 (Блок B Класс 2): SEED_TOOLS экспортируется для read-only аудита покрытия дверей. +// Аддитивный export — гейт-решения неизменны; пиннинг состава seed-инструментов (анти-дрейф). +import { SEED_TOOLS } from './enforce-supreme-gate.mjs'; +describe('R-24: SEED_TOOLS export (инвариантность состава)', () => { + it('экспортирован как Set с EnterPlanMode + AskUserQuestion', () => { + expect(SEED_TOOLS instanceof Set).toBe(true); + expect(SEED_TOOLS.has('EnterPlanMode')).toBe(true); + expect(SEED_TOOLS.has('AskUserQuestion')).toBe(true); + }); + it('seed-инструменты не считаются seed-навыками (тулы, не Skill)', () => { + expect(isSeed({ name: 'EnterPlanMode' })).toBe(true); + expect(isSeed({ name: 'AskUserQuestion' })).toBe(true); + }); +});