fix(hooks): the write guard covers Edit and NotebookEdit and fails closed

hooks.json wired pre-write-executor.mjs to the Write tool only, so Edit and
NotebookEdit reached .git/hooks/, .env, shell configs and the other protected
paths unguarded. The matcher is now Write|Edit|NotebookEdit.

The guard reads the target path per tool: file_path for Write and Edit (hooks
reference, PreToolUse tool_input tables), notebook_path for NotebookEdit (the
tool's input schema). When it cannot read a path (unparseable input, unknown
tool, missing field) it now blocks instead of allowing.

New tests resolve hooks.json with Claude Code's matcher semantics and run the
hooks that would fire per tool: 9 failed on the old code, all pass now. The two
fail-open pins in path-guard.test.mjs are flipped to fail closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 15:54:47 +02:00
commit 2c89a6b580
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
6 changed files with 201 additions and 15 deletions

View file

@ -1,6 +1,6 @@
#!/usr/bin/env node
// Hook: pre-write-executor.mjs
// Event: PreToolUse (Write)
// Event: PreToolUse (Write, Edit, NotebookEdit)
// Purpose: Block writes to security-sensitive paths. Wired universally (every
// Voyage session, not just execution) by deliberate decision — protecting
// ~/.ssh, ~/.aws, .git/hooks, .env, shell configs benefits every session.
@ -9,8 +9,10 @@
//
// Protocol:
// - Read JSON from stdin: { tool_name, tool_input }
// - tool_input.file_path — the target path for Write tool
// - Target path: tool_input.file_path (Write, Edit) or
// tool_input.notebook_path (NotebookEdit)
// - BLOCK (exit 2): writes to security infrastructure, shell configs, secrets
// - BLOCK (exit 2): no readable target path (fail closed)
// - Allow (exit 0): everything else
import { readFileSync } from 'node:fs';
@ -94,6 +96,24 @@ const BLOCK_RULES = [
},
];
// Which tool_input field carries the target path, per tool. Write and Edit:
// hooks reference, PreToolUse tool_input tables. NotebookEdit: the tool's own
// input schema. hooks.json must wire exactly these tools.
const PATH_FIELD = {
Write: 'file_path',
Edit: 'file_path',
NotebookEdit: 'notebook_path',
};
function blockNoPath(reason) {
process.stderr.write(
`[voyage] BLOCKED: no target path (${reason})\n` +
' The write guard could not read the path this call writes to, so it ' +
'cannot be checked. Blocked (fail closed).\n'
);
process.exit(2);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
@ -102,14 +122,18 @@ try {
const raw = readFileSync(0, 'utf-8');
input = JSON.parse(raw);
} catch {
// Cannot parse stdin — fail open.
process.exit(0);
blockNoPath('unreadable hook input');
}
const filePath = input?.tool_input?.file_path;
const field = PATH_FIELD[input?.tool_name];
if (!field) {
blockNoPath(`unknown tool ${JSON.stringify(input?.tool_name)}`);
}
const filePath = input?.tool_input?.[field];
if (!filePath || typeof filePath !== 'string') {
process.exit(0);
blockNoPath(`${input.tool_name} without tool_input.${field}`);
}
const resolved = resolve(filePath);