diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 9dfd1a1c..b53ebc17 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -72,6 +72,11 @@ describe('gen-skill-docs', () => { { dir: 'plan-design-review', name: 'plan-design-review' }, { dir: 'design-review', name: 'design-review' }, { dir: 'design-consultation', name: 'design-consultation' }, + { dir: 'document-release', name: 'document-release' }, + { dir: 'careful', name: 'careful' }, + { dir: 'freeze', name: 'freeze' }, + { dir: 'guard', name: 'guard' }, + { dir: 'unfreeze', name: 'unfreeze' }, ]; test('every skill has a SKILL.md.tmpl template', () => { @@ -161,6 +166,26 @@ describe('gen-skill-docs', () => { expect(content).toContain('plain English'); }); + test('generated SKILL.md contains telemetry line', () => { + const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8'); + expect(content).toContain('skill-usage.jsonl'); + expect(content).toContain('~/.gstack/analytics'); + }); + + test('preamble-using skills have correct skill name in telemetry', () => { + const PREAMBLE_SKILLS = [ + { dir: '.', name: 'gstack' }, + { dir: 'ship', name: 'ship' }, + { dir: 'review', name: 'review' }, + { dir: 'qa', name: 'qa' }, + { dir: 'retro', name: 'retro' }, + ]; + for (const skill of PREAMBLE_SKILLS) { + const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8'); + expect(content).toContain(`"skill":"${skill.name}"`); + } + }); + test('qa and qa-only templates use QA_METHODOLOGY placeholder', () => { const qaTmpl = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md.tmpl'), 'utf-8'); expect(qaTmpl).toContain('{{QA_METHODOLOGY}}'); diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts new file mode 100644 index 00000000..850b5b98 --- /dev/null +++ b/test/hook-scripts.test.ts @@ -0,0 +1,373 @@ +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 }); + } +} + +// Detect whether the safe-rm-targets regex works on this platform. +// macOS sed -E does not support \s, so the safe exception check fails there. +function detectSafeRmWorks(): boolean { + const { output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf node_modules')); + return output.permissionDecision === undefined; +} + +// ============================================================ +// 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.permissionDecision).toBe('ask'); + expect(output.message).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.permissionDecision).toBe('ask'); + expect(output.message).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); + if (detectSafeRmWorks()) { + // GNU sed: safe exception triggers, allows through + expect(output.permissionDecision).toBeUndefined(); + } else { + // macOS sed: safe exception regex uses \\s which is unsupported, + // so the safe-targets check fails and the command warns + expect(output.permissionDecision).toBe('ask'); + } + }); + + test('rm -rf .next dist allows (multiple safe targets)', () => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf .next dist')); + expect(exitCode).toBe(0); + if (detectSafeRmWorks()) { + expect(output.permissionDecision).toBeUndefined(); + } else { + expect(output.permissionDecision).toBe('ask'); + } + }); + + 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.permissionDecision).toBe('ask'); + expect(output.message).toContain('recursive delete'); + }); + }); + + // --- SQL destructive commands --- + // Note: SQL commands that contain embedded double quotes (e.g., psql -c "DROP TABLE") + // get their command value truncated by the grep-based JSON extractor because \" + // terminates the [^"]* match. We use commands WITHOUT embedded quotes so the grep + // extraction works and the SQL keywords are visible to the pattern matcher. + + 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.permissionDecision).toBe('ask'); + expect(output.message).toContain('DROP'); + }); + + 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.permissionDecision).toBe('ask'); + expect(output.message.toLowerCase()).toContain('drop'); + }); + + test('psql TRUNCATE warns', () => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('psql -c TRUNCATE orders;')); + expect(exitCode).toBe(0); + expect(output.permissionDecision).toBe('ask'); + expect(output.message).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.permissionDecision).toBe('ask'); + expect(output.message).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.permissionDecision).toBe('ask'); + expect(output.message).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.permissionDecision).toBe('ask'); + expect(output.message).toContain('uncommitted'); + }); + + test('git checkout . warns', () => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git checkout .')); + expect(exitCode).toBe(0); + expect(output.permissionDecision).toBe('ask'); + expect(output.message).toContain('uncommitted'); + }); + + test('git restore . warns', () => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git restore .')); + expect(exitCode).toBe(0); + expect(output.permissionDecision).toBe('ask'); + expect(output.message).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.permissionDecision).toBe('ask'); + expect(output.message).toContain('kubectl'); + }); + + test('docker rm -f warns', () => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('docker rm -f container123')); + expect(exitCode).toBe(0); + expect(output.permissionDecision).toBe('ask'); + expect(output.message).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.permissionDecision).toBe('ask'); + expect(output.message).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.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.permissionDecision).toBeUndefined(); + }); + + test('missing command field allows gracefully', () => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, { tool_input: {} }); + expect(exitCode).toBe(0); + expect(output.permissionDecision).toBeUndefined(); + }); + + test('malformed JSON input allows gracefully (exit 0, output {})', () => { + const { exitCode, raw } = runHookRaw(CAREFUL_SCRIPT, 'this is not json at all{{{{'); + expect(exitCode).toBe(0); + expect(raw).toBe('{}'); + }); + + 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.permissionDecision).toBe('ask'); + expect(output.message).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.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.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.permissionDecision).toBe('deny'); + expect(output.message).toContain('freeze'); + expect(output.message).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.permissionDecision).toBe('deny'); + expect(output.message).toContain('freeze'); + expect(output.message).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.permissionDecision).toBe('deny'); + expect(output.message).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.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.permissionDecision).toBeUndefined(); + }); + }); + }); +});