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>
153 lines
4.9 KiB
JavaScript
153 lines
4.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Hook: pre-write-executor.mjs
|
|
// 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.
|
|
// The CC if: path-scoping mechanism now works, but narrowing to execute-only
|
|
// would only weaken protection with no benefit. See cc-upgrade matrix CC-15/F2.
|
|
//
|
|
// Protocol:
|
|
// - Read JSON from stdin: { tool_name, tool_input }
|
|
// - 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';
|
|
import { resolve } from 'node:path';
|
|
|
|
const HOME = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BLOCK rules — path patterns that must never be written during execution.
|
|
// ---------------------------------------------------------------------------
|
|
const BLOCK_RULES = [
|
|
{
|
|
name: 'Git hook injection (.git/hooks/)',
|
|
test: (p) => /\/\.git\/hooks\//.test(p),
|
|
description:
|
|
'Writing to .git/hooks/ could inject malicious git hooks that execute ' +
|
|
'on every commit, push, or checkout. Blocked.',
|
|
},
|
|
{
|
|
name: 'Claude settings self-modification',
|
|
test: (p) => /\/\.claude\/settings[^/]*\.json$/.test(p),
|
|
description:
|
|
'Writing to .claude/settings.json could disable security hooks or ' +
|
|
'change permission modes. Blocked.',
|
|
},
|
|
{
|
|
name: 'Claude hooks self-modification',
|
|
test: (p) => /\/\.claude\/hooks\//.test(p) || /\/\.claude-plugin\//.test(p),
|
|
description:
|
|
'Writing to .claude/hooks/ or .claude-plugin/ could modify security ' +
|
|
'hook configuration. Blocked.',
|
|
},
|
|
{
|
|
name: 'Shell configuration files',
|
|
test: (p) => {
|
|
const sensitive = [
|
|
`${HOME}/.zshrc`,
|
|
`${HOME}/.bashrc`,
|
|
`${HOME}/.bash_profile`,
|
|
`${HOME}/.profile`,
|
|
`${HOME}/.zshenv`,
|
|
`${HOME}/.zprofile`,
|
|
];
|
|
const resolved = resolve(p);
|
|
return sensitive.some((s) => resolved === s || resolved.startsWith(s + '.'));
|
|
},
|
|
description:
|
|
'Writing to shell config files (~/.zshrc, ~/.bashrc, etc.) could inject ' +
|
|
'persistent commands. Blocked.',
|
|
},
|
|
{
|
|
name: 'SSH directory',
|
|
test: (p) => {
|
|
const resolved = resolve(p);
|
|
return resolved.startsWith(`${HOME}/.ssh/`) || resolved === `${HOME}/.ssh`;
|
|
},
|
|
description: 'Writing to ~/.ssh/ could compromise SSH keys or config. Blocked.',
|
|
},
|
|
{
|
|
name: 'AWS credentials',
|
|
test: (p) => {
|
|
const resolved = resolve(p);
|
|
return resolved.startsWith(`${HOME}/.aws/`) || resolved === `${HOME}/.aws`;
|
|
},
|
|
description: 'Writing to ~/.aws/ could compromise cloud credentials. Blocked.',
|
|
},
|
|
{
|
|
name: 'GnuPG directory',
|
|
test: (p) => {
|
|
const resolved = resolve(p);
|
|
return resolved.startsWith(`${HOME}/.gnupg/`) || resolved === `${HOME}/.gnupg`;
|
|
},
|
|
description: 'Writing to ~/.gnupg/ could compromise GPG keys. Blocked.',
|
|
},
|
|
{
|
|
name: 'Environment files (.env)',
|
|
test: (p) => /\/\.env(?:\.[a-zA-Z0-9]+)?$/.test(p),
|
|
description:
|
|
'Writing to .env files could expose or modify secrets. Blocked. ' +
|
|
'Use .env.template instead.',
|
|
},
|
|
];
|
|
|
|
// 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
|
|
// ---------------------------------------------------------------------------
|
|
let input;
|
|
try {
|
|
const raw = readFileSync(0, 'utf-8');
|
|
input = JSON.parse(raw);
|
|
} catch {
|
|
blockNoPath('unreadable hook input');
|
|
}
|
|
|
|
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') {
|
|
blockNoPath(`${input.tool_name} without tool_input.${field}`);
|
|
}
|
|
|
|
const resolved = resolve(filePath);
|
|
|
|
for (const rule of BLOCK_RULES) {
|
|
if (rule.test(resolved)) {
|
|
process.stderr.write(
|
|
`[voyage] BLOCKED: ${rule.name}\n` +
|
|
` Path: ${resolved}\n` +
|
|
` ${rule.description}\n`
|
|
);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
// Allow
|
|
process.exit(0);
|