// tests/hooks/write-guard-coverage.test.mjs // Pins WHICH tools the write guard (pre-write-executor.mjs) fires on, not just // what it blocks. path-guard.test.mjs runs the script directly, so it stayed // green while hooks.json wired the guard to `Write` alone and `Edit` / // `NotebookEdit` reached .git/hooks/, .env and shell configs unguarded. // // The dispatch tests below resolve hooks.json the way Claude Code does // (matcher semantics: code.claude.com/docs/en/hooks ยง Matcher patterns) and run // every PreToolUse hook that would fire for the tool, so a matcher gap fails // here even when the script itself is correct. // // Field names: Write and Edit carry `file_path` (hooks reference, PreToolUse // tool_input tables); NotebookEdit carries `notebook_path` (the tool's own // input schema; the hooks reference has no NotebookEdit table). import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFileSync } from 'node:fs'; 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 HOOKS_JSON = join(ROOT, 'hooks', 'hooks.json'); const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs'); const WRITE_TOOLS = ['Write', 'Edit', 'NotebookEdit']; // Claude Code matcher semantics: "*", "" or omitted match all; a value of only // letters, digits, _, -, spaces, `,` and `|` is a list of exact names; anything // else is an unanchored JavaScript regular expression. function matcherFires(matcher, toolName) { if (matcher === undefined || matcher === '' || matcher === '*') return true; if (/^[A-Za-z0-9_\- ,|]+$/.test(matcher)) { return matcher.split(/[|,]/).map((s) => s.trim()).includes(toolName); } return new RegExp(matcher).test(toolName); } function preToolUseScriptsFor(toolName) { const cfg = JSON.parse(readFileSync(HOOKS_JSON, 'utf8')); const scripts = []; for (const entry of cfg.hooks.PreToolUse || []) { if (!matcherFires(entry.matcher, toolName)) continue; for (const h of entry.hooks || []) { const arg = (h.args || []).find((a) => a.endsWith('.mjs')); if (arg) scripts.push(arg.replace('${CLAUDE_PLUGIN_ROOT}', ROOT)); } } return scripts; } // Runs every PreToolUse hook Claude Code would run for this call; the call is // blocked when any of them exits 2. async function dispatch(tool_name, tool_input) { const results = []; for (const script of preToolUseScriptsFor(tool_name)) { results.push(await runHook(script, { hook_event_name: 'PreToolUse', tool_name, tool_input })); } return { blocked: results.some((r) => r.code === 2), results }; } function editInput(file_path) { return { file_path, old_string: 'a', new_string: 'b', replace_all: false }; } function notebookInput(notebook_path) { return { notebook_path, cell_id: 'c1', new_source: 'x', edit_mode: 'replace' }; } // ----------------------------------------------------------------------- // (a) The matcher covers every file-writing tool, and nothing else // ----------------------------------------------------------------------- test('hooks.json wires pre-write-executor to Write, Edit and NotebookEdit', () => { for (const tool of WRITE_TOOLS) { const scripts = preToolUseScriptsFor(tool); assert.ok( scripts.some((s) => s.endsWith('pre-write-executor.mjs')), `pre-write-executor must fire on ${tool}; fires: ${JSON.stringify(scripts)}`, ); } }); test('hooks.json does not wire pre-write-executor to read-only tools', () => { // The guard fails closed without a path, so firing on Read would block // reads of .env and friends. Coverage must be exact, not "*". for (const tool of ['Read', 'Glob', 'Grep', 'Bash']) { const scripts = preToolUseScriptsFor(tool); assert.ok( !scripts.some((s) => s.endsWith('pre-write-executor.mjs')), `pre-write-executor must not fire on ${tool}`, ); } }); // ----------------------------------------------------------------------- // (b) An Edit against a protected path is stopped // ----------------------------------------------------------------------- test('dispatch BLOCKS Edit to .git/hooks/', async () => { const { blocked } = await dispatch('Edit', editInput('/some/repo/.git/hooks/pre-commit')); assert.ok(blocked, 'Edit to .git/hooks/ must be blocked'); }); test('dispatch BLOCKS Edit to .env', async () => { const { blocked } = await dispatch('Edit', editInput('/some/repo/.env')); assert.ok(blocked, 'Edit to .env must be blocked'); }); test('dispatch BLOCKS Write to .git/hooks/ (unchanged)', async () => { const { blocked } = await dispatch('Write', { file_path: '/some/repo/.git/hooks/pre-commit', content: 'x' }); assert.ok(blocked); }); // ----------------------------------------------------------------------- // (c) A NotebookEdit is stopped via notebook_path // ----------------------------------------------------------------------- test('pre-write-executor BLOCKS NotebookEdit via notebook_path', async () => { const { code, stderr } = await runHook(PRE_WRITE, { tool_name: 'NotebookEdit', tool_input: notebookInput('/some/repo/.claude/hooks/evil.ipynb'), }); assert.strictEqual(code, 2, 'notebook_path must be read and checked'); assert.match(stderr, /Claude hooks/); }); test('dispatch BLOCKS NotebookEdit to .git/hooks/', async () => { const { blocked } = await dispatch('NotebookEdit', notebookInput('/some/repo/.git/hooks/nb.ipynb')); assert.ok(blocked, 'NotebookEdit to .git/hooks/ must be blocked'); }); // ----------------------------------------------------------------------- // (d) Ordinary, legitimate edits pass (the other direction) // ----------------------------------------------------------------------- test('dispatch ALLOWS a legitimate Edit', async () => { const { blocked, results } = await dispatch('Edit', editInput('/some/repo/lib/util/foo.mjs')); assert.ok(!blocked, `legitimate Edit must pass: ${JSON.stringify(results)}`); }); test('dispatch ALLOWS a legitimate NotebookEdit', async () => { const { blocked, results } = await dispatch('NotebookEdit', notebookInput('/some/repo/notebooks/analysis.ipynb')); assert.ok(!blocked, `legitimate NotebookEdit must pass: ${JSON.stringify(results)}`); }); // ----------------------------------------------------------------------- // Fail closed: no path found means no write // ----------------------------------------------------------------------- test('dispatch BLOCKS an Edit with no path (fail closed)', async () => { const { blocked } = await dispatch('Edit', { old_string: 'a', new_string: 'b' }); assert.ok(blocked, 'a write call with no readable path must be blocked'); }); test('pre-write-executor BLOCKS NotebookEdit carrying file_path but no notebook_path', async () => { // NotebookEdit's path field is notebook_path; any other shape is unknown and // must not be waved through. const { code } = await runHook(PRE_WRITE, { tool_name: 'NotebookEdit', tool_input: { file_path: '/some/repo/notebooks/a.ipynb', new_source: 'x' }, }); assert.strictEqual(code, 2); });