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'; 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): { exitCode: number; output: any; raw: string } { const result = spawnSync('bash', [scriptPath], { input: JSON.stringify(input), 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 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. test('rm -R / warns (capital -R recursive)', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -R /')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); 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', () => { test('git push --force warns with force-push', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push --force origin main')); expect(exitCode).toBe(0); expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push'); }); test('git push -f warns', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push -f origin main')); 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'); }); }); }); // ============================================================ // 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(); }); }); }); });