e1a6f26c06
Аудит M1-M4 (audit-context-building) нашёл непоследовательность guard формы sessionId: N3-фикс защитил только action-journal.paths() (M1), а 4 sibling- строителя пути из event.session_id (недоверенный источник) остались без проверки. Единый экспорт assertSafeSessionId (action-journal.mjs, переиспользует SESSION_ID_RE N3) применён во всех точках машин: - M1 action-journal.paths() — рефактор на общий guard (поведение N3 сохранено) - M4 judge-subrun-journal.paths() — guard добавлен (канал прилежности судьи F1) - M2 plan-lock.planPath + artifactPath — guard добавлен - M2 enforce-supreme-gate — экспортируемый guarded stepStatePath, применён в main() TDD RED-GREEN на каждом файле. Регрессия tools-only 2540 passed + 2 skip (+10). Серьёзность класса — низкая / защита-в-глубину (sessionId harness-controlled), закрыт ради консистентности (был 1 из 5). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
336 lines
20 KiB
JavaScript
336 lines
20 KiB
JavaScript
// tools/enforce-supreme-gate.test.mjs
|
||
import { describe, it, expect } from 'vitest';
|
||
import { isSeed, SEED_SKILLS } from './enforce-supreme-gate.mjs';
|
||
import { decide, actionOf } from './enforce-supreme-gate.mjs';
|
||
import { runGate } from './enforce-supreme-gate.mjs';
|
||
import { decideMode } from './enforce-supreme-gate.mjs';
|
||
import { isObserveOnly } from './enforce-supreme-gate.mjs';
|
||
import { resolveStepPtr } from './enforce-supreme-gate.mjs';
|
||
import { resolveSessionId } from './enforce-supreme-gate.mjs';
|
||
import { signStepState, verifyStepState } from './enforce-supreme-gate.mjs';
|
||
import { stepStatePath } from './enforce-supreme-gate.mjs';
|
||
|
||
// N3-shared (2026-06-07 аудит M1-M4): путь файла указателя шага строится из sessionId
|
||
// (resolveSessionId(event), недоверенный источник) — тот же guard формы, что action-journal.
|
||
describe('N3: stepStatePath path-injection guard', () => {
|
||
it('нормальный sessionId → детерминированный путь', () => {
|
||
expect(stepStatePath('/rt', 'S1')).toBe('/rt/plan-step-S1');
|
||
expect(stepStatePath('/rt/', 'unknown')).toBe('/rt/plan-step-unknown');
|
||
});
|
||
it('traversal/слэш/точка/обратный слэш → throw', () => {
|
||
expect(() => stepStatePath('/rt', '../evil')).toThrow();
|
||
expect(() => stepStatePath('/rt', 'a/b')).toThrow();
|
||
expect(() => stepStatePath('/rt', 'a.b')).toThrow();
|
||
expect(() => stepStatePath('/rt', 'a\\b')).toThrow();
|
||
});
|
||
});
|
||
|
||
describe('R-19: указатель шага подписан (подмена ptr без ключа → сброс)', () => {
|
||
const SK = 'step-key';
|
||
it('валидно подписанный → ptr; подменённый ptr со старой подписью → 0', () => {
|
||
const signed = signStepState('P1', 3, SK);
|
||
expect(resolveStepPtr(signed, 'P1', (s) => verifyStepState(s, SK))).toBe(3);
|
||
const tampered = { ...signed, ptr: 9 };
|
||
expect(resolveStepPtr(tampered, 'P1', (s) => verifyStepState(s, SK))).toBe(0);
|
||
});
|
||
it('без verify-колбэка — обратная совместимость R-27 сохранена', () => {
|
||
expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, 'P1')).toBe(3);
|
||
});
|
||
});
|
||
|
||
describe('resolveSessionId — берёт session_id из события, не из env (R-28)', () => {
|
||
it('из event.session_id (канон Claude Code stdin)', () => {
|
||
expect(resolveSessionId({ session_id: 'S-42' }, {})).toBe('S-42');
|
||
});
|
||
it('фолбэк на env, затем unknown', () => {
|
||
expect(resolveSessionId({}, { CLAUDE_SESSION_ID: 'E-1' })).toBe('E-1');
|
||
expect(resolveSessionId(null, {})).toBe('unknown');
|
||
});
|
||
});
|
||
|
||
describe('resolveStepPtr — указатель шага привязан к plan_id (R-27)', () => {
|
||
it('тот же план → возвращает сохранённый ptr', () => {
|
||
expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, 'P1')).toBe(3);
|
||
});
|
||
it('другой план → сброс в 0 (перепечать не отматывает чужой указатель)', () => {
|
||
expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, 'P2')).toBe(0);
|
||
});
|
||
it('легаси (голое число) / null / без plan_id → 0', () => {
|
||
expect(resolveStepPtr(5, 'P1')).toBe(0);
|
||
expect(resolveStepPtr(null, 'P1')).toBe(0);
|
||
expect(resolveStepPtr({ ptr: 9 }, 'P1')).toBe(0);
|
||
});
|
||
it('нет текущего плана → 0', () => {
|
||
expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, undefined)).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('isSeed (D12/D13 bootstrap exemption)', () => {
|
||
it('EnterPlanMode and AskUserQuestion are seeds', () => {
|
||
expect(isSeed({ name: 'EnterPlanMode' })).toBe(true);
|
||
expect(isSeed({ name: 'AskUserQuestion' })).toBe(true);
|
||
});
|
||
it('Skill(writing-plans) / brainstorming / discovery-interview are seeds', () => {
|
||
for (const s of SEED_SKILLS) {
|
||
expect(isSeed({ name: 'Skill', input: { skill: s } })).toBe(true);
|
||
expect(isSeed({ name: 'Skill', input: { skill: 'superpowers:' + s } })).toBe(true);
|
||
}
|
||
});
|
||
it('a non-seed Skill is NOT a seed', () => {
|
||
expect(isSeed({ name: 'Skill', input: { skill: 'coder' } })).toBe(false);
|
||
});
|
||
it('mutating tools are not seeds', () => {
|
||
expect(isSeed({ name: 'Write', input: { file_path: 'x' } })).toBe(false);
|
||
expect(isSeed({ name: 'Bash', input: { command: 'rm x' } })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('actionOf — поля объекта (F1, выровнено с B4)', () => {
|
||
it('берёт объект из доп. полей MCP-писателей (filename/uri/destination)', () => {
|
||
expect(actionOf({ name: 'mcp__fs__write', input: { filename: 'tools/foo.mjs' } }).object).toBe('tools/foo.mjs');
|
||
expect(actionOf({ name: 'mcp__x', input: { uri: 'a/b.txt' } }).object).toBe('a/b.txt');
|
||
expect(actionOf({ name: 'mcp__y', input: { destination: 'd/e.json' } }).object).toBe('d/e.json');
|
||
});
|
||
});
|
||
|
||
describe('decide — самодостаточная проверка печати артефакта (F2, защита-в-глубину)', () => {
|
||
it('битая печать артефакта при верном id → block (даже без обёртки decideMode)', () => {
|
||
const step = { n: 1, op: 'Write', object: 'a.mjs', ref: '§1' };
|
||
const plan = { steps: [step], artifact_id: 'AID' };
|
||
const artifact = { artifact_id: 'AID', sections: { '§1': 'x' } };
|
||
const verifyImpl = (obj) => obj === plan; // план валиден, артефакт — НЕТ (битая печать)
|
||
const r = decide({
|
||
toolUse: { name: 'Write', input: { file_path: 'a.mjs' } },
|
||
frozenPlan: plan, frozenArtifact: artifact, stepPtr: 0, key: 'K',
|
||
verifyImpl, normalize: (p) => p,
|
||
});
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/печать артефакта|seal/i);
|
||
});
|
||
it('R-31 split: артефакт проверяется ОТДЕЛЬНЫМ verifyArtifactImpl (не impl плана)', () => {
|
||
const step = { n: 1, op: 'Write', object: 'a.mjs', ref: '§1' };
|
||
const plan = { steps: [step], artifact_id: 'AID' };
|
||
const artifact = { artifact_id: 'AID', sections: { '§1': 'x' } };
|
||
const r = decide({
|
||
toolUse: { name: 'Write', input: { file_path: 'a.mjs' } },
|
||
frozenPlan: plan, frozenArtifact: artifact, stepPtr: 0, key: 'K',
|
||
verifyImpl: (o) => o === plan, verifyArtifactImpl: () => true, normalize: (p) => p,
|
||
});
|
||
expect(r.decision).toBe('allow');
|
||
});
|
||
});
|
||
|
||
const KEY = 'supreme-key';
|
||
const PLAN = {
|
||
plan_id: 'x', frozen_at: 1,
|
||
steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', intent: 'i' }],
|
||
sig: 'unused-in-stub',
|
||
};
|
||
// stub verify: считаем план валидным если sig !== 'BAD'
|
||
const verifyStub = (p) => p && p.sig !== 'BAD';
|
||
const ctx = (over) => ({ key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, normalize: (p) => p.toLowerCase(), ...over });
|
||
|
||
describe('supreme-gate decide()', () => {
|
||
it('seed passes even with no frozen plan', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'AskUserQuestion' }, frozenPlan: null, stepPtr: 0 }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.reason).toMatch(/seed/i);
|
||
});
|
||
it('default-deny when there is no frozen plan (non-seed)', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: null, stepPtr: 0 }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/нет замороженного плана|no frozen plan/i);
|
||
});
|
||
it('default-deny when frozen plan signature is invalid', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: { ...PLAN, sig: 'BAD' }, stepPtr: 0 }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/печать|seal|signature/i);
|
||
});
|
||
it('allow when action matches the current plan step', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } }, frozenPlan: PLAN, stepPtr: 0 }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.advanceTo).toBe(1);
|
||
});
|
||
it('block when action does NOT match the current step (not in plan)', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/evil.mjs' } }, frozenPlan: PLAN, stepPtr: 0 }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/не в плане|not in plan/i);
|
||
});
|
||
it('actionOf maps a tool event to {op, object}', () => {
|
||
expect(actionOf({ name: 'Write', input: { file_path: 'a.mjs' } })).toEqual({ op: 'Write', object: 'a.mjs' });
|
||
expect(actionOf({ name: 'Bash', input: { command: 'rm x' } })).toEqual({ op: 'Bash', object: 'rm x' });
|
||
expect(actionOf({ name: 'Skill', input: { skill: 'coder' } })).toEqual({ op: 'Skill', object: 'coder' });
|
||
});
|
||
});
|
||
|
||
describe('runGate (pure orchestration)', () => {
|
||
it('allow advances pointer and journals the action', () => {
|
||
const journaled = [];
|
||
const r = runGate({
|
||
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
|
||
frozenPlan: PLAN, frozenArtifact: { sig: 'ok' }, stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, normalize: (p) => p.toLowerCase(),
|
||
journal: (entry) => journaled.push(entry), saveStep: (n) => (journaled.savedStep = n),
|
||
});
|
||
expect(r.block).toBe(false);
|
||
expect(journaled).toHaveLength(1);
|
||
expect(journaled.savedStep).toBe(1);
|
||
});
|
||
it('block does NOT journal or advance', () => {
|
||
const journaled = [];
|
||
const r = runGate({
|
||
event: { tool_name: 'Write', tool_input: { file_path: 'tools/evil.mjs' } },
|
||
frozenPlan: PLAN, frozenArtifact: { sig: 'ok' }, stepPtr: 0, key: KEY, verifyImpl: verifyStub, verifyArtifactImpl: verifyStub, normalize: (p) => p.toLowerCase(),
|
||
journal: (e) => journaled.push(e), saveStep: () => {},
|
||
});
|
||
expect(r.block).toBe(true);
|
||
expect(journaled).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
const verifyStub2 = (p) => p && p.sig !== 'BAD';
|
||
const baseMode = (over) => ({ key: 'k', verifyImpl: verifyStub2, verifyArtifactImpl: verifyStub2, normalize: (p) => p.toLowerCase(), ...over });
|
||
|
||
describe('decideMode (C-7: разговорный vs реализационный)', () => {
|
||
it('разговорная фаза (нет плана-стройки): семя проходит', () => {
|
||
const r = decideMode(baseMode({ toolUse: { name: 'AskUserQuestion' }, frozenPlan: null, frozenArtifact: null }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.mode).toBe('conversational');
|
||
});
|
||
it('разговорная фаза: инструмент реализации блокируется (только думать/спрашивать)', () => {
|
||
const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'x' } }, frozenPlan: null, frozenArtifact: null }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.mode).toBe('conversational');
|
||
expect(r.reason).toMatch(/разговорн|только думать|спрашивать/i);
|
||
});
|
||
it('бэкстоп: реализационный план без замороженного артефакта → блок', () => {
|
||
const PLANL = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'x' }], sig: 'ok' };
|
||
const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'x' } }, frozenPlan: PLANL, frozenArtifact: null }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/артефакт|разговор/i);
|
||
});
|
||
it('есть артефакт+план+совпавший шаг → стройка (передаём в decide)', () => {
|
||
const PLANL = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }], sig: 'ok' };
|
||
const ART = { artifact_id: 'a', sig: 'ok' };
|
||
const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } }, frozenPlan: PLANL, frozenArtifact: ART, stepPtr: 0 }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.mode).toBe('implementation');
|
||
});
|
||
});
|
||
|
||
// stub classifyBash: readonly только для «безопасных» команд, редирект/писатель → не readonly (условие А)
|
||
const classifyBashStub = (cmd) => {
|
||
if (/[>|]|git\s+(config|gc|push|commit)|tee\b|\$\(/.test(cmd)) return { result: 'block', reason: 'mutating' };
|
||
if (/^(git\s+(status|log|diff|show)|grep|cat|ls)\b/.test(cmd)) return { result: 'allow', reason: 'readonly' };
|
||
return { result: 'block', reason: 'unknown' };
|
||
};
|
||
|
||
describe('isObserveOnly (зелёный проход = нет долговременного/исходящего эффекта, finding 9)', () => {
|
||
it('локальные смотрящие — да', () => {
|
||
for (const n of ['Read', 'Grep', 'Glob']) expect(isObserveOnly({ name: n })).toBe(true);
|
||
});
|
||
it('TodoWrite (эфемерный черновик) — да', () => {
|
||
expect(isObserveOnly({ name: 'TodoWrite', input: {} })).toBe(true);
|
||
});
|
||
it('read-only Bash по эффекту — да; редирект/писатель — НЕТ (условие А)', () => {
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'git status' } }, { classifyBash: classifyBashStub })).toBe(true);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'git log > evil.sh' } }, { classifyBash: classifyBashStub })).toBe(false);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'git config x y' } }, { classifyBash: classifyBashStub })).toBe(false);
|
||
});
|
||
it('мутирующие — НЕТ', () => {
|
||
for (const n of ['Write', 'Edit', 'MultiEdit', 'Task']) expect(isObserveOnly({ name: n })).toBe(false);
|
||
});
|
||
it('исходящие (WebFetch/WebSearch) — НЕТ (риск утечки → пол/судья)', () => {
|
||
expect(isObserveOnly({ name: 'WebFetch' })).toBe(false);
|
||
expect(isObserveOnly({ name: 'WebSearch' })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('decide() пропускает зелёный проход без шага плана', () => {
|
||
it('Read проходит без плана', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'Read', input: { file_path: 'x' } }, frozenPlan: null, stepPtr: 0 }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.reason).toMatch(/observe|смотр|зелён/i);
|
||
});
|
||
});
|
||
|
||
const ARTID = 'aid-1';
|
||
const PLAN_REF = { plan_id: 'p', artifact_id: ARTID, frozen_at: 1,
|
||
steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', ref: '§1' }], sig: 'ok' };
|
||
const ART_OK = { artifact_id: ARTID, sections: { '§1': 'teal' }, sig: 'ok' };
|
||
|
||
describe('decide() закрытая дверь + привязка к версии артефакта (C-5)', () => {
|
||
it('совпавший шаг + ссылка резолвится + версия совпала → allow', () => {
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } },
|
||
frozenPlan: PLAN_REF, frozenArtifact: ART_OK, stepPtr: 0 }));
|
||
expect(r.decision).toBe('allow');
|
||
});
|
||
it('ссылка шага не резолвится в артефакте → block', () => {
|
||
const planBadRef = { ...PLAN_REF, steps: [{ ...PLAN_REF.steps[0], ref: '§9' }] };
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } },
|
||
frozenPlan: planBadRef, frozenArtifact: ART_OK, stepPtr: 0 }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/не резолвится|закрыт|C-5/i);
|
||
});
|
||
it('версия артефакта не та, под которую печатали план → block', () => {
|
||
const otherArt = { ...ART_OK, artifact_id: 'aid-2' };
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/FOO.mjs' } },
|
||
frozenPlan: PLAN_REF, frozenArtifact: otherArt, stepPtr: 0 }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/версия|artifact|пере-печать/i);
|
||
});
|
||
});
|
||
|
||
// F-A (аудит 2026-06-07): зелёный проход Bash через НАСТОЯЩИЙ classifyBashCommand.
|
||
// reason 'whitelisted reading command(s)' схлопывается, если в цепочке есть хоть
|
||
// один читатель — тогда whitelisted-мутатор за `cat x &&` проскакивал зелёным
|
||
// проходом (composer pint → переписать исходники, migrate:fresh → дроп БД,
|
||
// node <script> → произвольный JS). Эти тесты используют дефолтный classifyBash.
|
||
describe('isObserveOnly — F-A: цепочка читатель+мутатор НЕ зелёный проход (real classify)', () => {
|
||
it('читатель + whitelisted-мутатор → НЕ observe-only', () => {
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'grep x README && composer pint' } })).toBe(false);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'cat README && php artisan migrate:fresh' } })).toBe(false);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'cat a && node evil.mjs' } })).toBe(false);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'composer pint && grep x README' } })).toBe(false);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'grep x README && pest' } })).toBe(false);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'cat a && npm test' } })).toBe(false);
|
||
});
|
||
it('чистая цепочка читателей + одиночный readonly-git остаются зелёными', () => {
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'cat a && grep b a' } })).toBe(true);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'git status' } })).toBe(true);
|
||
expect(isObserveOnly({ name: 'Bash', input: { command: 'grep x README' } })).toBe(true);
|
||
});
|
||
});
|
||
|
||
// F-B (аудит 2026-06-07): при плане без валидного артефакта observe-only не должен
|
||
// душиться (инвариант finding 9 + согласованность с decide()).
|
||
describe('decideMode — F-B: observe-only проходит даже при плане без артефакта', () => {
|
||
it('Read при плане + пропавшем артефакте → allow (не блок)', () => {
|
||
const PLANL = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'x' }], sig: 'ok' };
|
||
const r = decideMode(baseMode({ toolUse: { name: 'Read', input: { file_path: 'x' } }, frozenPlan: PLANL, frozenArtifact: null }));
|
||
expect(r.decision).toBe('allow');
|
||
expect(r.mode).toBe('conversational');
|
||
});
|
||
it('Write при плане + пропавшем артефакте по-прежнему блокируется (бэкстоп цел)', () => {
|
||
const PLANL = { plan_id: 'x', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'x' }], sig: 'ok' };
|
||
const r = decideMode(baseMode({ toolUse: { name: 'Write', input: { file_path: 'x' } }, frozenPlan: PLANL, frozenArtifact: null }));
|
||
expect(r.decision).toBe('block');
|
||
});
|
||
});
|
||
|
||
// F-C (аудит 2026-06-07): шаг с ref обязан резолвиться в опечатанном артефакте
|
||
// НЕЗАВИСИМО от того, привязан ли план к artifact_id (закрытая дверь не
|
||
// выключается планом без artifact_id).
|
||
describe('decide — F-C: ref при плане без artifact_id всё равно требует артефакт', () => {
|
||
it('шаг с ref + план без artifact_id + нет артефакта → block', () => {
|
||
const plan = { plan_id: 'p', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', ref: '§1' }], sig: 'ok' };
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: plan, frozenArtifact: null, stepPtr: 0 }));
|
||
expect(r.decision).toBe('block');
|
||
expect(r.reason).toMatch(/ссылк|закрыт|артефакт|C-5/i);
|
||
});
|
||
it('шаг без ref + план без artifact_id → allow (легаси не сломан)', () => {
|
||
const plan = { plan_id: 'p', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }], sig: 'ok' };
|
||
const r = decide(ctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: plan, frozenArtifact: null, stepPtr: 0 }));
|
||
expect(r.decision).toBe('allow');
|
||
});
|
||
});
|