From 4157c7f20a8488d8f484f6332e1fad9b5ef72cb8 Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Thu, 2 Apr 2026 04:23:44 -0700 Subject: [PATCH] feat(hooks): add opt-in community hooks for GSD projects Port 3 community hooks from gsd-skill-creator, gated behind hooks.community config flag. All hooks are registered on install but are no-ops unless the project config has hooks: { community: true }. gsd-session-state.sh (SessionStart): outputs STATE.md head for orientation. gsd-validate-commit.sh (PreToolUse/Bash): blocks non-Conventional-Commits messages. gsd-phase-boundary.sh (PostToolUse/Write|Edit): warns when .planning/ files are modified. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/install.js | 71 ++++- hooks/gsd-phase-boundary.sh | 27 ++ hooks/gsd-session-state.sh | 33 +++ hooks/gsd-validate-commit.sh | 47 ++++ tests/hooks-opt-in.test.cjs | 507 +++++++++++++++++++++++++++++++++++ 5 files changed, 684 insertions(+), 1 deletion(-) create mode 100755 hooks/gsd-phase-boundary.sh create mode 100755 hooks/gsd-session-state.sh create mode 100755 hooks/gsd-validate-commit.sh create mode 100644 tests/hooks-opt-in.test.cjs diff --git a/bin/install.js b/bin/install.js index 9d5a58c4e..84b2d17d8 100755 --- a/bin/install.js +++ b/bin/install.js @@ -3709,7 +3709,7 @@ function uninstall(isGlobal, runtime = 'claude') { // 4. Remove GSD hooks const hooksDir = path.join(targetDir, 'hooks'); if (fs.existsSync(hooksDir)) { - const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js', 'gsd-prompt-guard.js']; + const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh']; let hookCount = 0; for (const hook of gsdHooks) { const hookPath = path.join(hooksDir, hook); @@ -4803,6 +4803,75 @@ function install(isGlobal, runtime = 'claude') { }); console.log(` ${green}✓${reset} Configured prompt injection guard hook`); } + + // Community hooks — registered on install but opt-in at runtime. + // Each hook checks .planning/config.json for hooks.community: true + // and exits silently (no-op) if not enabled. This lets users enable + // them per-project by adding: "hooks": { "community": true } + + // Configure commit validation hook (Conventional Commits enforcement, opt-in) + const validateCommitCommand = isGlobal + ? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-validate-commit.sh' + : 'bash ' + dirName + '/hooks/gsd-validate-commit.sh'; + const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-validate-commit')) + ); + + if (!hasValidateCommitHook) { + settings.hooks[preToolEvent].push({ + matcher: 'Bash', + hooks: [ + { + type: 'command', + command: validateCommitCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`); + } + + // Configure session state orientation hook (opt-in) + const sessionStateCommand = isGlobal + ? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-session-state.sh' + : 'bash ' + dirName + '/hooks/gsd-session-state.sh'; + const hasSessionStateHook = settings.hooks.SessionStart.some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-session-state')) + ); + + if (!hasSessionStateHook) { + settings.hooks.SessionStart.push({ + hooks: [ + { + type: 'command', + command: sessionStateCommand + } + ] + }); + console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`); + } + + // Configure phase boundary detection hook (opt-in) + const phaseBoundaryCommand = isGlobal + ? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-phase-boundary.sh' + : 'bash ' + dirName + '/hooks/gsd-phase-boundary.sh'; + const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-phase-boundary')) + ); + + if (!hasPhaseBoundaryHook) { + settings.hooks[postToolEvent].push({ + matcher: 'Write|Edit', + hooks: [ + { + type: 'command', + command: phaseBoundaryCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`); + } } return { settingsPath, settings, statuslineCommand, runtime }; diff --git a/hooks/gsd-phase-boundary.sh b/hooks/gsd-phase-boundary.sh new file mode 100755 index 000000000..6b0c39f07 --- /dev/null +++ b/hooks/gsd-phase-boundary.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# gsd-phase-boundary.sh — PostToolUse hook: detect .planning/ file writes +# Outputs a reminder when planning files are modified outside normal workflow. +# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency). +# +# OPT-IN: This hook is a no-op unless config.json has hooks.community: true. +# Enable with: "hooks": { "community": true } in .planning/config.json + +# Check opt-in config — exit silently if not enabled +if [ -f .planning/config.json ]; then + ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null) + if [ "$ENABLED" != "1" ]; then exit 0; fi +else + exit 0 +fi + +INPUT=$(cat) + +# Extract file_path from JSON using Node (handles escaping correctly) +FILE=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.file_path||'')}catch{}})" 2>/dev/null) + +if [[ "$FILE" == *.planning/* ]] || [[ "$FILE" == .planning/* ]]; then + echo ".planning/ file modified: $FILE" + echo "Check: Should STATE.md be updated to reflect this change?" +fi + +exit 0 diff --git a/hooks/gsd-session-state.sh b/hooks/gsd-session-state.sh new file mode 100755 index 000000000..57ebe26cb --- /dev/null +++ b/hooks/gsd-session-state.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# gsd-session-state.sh — SessionStart hook: inject project state reminder +# Outputs STATE.md head on every session start for orientation. +# +# OPT-IN: This hook is a no-op unless config.json has hooks.community: true. +# Enable with: "hooks": { "community": true } in .planning/config.json + +# Check opt-in config — exit silently if not enabled +if [ -f .planning/config.json ]; then + ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null) + if [ "$ENABLED" != "1" ]; then exit 0; fi +else + exit 0 +fi + +echo '## Project State Reminder' +echo '' + +if [ -f .planning/STATE.md ]; then + echo 'STATE.md exists - check for blockers and current phase.' + head -20 .planning/STATE.md +else + echo 'No .planning/ found - suggest /gsd:new-project if starting new work.' +fi + +echo '' + +if [ -f .planning/config.json ]; then + MODE=$(grep -o '"mode"[[:space:]]*:[[:space:]]*"[^"]*"' .planning/config.json 2>/dev/null || echo '"mode": "unknown"') + echo "Config: $MODE" +fi + +exit 0 diff --git a/hooks/gsd-validate-commit.sh b/hooks/gsd-validate-commit.sh new file mode 100755 index 000000000..fd2dd38a5 --- /dev/null +++ b/hooks/gsd-validate-commit.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# gsd-validate-commit.sh — PreToolUse hook: enforce Conventional Commits format +# Blocks git commit commands with non-conforming messages (exit 2). +# Allows conforming messages and all non-commit commands (exit 0). +# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency). +# +# OPT-IN: This hook is a no-op unless config.json has hooks.community: true. +# Enable with: "hooks": { "community": true } in .planning/config.json + +# Check opt-in config — exit silently if not enabled +if [ -f .planning/config.json ]; then + ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null) + if [ "$ENABLED" != "1" ]; then exit 0; fi +else + exit 0 +fi + +INPUT=$(cat) + +# Extract command from JSON using Node (handles escaping correctly, no jq needed) +CMD=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.command||'')}catch{}})" 2>/dev/null) + +# Only check git commit commands +if [[ "$CMD" =~ ^git[[:space:]]+commit ]]; then + # Extract message from -m flag + MSG="" + if [[ "$CMD" =~ -m[[:space:]]+\"([^\"]+)\" ]]; then + MSG="${BASH_REMATCH[1]}" + elif [[ "$CMD" =~ -m[[:space:]]+\'([^\']+)\' ]]; then + MSG="${BASH_REMATCH[1]}" + fi + + if [ -n "$MSG" ]; then + SUBJECT=$(echo "$MSG" | head -1) + # Validate Conventional Commits format + if ! [[ "$SUBJECT" =~ ^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+\))?:[[:space:]].+ ]]; then + echo '{"decision": "block", "reason": "Commit message must follow Conventional Commits: (): . Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore. Subject must be <=72 chars, lowercase, imperative mood, no trailing period."}' + exit 2 + fi + if [ ${#SUBJECT} -gt 72 ]; then + echo '{"decision": "block", "reason": "Commit subject must be 72 characters or less."}' + exit 2 + fi + fi +fi + +exit 0 diff --git a/tests/hooks-opt-in.test.cjs b/tests/hooks-opt-in.test.cjs new file mode 100644 index 000000000..f2b9c07ec --- /dev/null +++ b/tests/hooks-opt-in.test.cjs @@ -0,0 +1,507 @@ +/** + * GSD Tools Tests - Community Hooks (opt-in) + * + * Tests for feat/hooks-opt-in-1473d: + * - Hook file existence and permissions + * - Installer hook registration in install.js + * - Hook execution with opt-in enabled and disabled + * - Negative security tests for hooks + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { spawnSync } = require('child_process'); + +const HOOKS_DIR = path.join(__dirname, '..', 'hooks'); +const isWindows = process.platform === 'win32'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function createTempProject(prefix = 'gsd-hook-test-') { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); + return tmpDir; +} + +function cleanup(tmpDir) { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} +} + +function writeConfigWithHooks(tmpDir, enabled) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'balanced', + hooks: { community: enabled } + }, null, 2) + ); +} + +function writeMinimalStateMd(tmpDir, content) { + const defaultContent = content || '# Session State\n\n**Current Phase:** 01\n**Status:** Active\n'; + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + defaultContent + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Hook file existence and permissions +// ───────────────────────────────────────────────────────────────────────────── + +describe('hook file validation', () => { + test('gsd-session-state.sh exists', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh'); + assert.ok(fs.existsSync(hookPath), 'gsd-session-state.sh should exist'); + }); + + test('gsd-validate-commit.sh exists', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + assert.ok(fs.existsSync(hookPath), 'gsd-validate-commit.sh should exist'); + }); + + test('gsd-phase-boundary.sh exists', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh'); + assert.ok(fs.existsSync(hookPath), 'gsd-phase-boundary.sh should exist'); + }); + + test('gsd-session-state.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh'); + const stat = fs.statSync(hookPath); + assert.ok((stat.mode & 0o111) !== 0, 'gsd-session-state.sh should be executable'); + }); + + test('gsd-validate-commit.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const stat = fs.statSync(hookPath); + assert.ok((stat.mode & 0o111) !== 0, 'gsd-validate-commit.sh should be executable'); + }); + + test('gsd-phase-boundary.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh'); + const stat = fs.statSync(hookPath); + assert.ok((stat.mode & 0o111) !== 0, 'gsd-phase-boundary.sh should be executable'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Installer hook registration +// ───────────────────────────────────────────────────────────────────────────── + +describe('installer hook registration', () => { + const installJsPath = path.join(__dirname, '..', 'bin', 'install.js'); + let installSource; + + beforeEach(() => { + installSource = fs.readFileSync(installJsPath, 'utf-8'); + }); + + test('install.js contains gsd-validate-commit registration block', () => { + assert.ok( + installSource.includes('gsd-validate-commit'), + 'install.js should contain gsd-validate-commit hook registration' + ); + assert.ok( + installSource.includes('validateCommitCommand'), + 'install.js should define validateCommitCommand variable' + ); + assert.ok( + installSource.includes('hasValidateCommitHook'), + 'install.js should check for existing validate-commit hook' + ); + }); + + test('install.js contains gsd-session-state registration block', () => { + assert.ok( + installSource.includes('gsd-session-state'), + 'install.js should contain gsd-session-state hook registration' + ); + assert.ok( + installSource.includes('sessionStateCommand'), + 'install.js should define sessionStateCommand variable' + ); + assert.ok( + installSource.includes('hasSessionStateHook'), + 'install.js should check for existing session-state hook' + ); + }); + + test('install.js contains gsd-phase-boundary registration block', () => { + assert.ok( + installSource.includes('gsd-phase-boundary'), + 'install.js should contain gsd-phase-boundary hook registration' + ); + assert.ok( + installSource.includes('phaseBoundaryCommand'), + 'install.js should define phaseBoundaryCommand variable' + ); + assert.ok( + installSource.includes('hasPhaseBoundaryHook'), + 'install.js should check for existing phase-boundary hook' + ); + }); + + test('install.js registers validate-commit with PreToolUse event and Bash matcher', () => { + assert.ok( + installSource.includes("settings.hooks[preToolEvent].push"), + 'validate-commit should be pushed to preToolEvent hooks array' + ); + const validateCommitBlock = installSource.substring( + installSource.indexOf('// Configure commit validation hook'), + installSource.indexOf('// Configure session state orientation hook') + ); + assert.ok( + validateCommitBlock.includes("matcher: 'Bash'"), + 'validate-commit hook should use Bash matcher' + ); + assert.ok( + validateCommitBlock.includes('preToolEvent'), + 'validate-commit hook should register on preToolEvent (PreToolUse)' + ); + }); + + test('install.js adds all 3 new hooks to the uninstall cleanup list', () => { + const gsdHooksMatch = installSource.match(/const gsdHooks\s*=\s*\[([^\]]+)\]/); + assert.ok(gsdHooksMatch, 'install.js should define gsdHooks array for uninstall cleanup'); + + const gsdHooksContent = gsdHooksMatch[1]; + assert.ok( + gsdHooksContent.includes('gsd-session-state.sh'), + 'gsdHooks should include gsd-session-state.sh' + ); + assert.ok( + gsdHooksContent.includes('gsd-validate-commit.sh'), + 'gsdHooks should include gsd-validate-commit.sh' + ); + assert.ok( + gsdHooksContent.includes('gsd-phase-boundary.sh'), + 'gsdHooks should include gsd-phase-boundary.sh' + ); + }); + + test('install.js log messages indicate opt-in behavior', () => { + assert.ok( + installSource.includes('opt-in via config'), + 'install.js should mention opt-in in log messages' + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Opt-in gating behavior +// ───────────────────────────────────────────────────────────────────────────── + +describe('opt-in gating behavior', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('validate-commit is a no-op when hooks.community is false', () => { + writeConfigWithHooks(tmpDir, false); + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "WIP save"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + // Should exit 0 (no-op) even with a bad commit message + assert.strictEqual(result.status, 0, `Should be no-op when disabled, got ${result.status}`); + }); + + test('validate-commit is a no-op when config.json is absent', () => { + // No config.json at all + const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-bare-')); + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "WIP save"' } + }); + + try { + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: bareDir, + }); + + assert.strictEqual(result.status, 0, `Should be no-op without config.json, got ${result.status}`); + } finally { + fs.rmSync(bareDir, { recursive: true, force: true }); + } + }); + + test('session-state is a no-op when hooks.community is false', () => { + writeConfigWithHooks(tmpDir, false); + writeMinimalStateMd(tmpDir); + const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh'); + + const result = spawnSync('bash', [hookPath], { + input: '', + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`); + // Should NOT output state info when disabled + assert.ok( + !result.stdout.includes('Project State Reminder'), + `Should not output state reminder when disabled: ${result.stdout}` + ); + }); + + test('phase-boundary is a no-op when hooks.community is false', () => { + writeConfigWithHooks(tmpDir, false); + const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh'); + const input = JSON.stringify({ + tool_input: { file_path: '.planning/STATE.md' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`); + assert.ok( + !result.stdout.includes('.planning/ file modified'), + `Should not output warning when disabled: ${result.stdout}` + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Hook execution when enabled +// ───────────────────────────────────────────────────────────────────────────── + +describe('hook execution when enabled', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + writeConfigWithHooks(tmpDir, true); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('validate-commit allows valid conventional commit', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "fix(core): add locking mechanism"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Valid commit should exit 0, got ${result.status}. stderr: ${result.stderr}`); + }); + + test('validate-commit blocks non-conventional commit', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "WIP save"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 2, `Non-conventional commit should exit 2, got ${result.status}`); + assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`); + assert.ok(result.stdout.includes('Conventional Commits'), `stdout should mention "Conventional Commits": ${result.stdout}`); + }); + + test('validate-commit allows non-commit commands', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git push origin main' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Non-commit command should exit 0, got ${result.status}`); + }); + + test('session-state outputs state info when enabled', () => { + writeMinimalStateMd(tmpDir); + const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh'); + + const result = spawnSync('bash', [hookPath], { + input: '', + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`); + assert.ok( + result.stdout.includes('STATE.md exists'), + `stdout should contain "STATE.md exists": ${result.stdout}` + ); + }); + + test('session-state exits 0 without .planning/ (in enabled project)', () => { + // Create a dir with config but no STATE.md + const noStateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-nostate-')); + fs.mkdirSync(path.join(noStateDir, '.planning'), { recursive: true }); + writeConfigWithHooks(noStateDir, true); + const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh'); + + try { + const result = spawnSync('bash', [hookPath], { + input: '', + encoding: 'utf-8', + cwd: noStateDir, + }); + + assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`); + assert.ok( + result.stdout.includes('No .planning/ found') || result.stdout.includes('Project State'), + `Should handle missing STATE.md gracefully: ${result.stdout}` + ); + } finally { + fs.rmSync(noStateDir, { recursive: true, force: true }); + } + }); + + test('phase-boundary detects .planning/ writes when enabled', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh'); + const input = JSON.stringify({ + tool_input: { file_path: '.planning/STATE.md' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`); + assert.ok( + result.stdout.includes('.planning/ file modified'), + `stdout should contain ".planning/ file modified": ${result.stdout}` + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Negative security tests for hooks +// ───────────────────────────────────────────────────────────────────────────── + +describe('hook security tests', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + writeConfigWithHooks(tmpDir, true); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('validate-commit blocks message with shell metacharacters', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "$(rm -rf /)"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 2, `Shell metacharacter message should be blocked: ${result.status}`); + assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`); + }); + + test('validate-commit blocks message with backtick injection', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "`whoami`"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 2, `Backtick injection should be blocked: ${result.status}`); + assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`); + }); + + test('validate-commit allows commit with scope containing special chars', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "fix(api/v2): handle edge case"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Valid commit with / in scope should be allowed: ${result.status}`); + }); + + test('phase-boundary handles malformed JSON input gracefully', () => { + const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh'); + const input = 'not json at all'; + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + assert.strictEqual(result.status, 0, `Should not crash on malformed JSON: ${result.stderr}`); + }); + + test('hooks handle config.json with broken JSON gracefully', () => { + // Write malformed JSON config + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + '{ broken json' + ); + + const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh'); + const input = JSON.stringify({ + tool_input: { command: 'git commit -m "WIP save"' } + }); + + const result = spawnSync('bash', [hookPath], { + input, + encoding: 'utf-8', + cwd: tmpDir, + }); + + // Should exit 0 (treat malformed config as disabled) + assert.strictEqual(result.status, 0, `Malformed config should be treated as disabled: ${result.status}`); + }); +});