// tools/enforce-runtime-write-deny.test.mjs // Standalone write-deny on ~/.claude/runtime (router-gate v4 §"Side-channel // write-protection", item 1b / C3). Closes a pre-existing self-write hole on the // git-approval anchor; uses the resolving pathNormalize so `.`/`..` segments // cannot evade the match (V2-2). import { describe, it, expect } from 'vitest'; import { decide, decideFromEvent } from './enforce-runtime-write-deny.mjs'; import { homedir } from 'node:os'; import { join } from 'node:path'; const HOME = homedir(); const HOME_FWD = HOME.replace(/\\/g, '/'); describe('enforce-runtime-write-deny decide()', () => { it('blocks a Write into ~/.claude/runtime (git-approval anchor)', () => { const r = decide({ filePath: join(HOME, '.claude', 'runtime', 'askuser-decisions-S.jsonl') }); expect(r.block).toBe(true); }); it('P10-a: blocks ANY path-bearing tool whose path resolves into runtime', () => { expect(decideFromEvent({ tool_name: 'mcp__fs__write_file', tool_input: { file_path: `${HOME_FWD}/.claude/runtime/x.jsonl` } }).block).toBe(true); expect(decideFromEvent({ tool_name: 'mcp__fs__write', tool_input: { path: `${HOME_FWD}/.claude/runtime/y.json` } }).block).toBe(true); }); it('P10-a: does NOT block a path-bearing tool on a normal project file', () => { expect(decideFromEvent({ tool_name: 'mcp__fs__write', tool_input: { path: `${HOME_FWD}/project/x.mjs` } }).block).toBe(false); }); it('P10-a: ignores tools with no path field', () => { expect(decideFromEvent({ tool_name: 'Bash', tool_input: { command: 'ls' } }).block).toBe(false); }); it('P10-a: blocks by path regardless of tool name', () => { expect(decide({ filePath: join(HOME, '.claude', 'runtime', 'x') }).block).toBe(true); }); it('фикс-B4: ловит путь в доп. полях MCP-писателей (filename/destination/uri/output_path/dest)', () => { for (const f of ['filename', 'destination', 'uri', 'output_path', 'dest']) { expect(decideFromEvent({ tool_name: 'mcp__fs__write', tool_input: { [f]: `${HOME_FWD}/.claude/runtime/z.json` } }).block).toBe(true); } }); it('blocks the .-segment evasion (V2-2)', () => { // Raw string with `/./` — path.join would pre-collapse it, so build it literally. const evasion = `${HOME_FWD}/.claude/./runtime/x.jsonl`; const r = decide({ toolName: 'Write', filePath: evasion }); expect(r.block).toBe(true); }); it('blocks Edit/MultiEdit/NotebookEdit too', () => { const p = join(HOME, '.claude', 'runtime', 'safe-baseline-ledger-S.json'); expect(decide({ toolName: 'Edit', filePath: p }).block).toBe(true); expect(decide({ toolName: 'MultiEdit', filePath: p }).block).toBe(true); expect(decide({ toolName: 'NotebookEdit', filePath: p }).block).toBe(true); }); it('allows a Write to a normal project path', () => { const r = decide({ toolName: 'Write', filePath: join(HOME, 'project', 'src', 'x.mjs') }); expect(r.block).toBe(false); }); it('no block when there is no path', () => { expect(decide({ filePath: '' }).block).toBe(false); expect(decideFromEvent({ tool_name: 'Bash', tool_input: { command: 'rm x' } }).block).toBe(false); }); it('fail-open (no block) when the normalizer throws — never bricks the session', () => { const throwing = () => { throw new Error('boom'); }; const r = decide({ toolName: 'Write', filePath: join(HOME, '.claude', 'runtime', 'x'), normalizeImpl: throwing }); expect(r.block).toBe(false); }); it('blocks via injected normalizer that resolves into runtime', () => { const r = decide({ toolName: 'Write', filePath: 'whatever', normalizeImpl: () => '/home/u/.claude/runtime/x.jsonl' }); expect(r.block).toBe(true); }); });