397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
74 lines
3.7 KiB
JavaScript
74 lines
3.7 KiB
JavaScript
// 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);
|
|
});
|
|
});
|