import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import { gitArgvIn } from './helpers/scratch-repo'; const ROOT = path.resolve(import.meta.dir, '..'); const CAREFUL_SCRIPT = path.join(ROOT, 'careful', 'bin', 'check-careful.sh'); const FREEZE_SCRIPT = path.join(ROOT, 'freeze', 'bin', 'check-freeze.sh'); function runHook(scriptPath: string, input: object, env?: Record, cwd?: string): { exitCode: number; output: any; raw: string } { const result = spawnSync('bash', [scriptPath], { input: JSON.stringify(input), stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...env }, cwd, timeout: 5000, }); const raw = result.stdout.toString().trim(); let output: any = {}; try { output = JSON.parse(raw); } catch {} return { exitCode: result.status ?? 1, output, raw }; } // Scratch git repo with a resolvable origin default branch — the HIGH-tier // force-push check reads `git symbolic-ref refs/remotes/origin/HEAD` from the // hook's cwd, and Conductor worktrees don't reliably carry that ref. function withGitRepo(defaultBranch: string, currentBranch: string, fn: (repoDir: string) => void) { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-git-')); try { const git = (args: string[]) => gitArgvIn(repoDir, args); git(['init', '-q', '-b', defaultBranch]); git(['commit', '--allow-empty', '-q', '-m', 'init']); // A symbolic ref may dangle; the hook only reads its NAME. git(['symbolic-ref', 'refs/remotes/origin/HEAD', `refs/remotes/origin/${defaultBranch}`]); if (currentBranch !== defaultBranch) git(['checkout', '-q', '-b', currentBranch]); fn(repoDir); } finally { fs.rmSync(repoDir, { recursive: true, force: true }); } } function runHookRaw(scriptPath: string, rawInput: string, env?: Record): { exitCode: number; output: any; raw: string } { const result = spawnSync('bash', [scriptPath], { input: rawInput, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...env }, timeout: 5000, }); const raw = result.stdout.toString().trim(); let output: any = {}; try { output = JSON.parse(raw); } catch {} return { exitCode: result.status ?? 1, output, raw }; } function carefulInput(command: string) { return { tool_input: { command } }; } function freezeInput(filePath: string) { return { tool_input: { file_path: filePath } }; } function withFreezeDir(freezePath: string, fn: (stateDir: string) => void) { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-test-')); fs.writeFileSync(path.join(stateDir, 'freeze-dir.txt'), freezePath); try { fn(stateDir); } finally { fs.rmSync(stateDir, { recursive: true, force: true }); } } // ============================================================ // Frontmatter hook wiring (#2469 / #1871) // ============================================================ // Frontmatter hooks run before any runtime variable exists, so a // ${CLAUDE_SKILL_DIR}-relative command silently never resolves and the guard // never fires. Every command: line must anchor on $HOME like careful/freeze. describe('frontmatter hook command paths', () => { test.each(['investigate/SKILL.md', 'careful/SKILL.md', 'freeze/SKILL.md', 'guard/SKILL.md'])( '%s hook commands are $HOME-anchored, never CLAUDE_SKILL_DIR', (rel) => { const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8'); const commandLines = content.split('\n').filter((l) => l.trim().startsWith('command:')); expect(commandLines.length).toBeGreaterThan(0); for (const line of commandLines) { expect(line).not.toContain('CLAUDE_SKILL_DIR'); expect(line).toContain('$HOME/.claude/skills/gstack/'); } }, ); }); // ============================================================ // check-careful.sh tests // ============================================================ describe('check-careful.sh', () => { // --- Destructive rm commands --- describe('rm -rf / rm -r', () => { test('rm -rf /var/data warns with recursive delete message', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /var/data')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); test('rm -r ./some-dir warns', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -r ./some-dir')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); test('rm -rf node_modules allows (safe exception)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf node_modules')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('rm -rf .next dist allows (multiple safe targets)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf .next dist')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('rm -rf node_modules /var/data warns (mixed safe+unsafe)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf node_modules /var/data')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); // The safe exception matches the COMPLETE command against an anchored // whitelist shape — anything else (chains, comments, substitution) falls // through to the destructive-pattern warning. test('rm -rf /; rm -rf node_modules warns (semicolon chain, dangerous first)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /; rm -rf node_modules')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); test('rm -rf /etc/data && rm -rf dist warns (&& chain, dangerous first)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /etc/data && rm -rf dist')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); test('rm -rf node_modules; rm -rf /home/user/data warns (safe first, dangerous last)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf node_modules; rm -rf /home/user/data')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); // Command substitution can end in a whitelisted suffix while running // anything inside $(...) or backticks — the whitelist's target tokens // exclude `(` and backtick so these cannot ride the safe exception. test('rm -rf $(./wipe-all)/node_modules warns (command substitution)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf $(./wipe-all)/node_modules')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); test('rm -rf `./wipe-all`/node_modules warns (backtick substitution)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf `./wipe-all`/node_modules')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); // Capital -R is the documented recursive flag on BSD rm (macOS) and accepted // by GNU rm. Both greps previously required a lowercase r, so `rm -R /` // silently allowed. A bare recursive delete of / is now HIGH-tier: denied, // not asked. test('rm -R / denies (HIGH tier: recursive delete of root)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -R /')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('HIGH'); }); test('rm -fR /home/user warns (capital R in flag cluster)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -fR /home/user')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); test('rm -Rf node_modules allows (capital R, single safe target)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -Rf node_modules')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); // The old grep extractor stopped at the first escaped quote in the JSON // string, so any quoted argument truncated the command BEFORE the pattern // checks ran — hiding everything after it. (#2426) test.each([ 'git commit -m "wip" && rm -rf /', 'bash -c "rm -rf /"', 'echo "x"; rm -rf ~', 'npm run build --msg "done" && rm -rf /', ])('a quoted argument cannot hide a later destructive command: %s', (command) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); // JSON-escaped newline (literal two-char \n surviving the grep extraction // path) breaks the anchored whitelist shape → falls through to the warn. test('newline-chained rm warns (escaped-newline separator branch)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /etc/x\nrm -rf node_modules')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); // Deliberate false positive, pinned: a safe-prefix chain ending in a safe rm // is indistinguishable from the dangerous-first exploit shape without real // shell parsing, so warn-on-all-chains is the designed fail-closed direction. // A future per-segment parser must consciously change this test. test('cd app && rm -rf node_modules asks (fail-closed on chains, by design)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('cd app && rm -rf node_modules')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); test.each([ 'rm -rf /; rm -rf node_modules', 'rm -rf / && rm -rf node_modules', 'rm -rf / # rm -rf node_modules', 'rm -rf node_modules; rm -rf /', 'rm -rf node_modules || rm -rf /', 'echo ok && rm -rf /', 'rm -rf node_modules\nrm -rf /', ])('never lets a safe-looking target hide a destructive command: %s', (command) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); }); // --- Shell obfuscation --- describe('shell obfuscation', () => { test.each([ 'rm${IFS}-rf${IFS}/', 'rm$IFS-rf$IFS/', 'echo cm0gLXJmIC8= | base64 -d | sh', ])('asks when the command hides its shape behind expansion: %s', (command) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('obfuscation'); }); test('ordinary commands are unaffected', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('cat file.b64 | base64 -d > out.bin')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); // --- JSON payload extraction --- describe('command extraction', () => { test('fails closed when the payload is not valid JSON', () => { const { exitCode, output } = runHookRaw(CAREFUL_SCRIPT, 'this is not json'); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('parse'); }); test('allows a well-formed payload with no command field', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, { tool_input: { file_path: '/tmp/x' } }); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('allows when command is present but not a string', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, { tool_input: { command: 42 } }); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('preserves escaped quotes in the extracted command', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('echo "hello world"')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); // --- SQL destructive commands --- // Embedded double quotes are now safe to use here. They previously truncated the // extracted command (the grep-based extractor stopped at the first \"), which hid // the SQL keyword from the pattern matcher — so the older tests had to be written // without quotes, in a shape no one actually types. The JSON-parser extraction // fixed that, and the quoted forms below are the realistic ones. describe('SQL destructive commands', () => { test('psql DROP TABLE warns with DROP in message', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('psql -c DROP TABLE users;')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('DROP'); }); test.each([ 'psql -c "DROP TABLE users"', 'psql -c "TRUNCATE orders"', 'mysql -e "DROP DATABASE prod"', ])('a quoted SQL statement is still inspected: %s', (command) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); }); test('mysql drop database warns (case insensitive)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('mysql -e drop database mydb')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason.toLowerCase()).toContain('drop'); }); test('psql TRUNCATE warns', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('psql -c TRUNCATE orders;')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('TRUNCATE'); }); }); // --- Git destructive commands --- describe('git destructive commands', () => { // Force-push to a NON-default branch is MEDIUM (ask). Force-push to the // default branch is HIGH (deny) — covered in the HIGH tier describe. The // fixture repo pins the default branch so the split is deterministic // regardless of the host repo's origin/HEAD. test('git push --force warns with force-push (non-default target)', () => { withGitRepo('trunk', 'trunk', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push --force origin main'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push'); }); }); test('git push -f warns (non-default target)', () => { withGitRepo('trunk', 'trunk', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push -f origin main'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push'); }); }); test('git reset --hard warns with uncommitted', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git reset --hard HEAD~3')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('uncommitted'); }); test('git checkout . warns', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git checkout .')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('uncommitted'); }); test('git restore . warns', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git restore .')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('uncommitted'); }); }); // --- Container / infra destructive commands --- describe('container and infra commands', () => { test('kubectl delete warns with kubectl in message', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('kubectl delete pod my-pod')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('kubectl'); }); test('docker rm -f warns', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('docker rm -f container123')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Docker'); }); test('docker system prune -a warns', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('docker system prune -a')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Docker'); }); }); // --- Safe commands --- describe('safe commands allow without warning', () => { const safeCmds = [ 'ls -la', 'git status', 'npm install', 'cat README.md', 'echo hello', ]; for (const cmd of safeCmds) { test(`"${cmd}" allows`, () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(cmd)); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); } }); // --- Edge cases --- describe('edge cases', () => { test('empty command allows gracefully', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('missing command field allows gracefully', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, { tool_input: {} }); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('malformed JSON input fails CLOSED (asks instead of allowing)', () => { // Pre-#2426 this allowed (`{}`) — a hook that gates destructive commands // must not allow-by-default on input it cannot read. const { exitCode, output } = runHookRaw(CAREFUL_SCRIPT, 'this is not json at all{{{{'); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('parse'); }); test('Python fallback: grep fails on multiline JSON, Python parses it', () => { // Construct JSON where "command": and the value are on separate lines. // grep works line-by-line, so it cannot match "command"..."value" across lines. // This forces CMD to be empty, triggering the Python fallback which handles // the full JSON correctly. const rawJson = '{"tool_input":{"command":\n"rm -rf /tmp/important"}}'; const { exitCode, output } = runHookRaw(CAREFUL_SCRIPT, rawJson); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); }); // --- HIGH tier (hard deny) --- // A tiny set of catastrophic SIMPLE commands is denied outright while // /careful is active. Best-effort advisory hard-stop, not a policy boundary: // compound commands always fall through to the MEDIUM ask. describe('HIGH tier (hard deny)', () => { test.each(['rm -rf /', 'rm -rf ~', 'rm -rf $HOME', 'sudo rm -rf /', 'rm -Rf ~/'])( 'denies catastrophic recursive delete: %s', (command) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('HIGH'); }, ); test('rm -rf ~/subdir stays MEDIUM ask (not the whole home dir)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf ~/subdir')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); }); test('git push --force origin denies', () => { withGitRepo('main', 'feature', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push --force origin main'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('default branch'); }); }); test('bare git push --force while ON the default branch denies', () => { withGitRepo('main', 'main', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push --force'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('HIGH'); }); }); test('bare git push --force on a feature branch asks (MEDIUM)', () => { withGitRepo('main', 'feature', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push --force'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push'); }); }); test('git push -f origin feature asks (MEDIUM — not the default branch)', () => { withGitRepo('main', 'main', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push -f origin feature'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push'); }); }); test('compound force-push falls through to ask, never deny (cannot resolve cwd)', () => { withGitRepo('main', 'main', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('cd elsewhere && git push --force origin main'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); }); }); test.each(['rm -rf --no-preserve-root /', 'rm -rf / --no-preserve-root', 'rm -rf /*'])( 'denies catastrophic rm variant: %s', (command) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('HIGH'); }, ); test('plus-refspec force to the default branch denies (git push origin +main)', () => { withGitRepo('main', 'feature', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push origin +main'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('HIGH'); }); }); test('refspec-form force to the default branch denies (git push -f origin HEAD:main)', () => { withGitRepo('main', 'feature', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push -f origin HEAD:main'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); }); }); test('plus-refspec force to a FEATURE branch asks (MEDIUM, not silent allow)', () => { withGitRepo('main', 'main', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push origin +feature'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push'); }); }); test('slashed default branch is matched whole (git push -f origin release/2.0)', () => { withGitRepo('release/2.0', 'feature', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push -f origin release/2.0'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('release/2.0'); }); }); test.each(['rm -rf "/"', "rm -rf '~'", 'rm -rf //'])('quoted root targets still deny: %s', (command) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); }); test('quoted default-branch ref still denies (git push -f origin "main")', () => { withGitRepo('main', 'feature', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push -f origin "main"'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); }); }); test('missing origin/HEAD symbolic ref falls back to origin/main probe (Conductor worktrees)', () => { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-nohead-')); try { const git = (args: string[]) => gitArgvIn(repoDir, args); git(['init', '-q', '-b', 'main']); git(['commit', '--allow-empty', '-q', '-m', 'init']); // No symbolic-ref — only a plain remote-tracking ref, like a Conductor worktree. git(['update-ref', 'refs/remotes/origin/main', 'HEAD']); git(['checkout', '-q', '-b', 'feature']); const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push --force origin main'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); } finally { fs.rmSync(repoDir, { recursive: true, force: true }); } }); test('--force-with-lease is never HIGH (the safe force variant)', () => { withGitRepo('main', 'main', (repoDir) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push --force-with-lease origin main'), undefined, repoDir); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).not.toBe('deny'); }); }); }); // --- Additive project patterns --- // Config can only ADD warn rules. The files are consulted after the baseline // families, so no file content can suppress a baseline match. describe('additive project patterns', () => { function withPatternFile(content: string, fn: (gstackHome: string) => void) { const gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-pat-')); fs.writeFileSync(path.join(gstackHome, 'careful-patterns.txt'), content); try { fn(gstackHome); } finally { fs.rmSync(gstackHome, { recursive: true, force: true }); } } test('a project pattern adds an ask rule', () => { withPatternFile('# infra safety\nterraform\\s+destroy\n', (gstackHome) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('terraform destroy -auto-approve'), { GSTACK_HOME: gstackHome }); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Project rule'); }); }); test('a garbage pattern file cannot suppress a baseline match (additive invariant)', () => { withPatternFile('# override: allow everything\nallow-everything\nignore baseline\n', (gstackHome) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /var/data'), { GSTACK_HOME: gstackHome }); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); }); test('an invalid regex line is skipped without breaking the hook', () => { withPatternFile('([unclosed\nterraform\\s+destroy\n', (gstackHome) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('terraform destroy'), { GSTACK_HOME: gstackHome }); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Project rule'); }); }); test('safe commands still allow with a pattern file present', () => { withPatternFile('terraform\\s+destroy\n', (gstackHome) => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('ls -la'), { GSTACK_HOME: gstackHome }); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); }); }); // ============================================================ // check-freeze.sh tests // ============================================================ describe('check-freeze.sh', () => { describe('edits inside freeze boundary', () => { test('edit inside freeze boundary allows', () => { withFreezeDir('/Users/dev/project/src/', (stateDir) => { const { exitCode, output } = runHook( FREEZE_SCRIPT, freezeInput('/Users/dev/project/src/index.ts'), { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); test('edit in subdirectory of freeze path allows', () => { withFreezeDir('/Users/dev/project/src/', (stateDir) => { const { exitCode, output } = runHook( FREEZE_SCRIPT, freezeInput('/Users/dev/project/src/components/Button.tsx'), { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); }); describe('edits outside freeze boundary', () => { test('edit outside freeze boundary denies', () => { withFreezeDir('/Users/dev/project/src/', (stateDir) => { const { exitCode, output } = runHook( FREEZE_SCRIPT, freezeInput('/Users/dev/other-project/index.ts'), { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('freeze'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('outside'); }); }); test('write outside freeze boundary denies', () => { withFreezeDir('/Users/dev/project/src/', (stateDir) => { const { exitCode, output } = runHook( FREEZE_SCRIPT, freezeInput('/etc/hosts'), { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('freeze'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('outside'); }); }); }); describe('trailing slash prevents prefix confusion', () => { test('freeze at /src/ denies /src-old/ (trailing slash prevents prefix match)', () => { withFreezeDir('/Users/dev/project/src/', (stateDir) => { const { exitCode, output } = runHook( FREEZE_SCRIPT, freezeInput('/Users/dev/project/src-old/index.ts'), { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('outside'); }); }); }); describe('no freeze file exists', () => { test('allows everything when no freeze file present', () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-test-')); try { const { exitCode, output } = runHook( FREEZE_SCRIPT, freezeInput('/anywhere/at/all.ts'), { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); } finally { fs.rmSync(stateDir, { recursive: true, force: true }); } }); }); describe('edge cases', () => { test('missing file_path field allows gracefully', () => { withFreezeDir('/Users/dev/project/src/', (stateDir) => { const { exitCode, output } = runHook( FREEZE_SCRIPT, { tool_input: {} }, { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); test('malformed JSON payload DENIES (fail closed — freeze is a deny-tier hook)', () => { withFreezeDir('/Users/dev/project/src/', (stateDir) => { const { exitCode, output } = runHookRaw( FREEZE_SCRIPT, 'not json at all {{{{', { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('fail closed'); }); }); test('a quote-bearing path outside the boundary emits PARSEABLE deny JSON', () => { // The old printf-interpolated deny emitted malformed JSON for paths // containing quotes — Claude Code silently ignored the whole decision, // so the deny no-oped exactly when the path was hostile. withFreezeDir('/Users/dev/project/src/', (stateDir) => { const { exitCode, output, raw } = runHook( FREEZE_SCRIPT, freezeInput('/tmp/evil"quoted/x.ts'), { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); expect(() => JSON.parse(raw)).not.toThrow(); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); }); }); test('a newline-bearing path outside the boundary emits PARSEABLE deny JSON', () => { withFreezeDir('/Users/dev/project/src/', (stateDir) => { const { exitCode, output, raw } = runHook( FREEZE_SCRIPT, freezeInput('/tmp/evil\npath.ts'), { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); expect(() => JSON.parse(raw)).not.toThrow(); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); }); }); }); describe('space-bearing freeze boundary', () => { // The old `tr -d '[:space:]'` stripped INTERNAL spaces from the freeze // path, so a boundary like ".../My Project/src" never matched anything. test('a boundary containing spaces allows edits inside it', () => { const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-space-')); const boundary = path.join(base, 'My Project', 'src'); fs.mkdirSync(boundary, { recursive: true }); try { withFreezeDir(boundary + '/', (stateDir) => { const inside = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'index.ts')), { CLAUDE_PLUGIN_DATA: stateDir }); expect(inside.exitCode).toBe(0); expect(inside.output.hookSpecificOutput?.permissionDecision).toBeUndefined(); const outside = runHook(FREEZE_SCRIPT, freezeInput(path.join(base, 'elsewhere.ts')), { CLAUDE_PLUGIN_DATA: stateDir }); expect(outside.exitCode).toBe(0); expect(outside.output.hookSpecificOutput?.permissionDecision).toBe('deny'); }); } finally { fs.rmSync(base, { recursive: true, force: true }); } }); }); describe('broken install fails closed', () => { test('a missing hook-extract helper DENIES instead of proceeding', () => { // Copy the freeze hook into a tree with NO careful sibling — the source // fails, and a deny-tier boundary must fail CLOSED, not fall through. const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-broken-')); const binDir = path.join(base, 'freeze', 'bin'); fs.mkdirSync(binDir, { recursive: true }); const script = path.join(binDir, 'check-freeze.sh'); fs.copyFileSync(FREEZE_SCRIPT, script); try { withFreezeDir('/Users/dev/project/src/', (stateDir) => { const { exitCode, output } = runHook(script, freezeInput('/Users/dev/project/src/x.ts'), { CLAUDE_PLUGIN_DATA: stateDir }); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('fail closed'); }); } finally { fs.rmSync(base, { recursive: true, force: true }); } }); }); describe('symlink boundary escape', () => { // The old resolver followed the parent directory but NOT the final path // component, so an in-boundary symlink pointing outside the boundary was // allowed while the write landed outside. test('an in-boundary symlink to an outside target denies', () => { const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-link-')); const boundary = path.join(base, 'boundary'); const outside = path.join(base, 'outside'); fs.mkdirSync(boundary, { recursive: true }); fs.mkdirSync(outside, { recursive: true }); fs.writeFileSync(path.join(outside, 'secret.txt'), 'x'); fs.symlinkSync(path.join(outside, 'secret.txt'), path.join(boundary, 'link.txt')); try { withFreezeDir(boundary + '/', (stateDir) => { const viaLink = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'link.txt')), { CLAUDE_PLUGIN_DATA: stateDir }); expect(viaLink.exitCode).toBe(0); expect(viaLink.output.hookSpecificOutput?.permissionDecision).toBe('deny'); // A real in-boundary file is unaffected. fs.writeFileSync(path.join(boundary, 'real.txt'), 'y'); const real = runHook(FREEZE_SCRIPT, freezeInput(path.join(boundary, 'real.txt')), { CLAUDE_PLUGIN_DATA: stateDir }); expect(real.exitCode).toBe(0); expect(real.output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); } finally { fs.rmSync(base, { recursive: true, force: true }); } }); }); });