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:
parent
02f320fbe4
commit
2c89a6b580
6 changed files with 201 additions and 15 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue