// 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 OPEN — malformed input must not crash the hook chain // ----------------------------------------------------------------------- test('pre-write-executor fails open on missing file_path', async () => { const { code } = await runHook(PRE_WRITE, { tool_name: 'Write', tool_input: {} }); assert.strictEqual(code, 0, 'missing file_path should fail open (exit 0)'); }); test('pre-write-executor fails open on malformed JSON', async () => { const { code } = await runHook(PRE_WRITE, 'not-json'); assert.strictEqual(code, 0, 'malformed JSON should fail open (exit 0)'); });