Merge pull request #1564 from Tibsfox/feat/hooks-opt-in-1473d
feat(hooks): opt-in session-state, commit-validation, phase-boundary hooks
This commit is contained in:
@@ -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);
|
||||
@@ -4809,6 +4809,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 };
|
||||
|
||||
27
hooks/gsd-phase-boundary.sh
Executable file
27
hooks/gsd-phase-boundary.sh
Executable file
@@ -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
|
||||
33
hooks/gsd-session-state.sh
Executable file
33
hooks/gsd-session-state.sh
Executable file
@@ -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
|
||||
47
hooks/gsd-validate-commit.sh
Executable file
47
hooks/gsd-validate-commit.sh
Executable file
@@ -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: <type>(<scope>): <subject>. 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
|
||||
507
tests/hooks-opt-in.test.cjs
Normal file
507
tests/hooks-opt-in.test.cjs
Normal file
@@ -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}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user