voyage/tests/hooks/path-guard.test.mjs
Kjell Tore Guttormsen 2c89a6b580
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>
2026-10-06 15:54:47 +02:00

178 lines
8.2 KiB
JavaScript

// tests/hooks/path-guard.test.mjs
// Step 18 (plan-v2) — pins pre-write-executor.mjs BLOCK rules so a future
// silent weakening of the BLOCK_RULES list shows up as test failures
// instead of slipping through code review.
//
// Coverage: every BLOCK rule named in pre-write-executor.mjs gets at least
// one test. Allowlist examples (regular file paths, lib modules) confirm
// the hook does not over-block.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runHook } from '../helpers/hook-helper.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
const HOME = process.env.HOME || process.env.USERPROFILE || '/tmp';
function writeInput(file_path, content = 'x') {
return { tool_name: 'Write', tool_input: { file_path, content } };
}
// -----------------------------------------------------------------------
// BLOCK — Git hook injection (.git/hooks/)
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS .git/hooks/ writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput('/tmp/repo/.git/hooks/pre-commit'));
assert.strictEqual(code, 2, 'BLOCK exit code 2 expected for .git/hooks/ writes');
assert.match(stderr, /Git hook injection/, 'BLOCK message should reference the rule name');
});
test('pre-write-executor BLOCKS deeper .git/hooks/ paths', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/.git/hooks/post-receive'));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// BLOCK — Claude settings self-modification
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS .claude/settings.json writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput('/some/repo/.claude/settings.json'));
assert.strictEqual(code, 2);
assert.match(stderr, /Claude settings/);
});
test('pre-write-executor BLOCKS .claude/settings.local.json writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/.claude/settings.local.json'));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// BLOCK — Claude hooks self-modification
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS .claude/hooks/ writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput('/some/repo/.claude/hooks/some-hook.mjs'));
assert.strictEqual(code, 2);
assert.match(stderr, /Claude hooks/);
});
test('pre-write-executor BLOCKS .claude-plugin/ writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/.claude-plugin/plugin.json'));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// BLOCK — Shell configuration files
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS ~/.zshrc writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput(`${HOME}/.zshrc`));
assert.strictEqual(code, 2);
assert.match(stderr, /Shell configuration/);
});
test('pre-write-executor BLOCKS ~/.bashrc writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput(`${HOME}/.bashrc`));
assert.strictEqual(code, 2);
});
test('pre-write-executor BLOCKS ~/.zshenv writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput(`${HOME}/.zshenv`));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// BLOCK — SSH directory
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS ~/.ssh/ writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput(`${HOME}/.ssh/id_rsa`));
assert.strictEqual(code, 2);
assert.match(stderr, /SSH/);
});
test('pre-write-executor BLOCKS ~/.ssh/config writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput(`${HOME}/.ssh/config`));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// BLOCK — AWS credentials
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS ~/.aws/ writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput(`${HOME}/.aws/credentials`));
assert.strictEqual(code, 2);
assert.match(stderr, /AWS/);
});
// -----------------------------------------------------------------------
// BLOCK — GnuPG directory
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS ~/.gnupg/ writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput(`${HOME}/.gnupg/private-keys-v1.d/foo`));
assert.strictEqual(code, 2);
assert.match(stderr, /GnuPG/);
});
// -----------------------------------------------------------------------
// BLOCK — Environment files (.env)
// -----------------------------------------------------------------------
test('pre-write-executor BLOCKS .env writes', async () => {
const { code, stderr } = await runHook(PRE_WRITE, writeInput('/some/repo/.env'));
assert.strictEqual(code, 2);
assert.match(stderr, /Environment files/);
});
test('pre-write-executor BLOCKS .env.production writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/.env.production'));
assert.strictEqual(code, 2);
});
test('pre-write-executor BLOCKS .env.local writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/.env.local'));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// ALLOW — legitimate paths must not be blocked (over-block regression)
// -----------------------------------------------------------------------
test('pre-write-executor ALLOWS legitimate lib module writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/lib/util/foo.mjs'));
assert.strictEqual(code, 0, 'legitimate lib writes must not be blocked');
});
test('pre-write-executor ALLOWS test file writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/tests/lib/foo.test.mjs'));
assert.strictEqual(code, 0);
});
test('pre-write-executor ALLOWS docs writes', async () => {
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/docs/architecture.md'));
assert.strictEqual(code, 0);
});
test('pre-write-executor BLOCKS .env.template writes (current over-block behavior — pin)', async () => {
// The current .env regex (/\/\.env(?:\.[a-zA-Z0-9]+)?$/) blocks .env.X for
// ALL alphanumeric X, including the safe `.template` convention. This test
// pins the over-block as a known limitation. Loosening the rule to permit
// `.env.template` (e.g. via an allowlist) is fine — but it should be a
// deliberate change, not a silent weakening of BLOCK_RULES. If this test
// starts failing, that is the trigger to revisit the regex intentionally.
const { code } = await runHook(PRE_WRITE, writeInput('/some/repo/.env.template'));
assert.strictEqual(code, 2, 'current behavior pin: .env.template is blocked. If you intend to allow it, update both the hook and this test together.');
});
// -----------------------------------------------------------------------
// FAIL CLOSED — a write the guard cannot read a path from is blocked
// -----------------------------------------------------------------------
test('pre-write-executor fails closed on missing file_path', async () => {
const { code, stderr } = await runHook(PRE_WRITE, { tool_name: 'Write', tool_input: {} });
assert.strictEqual(code, 2, 'missing file_path must fail closed (exit 2)');
assert.match(stderr, /no target path/);
});
test('pre-write-executor fails closed on malformed JSON', async () => {
const { code } = await runHook(PRE_WRITE, 'not-json');
assert.strictEqual(code, 2, 'malformed JSON must fail closed (exit 2)');
});