diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 256735f8b4..2a08c16382 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -127,7 +127,7 @@ Install it: Then restart your AI coding tool. MSG - echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}' + echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"gstack is required but not installed. See stderr for install instructions."}}' exit 0 fi diff --git a/careful/bin/check-careful.sh b/careful/bin/check-careful.sh index 22bf8b9226..2205d5ab89 100755 --- a/careful/bin/check-careful.sh +++ b/careful/bin/check-careful.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # check-careful.sh — PreToolUse hook for /careful skill # Reads JSON from stdin, checks Bash command for destructive patterns. -# Returns {"permissionDecision":"ask","message":"..."} to warn, or {} to allow. +# Returns a PreToolUse hookSpecificOutput with permissionDecision "ask" to warn, +# or {} to allow. The decision MUST be nested under hookSpecificOutput — Claude +# Code ignores a top-level permissionDecision, which silently no-ops the warning. set -euo pipefail # Read stdin (JSON with tool_input) @@ -94,7 +96,7 @@ if [ -n "$WARN" ]; then echo '{"event":"hook_fire","skill":"careful","pattern":"'"$PATTERN"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true WARN_ESCAPED=$(printf '%s' "$WARN" | sed 's/"/\\"/g') - printf '{"permissionDecision":"ask","message":"[careful] %s"}\n' "$WARN_ESCAPED" + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[careful] %s"}}\n' "$WARN_ESCAPED" else echo '{}' fi diff --git a/freeze/bin/check-freeze.sh b/freeze/bin/check-freeze.sh index 825bc227b5..f26c07578d 100755 --- a/freeze/bin/check-freeze.sh +++ b/freeze/bin/check-freeze.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # check-freeze.sh — PreToolUse hook for /freeze skill # Reads JSON from stdin, checks if file_path is within the freeze boundary. -# Returns {"permissionDecision":"deny","message":"..."} to block, or {} to allow. +# Returns a PreToolUse hookSpecificOutput with permissionDecision "deny" to block, +# or {} to allow. The decision MUST be nested under hookSpecificOutput — Claude +# Code ignores a top-level permissionDecision, which silently no-ops the block. set -euo pipefail # Read stdin @@ -74,6 +76,6 @@ case "$FILE_PATH" in mkdir -p ~/.gstack/analytics 2>/dev/null || true echo '{"event":"hook_fire","skill":"freeze","pattern":"boundary_deny","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true - printf '{"permissionDecision":"deny","message":"[freeze] Blocked: %s is outside the freeze boundary (%s). Only edits within the frozen directory are allowed."}\n' "$FILE_PATH" "$FREEZE_DIR" + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"[freeze] Blocked: %s is outside the freeze boundary (%s). Only edits within the frozen directory are allowed."}}\n' "$FILE_PATH" "$FREEZE_DIR" ;; esac diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts index db2e7629f3..c85906d3f0 100644 --- a/test/hook-scripts.test.ts +++ b/test/hook-scripts.test.ts @@ -67,34 +67,34 @@ describe('check-careful.sh', () => { 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'); + 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.permissionDecision).toBe('ask'); - expect(output.message).toContain('recursive delete'); + 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.permissionDecision).toBeUndefined(); + 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.permissionDecision).toBeUndefined(); + 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.permissionDecision).toBe('ask'); - expect(output.message).toContain('recursive delete'); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); test.each([ @@ -108,8 +108,8 @@ describe('check-careful.sh', () => { ])('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.permissionDecision).toBe('ask'); - expect(output.message).toContain('recursive delete'); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); }); @@ -123,22 +123,22 @@ describe('check-careful.sh', () => { 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'); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).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'); + 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.permissionDecision).toBe('ask'); - expect(output.message).toContain('TRUNCATE'); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('TRUNCATE'); }); }); @@ -148,36 +148,36 @@ describe('check-careful.sh', () => { 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'); + 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.permissionDecision).toBe('ask'); - expect(output.message).toContain('force-push'); + 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.permissionDecision).toBe('ask'); - expect(output.message).toContain('uncommitted'); + 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.permissionDecision).toBe('ask'); - expect(output.message).toContain('uncommitted'); + 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.permissionDecision).toBe('ask'); - expect(output.message).toContain('uncommitted'); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('uncommitted'); }); }); @@ -187,22 +187,22 @@ describe('check-careful.sh', () => { 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'); + 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.permissionDecision).toBe('ask'); - expect(output.message).toContain('Docker'); + 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.permissionDecision).toBe('ask'); - expect(output.message).toContain('Docker'); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('Docker'); }); }); @@ -221,7 +221,7 @@ describe('check-careful.sh', () => { test(`"${cmd}" allows`, () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(cmd)); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBeUndefined(); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); } }); @@ -232,13 +232,13 @@ describe('check-careful.sh', () => { test('empty command allows gracefully', () => { const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('')); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBeUndefined(); + 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.permissionDecision).toBeUndefined(); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); test('malformed JSON input allows gracefully (exit 0, output {})', () => { @@ -255,8 +255,8 @@ describe('check-careful.sh', () => { 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'); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); }); }); }); @@ -275,7 +275,7 @@ describe('check-freeze.sh', () => { { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBeUndefined(); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); @@ -287,7 +287,7 @@ describe('check-freeze.sh', () => { { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBeUndefined(); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); }); @@ -301,9 +301,9 @@ describe('check-freeze.sh', () => { { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBe('deny'); - expect(output.message).toContain('freeze'); - expect(output.message).toContain('outside'); + expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('freeze'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('outside'); }); }); @@ -315,9 +315,9 @@ describe('check-freeze.sh', () => { { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBe('deny'); - expect(output.message).toContain('freeze'); - expect(output.message).toContain('outside'); + expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('freeze'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('outside'); }); }); }); @@ -331,8 +331,8 @@ describe('check-freeze.sh', () => { { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBe('deny'); - expect(output.message).toContain('outside'); + expect(output.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('outside'); }); }); }); @@ -347,7 +347,7 @@ describe('check-freeze.sh', () => { { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBeUndefined(); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); } finally { fs.rmSync(stateDir, { recursive: true, force: true }); } @@ -363,7 +363,7 @@ describe('check-freeze.sh', () => { { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBeUndefined(); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); }); }); });