// tests/hooks/approval-ask.test.mjs // P12 — approval as a guard. The Bash guard answers `permissionDecision: "ask"` // for merge into main, push, tag/release and destructive git, so a person sees // the call before it runs — also in auto mode (code.claude.com/docs/en/hooks // § PreToolUse decision control: «A hook's "ask" also forces a permission // prompt in auto mode»). Tested both ways per event: every "must ask" has a // "must pass" neighbour, so a guard that asks about everything fails here too. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { join } from 'node:path'; import { runHook } from '../helpers/hook-helper.mjs'; import { ROOT, scriptsFor, makeRepo, gitIn, parseJsonOut } from './guard-helpers.mjs'; const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs'); const { classifyGitApproval } = await import(join(ROOT, 'hooks', 'lib', 'git-approval.mjs')).catch(() => ({})); const MAIN_REPO = makeRepo('main'); const FEATURE_REPO = makeRepo('main'); gitIn(FEATURE_REPO, 'checkout', '-q', '-b', 'feat'); async function decide(command, cwd = FEATURE_REPO) { const r = await runHook(PRE_BASH, { hook_event_name: 'PreToolUse', tool_name: 'Bash', cwd, tool_input: { command } }); let out = null; try { out = parseJsonOut(r.stdout); } catch { out = null; } return { ...r, out, decision: out?.hookSpecificOutput?.permissionDecision ?? null }; } const MUST_ASK = [ // [command, cwd, why] ['git push origin main', FEATURE_REPO, 'push to main'], ['git push', FEATURE_REPO, 'push (any branch)'], ['git push origin feat', FEATURE_REPO, 'push of a feature branch'], ['git push --force-with-lease origin feat', FEATURE_REPO, 'force push'], ['git push --tags', FEATURE_REPO, 'push of tags'], ['git -C /tmp/x push origin main', FEATURE_REPO, 'git with -C before the subcommand'], ['FOO=1 git push origin main', FEATURE_REPO, 'env-prefixed push'], ['npm test && git push origin main', FEATURE_REPO, 'push after a test'], ['sh -c "git push origin main"', FEATURE_REPO, 'push inside a shell wrapper'], ['git merge --no-ff worktree-x', MAIN_REPO, 'merge while on main'], ['git merge --ff-only feat', MAIN_REPO, 'fast-forward merge while on main'], ['git checkout main && git merge feat', FEATURE_REPO, 'switch to main, then merge'], ['git switch master; git merge feat', FEATURE_REPO, 'switch to master, then merge'], ['git tag v1.2.3', FEATURE_REPO, 'create a tag'], ['git tag -a v1 -m "release"', FEATURE_REPO, 'annotated tag'], ['git tag -d v1', FEATURE_REPO, 'delete a tag'], ['npm publish', FEATURE_REPO, 'publish a package'], ['git reset --hard HEAD~1', FEATURE_REPO, 'hard reset'], ['git clean -fd', FEATURE_REPO, 'clean with force'], ['git clean -xdf', FEATURE_REPO, 'clean with combined flags'], ['git branch -D feat', FEATURE_REPO, 'force-delete a branch'], ['git branch --delete --force feat', FEATURE_REPO, 'force-delete, long flags'], ['git stash drop', FEATURE_REPO, 'drop a stash'], ['git stash clear', FEATURE_REPO, 'clear all stashes'], ['git checkout -- src/a.js', FEATURE_REPO, 'discard changes to a file'], ['git checkout .', FEATURE_REPO, 'discard all changes'], ['git restore src/a.js', FEATURE_REPO, 'restore the working tree'], ['git filter-branch --tree-filter x HEAD', FEATURE_REPO, 'history rewrite'], ]; const MUST_PASS = [ ['git status', FEATURE_REPO, 'read-only'], ['git log --oneline -5', FEATURE_REPO, 'read-only'], ['git merge-base --is-ancestor main HEAD', MAIN_REPO, 'merge-base is not a merge'], ['git merge feat', FEATURE_REPO, 'merge into a feature branch'], ['git merge --abort', MAIN_REPO, 'aborting a merge'], ['git tag', FEATURE_REPO, 'list tags'], ['git tag -l "v*"', FEATURE_REPO, 'list tags with a pattern'], ['git tag --list', FEATURE_REPO, 'list tags, long flag'], ['echo "git push origin main"', FEATURE_REPO, 'quoted data, not a command'], ['git commit -m "never git push to main"', FEATURE_REPO, 'commit message naming push'], ['git restore --staged src/a.js', FEATURE_REPO, 'unstage only'], ['git branch -d feat', FEATURE_REPO, 'safe branch delete'], ['git stash list', FEATURE_REPO, 'list stashes'], ['git checkout -b new-branch', FEATURE_REPO, 'create a branch'], ['git checkout main', FEATURE_REPO, 'switch branch'], ['git fetch origin', FEATURE_REPO, 'fetch'], ['npm test', FEATURE_REPO, 'tests'], ['git worktree remove /tmp/wt --force', FEATURE_REPO, 'worktree cleanup (Hard Rule 12)'], ['git clean -n', FEATURE_REPO, 'clean dry run'], ]; for (const [cmd, cwd, why] of MUST_ASK) { test(`P12 must ask: ${cmd} (${why})`, async () => { const r = await decide(cmd, cwd); assert.equal(r.code, 0, `ask is a JSON answer on exit 0, not a block; stderr: ${r.stderr}`); assert.equal(r.decision, 'ask', `expected permissionDecision "ask" for \`${cmd}\`; stdout: ${r.stdout}`); assert.equal(r.out.hookSpecificOutput.hookEventName, 'PreToolUse'); const reason = r.out.hookSpecificOutput.permissionDecisionReason; assert.match(reason, /Voyage/, 'the prompt names Voyage as the asker'); assert.match(reason, /Way forward:/, 'the prompt explains the way forward'); }); } for (const [cmd, cwd, why] of MUST_PASS) { test(`P12 must pass: ${cmd} (${why})`, async () => { const r = await decide(cmd, cwd); assert.equal(r.code, 0, `stderr: ${r.stderr}`); assert.equal(r.decision, null, `no ask expected for \`${cmd}\`; stdout: ${r.stdout}`); }); } test('P12: a BLOCK rule still wins over ask (exit 2, no JSON)', async () => { const r = await decide('git push origin main; rm -rf /'); assert.equal(r.code, 2); }); test('P12: the Bash guard that asks is the one hooks.json runs for Bash', () => { const scripts = scriptsFor('PreToolUse', 'Bash'); assert.ok(scripts.some((s) => s.endsWith('pre-bash-executor.mjs')), JSON.stringify(scripts)); }); test('P12: approval kinds follow the operator decision (06.10): merge/push to main = merge, tag/publish = release, the rest unlogged', () => { assert.equal(typeof classifyGitApproval, 'function', 'hooks/lib/git-approval.mjs must export classifyGitApproval'); const kinds = (cmd, branch) => classifyGitApproval(cmd, { branch }).map((m) => m.kind); assert.deepEqual(kinds('git merge feat', 'main'), ['merge']); assert.deepEqual(kinds('git push origin main', 'feat'), ['merge']); assert.deepEqual(kinds('git push origin HEAD:refs/heads/main', 'feat'), ['merge']); assert.deepEqual(kinds('git push', 'main'), ['merge'], 'a bare push from main pushes main'); assert.deepEqual(kinds('git push origin feat', 'feat'), [null]); assert.deepEqual(kinds('git tag v1', 'feat'), ['release']); assert.deepEqual(kinds('git push --tags', 'feat'), ['release']); assert.deepEqual(kinds('npm publish', 'feat'), ['release']); assert.deepEqual(kinds('git reset --hard', 'feat'), [null]); assert.deepEqual(kinds('git status', 'main'), []); });