From d6f620fffec89491263960a6603d99c51f4166a5 Mon Sep 17 00:00:00 2001 From: Nikhilesh Nanduri Date: Fri, 15 May 2026 05:01:04 +0530 Subject: [PATCH 1/3] fix: symlink skill bin/ dirs + register PreToolUse hooks at install time (#1459 Bugs 1+2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: link_claude_skill_dirs() in setup and gstack-relink now symlink each skill's bin/ directory alongside SKILL.md. Previously only SKILL.md was linked, so hook scripts referenced in SKILL.md frontmatter (e.g. ${CLAUDE_SKILL_DIR}/bin/check-freeze.sh) resolved to non-existent paths. Bug 2: gstack-settings-hook gains add-pretooluse / remove-pretooluse actions. setup's new Step 11 calls these during install to write freeze and careful PreToolUse entries into ~/.claude/settings.json. Claude Code only fires hooks declared in settings.json — the SKILL.md frontmatter hooks section is documentation; registration must happen separately. Co-Authored-By: Claude Sonnet 4.6 --- bin/gstack-relink | 5 +++ bin/gstack-settings-hook | 88 +++++++++++++++++++++++++++++++++++++--- setup | 26 ++++++++++++ 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/bin/gstack-relink b/bin/gstack-relink index 31e6b82f06..ec18beab8e 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -77,6 +77,11 @@ for skill_dir in "$INSTALL_DIR"/*/; do # Create real directory with symlinked SKILL.md (absolute path) mkdir -p "$target" ln -snf "$INSTALL_DIR/$skill/SKILL.md" "$target/SKILL.md" + # Symlink bin/ so hook scripts declared in SKILL.md frontmatter are reachable + if [ -d "$INSTALL_DIR/$skill/bin" ]; then + [ -L "$target/bin" ] && rm "$target/bin" + ln -snf "$INSTALL_DIR/$skill/bin" "$target/bin" + fi SKILL_COUNT=$((SKILL_COUNT + 1)) done diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook index 8879a7d219..92c812c5a7 100755 --- a/bin/gstack-settings-hook +++ b/bin/gstack-settings-hook @@ -1,9 +1,11 @@ #!/usr/bin/env bash -# gstack-settings-hook — add/remove SessionStart hooks in Claude Code settings.json +# gstack-settings-hook — add/remove hooks in Claude Code settings.json # # Usage: -# gstack-settings-hook add # add SessionStart hook -# gstack-settings-hook remove # remove SessionStart hook +# gstack-settings-hook add # add SessionStart hook +# gstack-settings-hook remove # remove SessionStart hook +# gstack-settings-hook add-pretooluse # add PreToolUse hook +# gstack-settings-hook remove-pretooluse # remove PreToolUse hook # # Requires: bun (already a gstack hard dependency) # Writes atomically: .tmp + rename to prevent corruption on crash/disk-full. @@ -11,10 +13,16 @@ set -euo pipefail ACTION="${1:-}" -HOOK_CMD="${2:-}" SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-$HOME/.claude/settings.json}" -if [ -z "$ACTION" ] || [ -z "$HOOK_CMD" ]; then +if [ -z "$ACTION" ]; then + echo "Usage: gstack-settings-hook {add|remove} " >&2 + echo " gstack-settings-hook {add-pretooluse|remove-pretooluse} " >&2 + exit 1 +fi + +HOOK_CMD="${2:-}" +if [ -z "$HOOK_CMD" ]; then echo "Usage: gstack-settings-hook {add|remove} " >&2 exit 1 fi @@ -75,8 +83,76 @@ case "$ACTION" in fs.renameSync(tmp, settingsPath); " 2>/dev/null ;; + add-pretooluse) + MATCHER="${2:-}" + HOOK_CMD2="${3:-}" + if [ -z "$MATCHER" ] || [ -z "$HOOK_CMD2" ]; then + echo "Usage: gstack-settings-hook add-pretooluse " >&2 + exit 1 + fi + GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_MATCHER="$MATCHER" GSTACK_HOOK_CMD="$HOOK_CMD2" bun -e " + const fs = require('fs'); + const settingsPath = process.env.GSTACK_SETTINGS_PATH; + const matcher = process.env.GSTACK_MATCHER; + const hookCmd = process.env.GSTACK_HOOK_CMD; + + let settings = {}; + try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {} + + if (!settings.hooks) settings.hooks = {}; + if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = []; + + // Dedup: check if this exact matcher+command combo is already registered + const exists = settings.hooks.PreToolUse.some(entry => + entry.matcher === matcher && + entry.hooks && entry.hooks.some(h => h.command === hookCmd) + ); + + if (!exists) { + settings.hooks.PreToolUse.push({ + matcher, + hooks: [{ type: 'command', command: hookCmd }] + }); + } + + const tmp = settingsPath + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tmp, settingsPath); + " 2>/dev/null + ;; + remove-pretooluse) + MATCHER="${2:-}" + HOOK_CMD2="${3:-}" + if [ -z "$MATCHER" ] || [ -z "$HOOK_CMD2" ]; then + echo "Usage: gstack-settings-hook remove-pretooluse " >&2 + exit 1 + fi + [ -f "$SETTINGS_FILE" ] || exit 0 + GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_MATCHER="$MATCHER" GSTACK_HOOK_CMD="$HOOK_CMD2" bun -e " + const fs = require('fs'); + const settingsPath = process.env.GSTACK_SETTINGS_PATH; + const matcher = process.env.GSTACK_MATCHER; + const hookCmd = process.env.GSTACK_HOOK_CMD; + + let settings = {}; + try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { process.exit(0); } + + if (settings.hooks && settings.hooks.PreToolUse) { + settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(entry => + !(entry.matcher === matcher && + entry.hooks && entry.hooks.some(h => h.command === hookCmd)) + ); + if (settings.hooks.PreToolUse.length === 0) delete settings.hooks.PreToolUse; + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + } + + const tmp = settingsPath + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tmp, settingsPath); + " 2>/dev/null + ;; *) - echo "Unknown action: $ACTION (expected add or remove)" >&2 + echo "Unknown action: $ACTION (expected add, remove, add-pretooluse, or remove-pretooluse)" >&2 exit 1 ;; esac diff --git a/setup b/setup index f812511e4d..12fd483f26 100755 --- a/setup +++ b/setup @@ -402,6 +402,11 @@ link_claude_skill_dirs() { # Validate target isn't a symlink before creating the link if [ -L "$target/SKILL.md" ]; then rm "$target/SKILL.md"; fi ln -snf "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md" + # Symlink bin/ so hook scripts declared in SKILL.md frontmatter are reachable + if [ -d "$gstack_dir/$dir_name/bin" ]; then + if [ -L "$target/bin" ]; then rm "$target/bin"; fi + ln -snf "$gstack_dir/$dir_name/bin" "$target/bin" + fi linked+=("$link_name") fi done @@ -1042,3 +1047,24 @@ if [ "$NO_TEAM_MODE" -eq 1 ]; then log "Team mode disabled: auto-update hook removed." fi + +# 11. Register PreToolUse hooks for safety skills (freeze, careful) +# Hooks declared in SKILL.md frontmatter are not auto-read by Claude Code — they +# must be present in ~/.claude/settings.json. Register them here so the hook +# scripts are actually invoked when the skills are active. +# check-freeze.sh returns {} when no freeze state file exists, so it is safe as a +# permanent global hook. check-careful.sh checks for ~/.gstack/careful-active.txt +# and is similarly safe when /careful has not been invoked. +if [ -x "$SETTINGS_HOOK" ] && command -v bun >/dev/null 2>&1; then + _FREEZE_HOOK_SCRIPT="$SOURCE_GSTACK_DIR/freeze/bin/check-freeze.sh" + _CAREFUL_HOOK_SCRIPT="$SOURCE_GSTACK_DIR/careful/bin/check-careful.sh" + + if [ -f "$_FREEZE_HOOK_SCRIPT" ]; then + "$SETTINGS_HOOK" add-pretooluse "Edit|Write|MultiEdit" "bash $_FREEZE_HOOK_SCRIPT" 2>/dev/null || true + log " registered PreToolUse hook: freeze (Edit/Write/MultiEdit)" + fi + if [ -f "$_CAREFUL_HOOK_SCRIPT" ]; then + "$SETTINGS_HOOK" add-pretooluse "Bash" "bash $_CAREFUL_HOOK_SCRIPT" 2>/dev/null || true + log " registered PreToolUse hook: careful (Bash)" + fi +fi From fcc8c95ad00b44a39f27a456d278937acf454da1 Mon Sep 17 00:00:00 2001 From: Nikhilesh Nanduri Date: Fri, 15 May 2026 05:01:18 +0530 Subject: [PATCH 2/3] fix: use hookSpecificOutput envelope + careful-active state guard (#1459 Bugs 2+3) Bug 3: check-freeze.sh and check-careful.sh were returning a flat {"permissionDecision":"deny","message":"..."} object. Claude Code requires the hookSpecificOutput envelope: {"hookSpecificOutput":{"hookEventName":"PreToolUse", "permissionDecision":"deny","permissionDecisionReason":"..."}} Without the wrapper the decision is silently ignored and the tool executes. Bug 2 (careful side): check-careful.sh now checks for ~/.gstack/careful-active.txt before inspecting any command; it is a no-op when /careful has not been invoked. careful/SKILL.md now writes that file on activation and explains how to clear it. This makes the globally- registered hook safe to leave in settings.json permanently. check-freeze.sh also resolves its state-root via gstack-paths so it honours GSTACK_HOME in addition to CLAUDE_PLUGIN_DATA / $HOME/.gstack. Tests: all careful tests now use withCarefulActive() and pass CLAUDE_PLUGIN_DATA to the hook. freeze and careful tests assert the new hookSpecificOutput shape. Two new freeze tests verify the envelope format and confirm allow responses remain plain {}. Co-Authored-By: Claude Sonnet 4.6 --- careful/SKILL.md | 20 ++- careful/SKILL.md.tmpl | 20 ++- careful/bin/check-careful.sh | 27 +++- freeze/SKILL.md | 8 +- freeze/SKILL.md.tmpl | 8 +- freeze/bin/check-freeze.sh | 23 ++- test/hook-scripts.test.ts | 293 ++++++++++++++++++++++++----------- 7 files changed, 295 insertions(+), 104 deletions(-) diff --git a/careful/SKILL.md b/careful/SKILL.md index 91a5776e30..f5858ca9f3 100644 --- a/careful/SKILL.md +++ b/careful/SKILL.md @@ -36,6 +36,15 @@ mkdir -p ~/.gstack/analytics echo '{"skill":"careful","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 ``` +Activate the careful guard (writes the state file that check-careful.sh checks): + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +mkdir -p "$GSTACK_STATE_ROOT" +touch "$GSTACK_STATE_ROOT/careful-active.txt" +echo "CAREFUL_STATE: $GSTACK_STATE_ROOT/careful-active.txt" +``` + ## What's protected | Pattern | Example | Risk | @@ -60,4 +69,13 @@ The hook reads the command from the tool input JSON, checks it against the patterns above, and returns `permissionDecision: "ask"` with a warning message if a match is found. You can always override the warning and proceed. -To deactivate, end the conversation or start a new one. Hooks are session-scoped. +The hook is registered globally at install time but is a no-op unless +`~/.gstack/careful-active.txt` exists. The file is created when `/careful` is +invoked and deleted when the session ends or when the user explicitly removes it. + +To deactivate, run: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +rm -f "$GSTACK_STATE_ROOT/careful-active.txt" +echo "careful guardrails deactivated" +``` diff --git a/careful/SKILL.md.tmpl b/careful/SKILL.md.tmpl index 9d83411f83..bd9e9ac520 100644 --- a/careful/SKILL.md.tmpl +++ b/careful/SKILL.md.tmpl @@ -35,6 +35,15 @@ mkdir -p ~/.gstack/analytics echo '{"skill":"careful","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 ``` +Activate the careful guard (writes the state file that check-careful.sh checks): + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +mkdir -p "$GSTACK_STATE_ROOT" +touch "$GSTACK_STATE_ROOT/careful-active.txt" +echo "CAREFUL_STATE: $GSTACK_STATE_ROOT/careful-active.txt" +``` + ## What's protected | Pattern | Example | Risk | @@ -59,4 +68,13 @@ The hook reads the command from the tool input JSON, checks it against the patterns above, and returns `permissionDecision: "ask"` with a warning message if a match is found. You can always override the warning and proceed. -To deactivate, end the conversation or start a new one. Hooks are session-scoped. +The hook is registered globally at install time but is a no-op unless +`~/.gstack/careful-active.txt` exists. The file is created when `/careful` is +invoked and deleted when the session ends or when the user explicitly removes it. + +To deactivate, run: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-paths)" +rm -f "$GSTACK_STATE_ROOT/careful-active.txt" +echo "careful guardrails deactivated" +``` diff --git a/careful/bin/check-careful.sh b/careful/bin/check-careful.sh index d9c39e48c8..922bcf0506 100755 --- a/careful/bin/check-careful.sh +++ b/careful/bin/check-careful.sh @@ -1,9 +1,32 @@ #!/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 hookSpecificOutput with permissionDecision "ask" to warn, or {} to allow. set -euo pipefail +# Resolve state root (consistent with freeze and gstack-paths) +_PATHS_BIN="" +for _p in \ + "${CLAUDE_SKILL_DIR:-}/../gstack/bin/gstack-paths" \ + "$HOME/.claude/skills/gstack/bin/gstack-paths"; do + [ -x "$_p" ] && { _PATHS_BIN="$_p"; break; } +done + +if [ -n "$_PATHS_BIN" ]; then + eval "$("$_PATHS_BIN" 2>/dev/null)" 2>/dev/null || true + _STATE_DIR="${GSTACK_STATE_ROOT:-${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}}" +else + _STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}" +fi + +# No-op unless /careful has been activated (wrote careful-active.txt) +# This allows the hook to be registered globally without interfering in +# sessions where /careful was never invoked. +if [ ! -f "$_STATE_DIR/careful-active.txt" ]; then + echo '{}' + exit 0 +fi + # Read stdin (JSON with tool_input) INPUT=$(cat) @@ -106,7 +129,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/SKILL.md b/freeze/SKILL.md index 87f8506ca2..43a391067e 100644 --- a/freeze/SKILL.md +++ b/freeze/SKILL.md @@ -74,10 +74,12 @@ again. To remove it, run `/unfreeze` or end the session." The hook reads `file_path` from the Edit/Write tool input JSON, then checks whether the path starts with the freeze directory. If not, it returns -`permissionDecision: "deny"` to block the operation. +a `hookSpecificOutput` deny decision to block the operation. -The freeze boundary persists for the session via the state file. The hook -script reads it on every Edit/Write invocation. +The hook is registered globally in `~/.claude/settings.json` by the gstack +installer. It is a no-op when no freeze state file exists, so it does not +interfere in sessions where `/freeze` has not been invoked. The freeze +boundary persists via the state file; `/unfreeze` clears it. ## Notes diff --git a/freeze/SKILL.md.tmpl b/freeze/SKILL.md.tmpl index a1b456e535..36504ef8e6 100644 --- a/freeze/SKILL.md.tmpl +++ b/freeze/SKILL.md.tmpl @@ -73,10 +73,12 @@ again. To remove it, run `/unfreeze` or end the session." The hook reads `file_path` from the Edit/Write tool input JSON, then checks whether the path starts with the freeze directory. If not, it returns -`permissionDecision: "deny"` to block the operation. +a `hookSpecificOutput` deny decision to block the operation. -The freeze boundary persists for the session via the state file. The hook -script reads it on every Edit/Write invocation. +The hook is registered globally in `~/.claude/settings.json` by the gstack +installer. It is a no-op when no freeze state file exists, so it does not +interfere in sessions where `/freeze` has not been invoked. The freeze +boundary persists via the state file; `/unfreeze` clears it. ## Notes diff --git a/freeze/bin/check-freeze.sh b/freeze/bin/check-freeze.sh index 825bc227b5..682e30f9b1 100755 --- a/freeze/bin/check-freeze.sh +++ b/freeze/bin/check-freeze.sh @@ -1,14 +1,28 @@ #!/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 hookSpecificOutput with permissionDecision "deny" to block, or {} to allow. set -euo pipefail # Read stdin INPUT=$(cat) -# Locate the freeze directory state file -STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}" +# Locate the freeze directory state file via gstack-paths for consistent resolution +# across GSTACK_HOME / CLAUDE_PLUGIN_DATA / $HOME/.gstack fallback chain. +_PATHS_BIN="" +for _p in \ + "${CLAUDE_SKILL_DIR:-}/../gstack/bin/gstack-paths" \ + "$HOME/.claude/skills/gstack/bin/gstack-paths"; do + [ -x "$_p" ] && { _PATHS_BIN="$_p"; break; } +done + +if [ -n "$_PATHS_BIN" ]; then + eval "$("$_PATHS_BIN" 2>/dev/null)" 2>/dev/null || true + STATE_DIR="${GSTACK_STATE_ROOT:-${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}}" +else + STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}" +fi + FREEZE_FILE="$STATE_DIR/freeze-dir.txt" # If no freeze file exists, allow everything (not yet configured) @@ -74,6 +88,7 @@ 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" + _REASON=$(printf '[freeze] Blocked: %s is outside the freeze boundary (%s). Only edits within the frozen directory are allowed.' "$FILE_PATH" "$FREEZE_DIR" | sed 's/"/\\"/g') + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$_REASON" ;; esac diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts index f1ffe12397..63a2d05b83 100644 --- a/test/hook-scripts.test.ts +++ b/test/hook-scripts.test.ts @@ -46,6 +46,22 @@ function freezeInput(filePath: string) { return { tool_input: { file_path: filePath } }; } +/** Extract permissionDecision from the hookSpecificOutput envelope (or legacy flat format). */ +function getPermissionDecision(output: any): string | undefined { + if (output?.hookSpecificOutput?.permissionDecision !== undefined) { + return output.hookSpecificOutput.permissionDecision; + } + return output?.permissionDecision; +} + +/** Extract the deny/ask reason from the hookSpecificOutput envelope (or legacy flat format). */ +function getReason(output: any): string | undefined { + if (output?.hookSpecificOutput?.permissionDecisionReason !== undefined) { + return output.hookSpecificOutput.permissionDecisionReason; + } + return output?.message; +} + 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); @@ -56,45 +72,82 @@ function withFreezeDir(freezePath: string, fn: (stateDir: string) => void) { } } +function withCarefulActive(fn: (stateDir: string) => void) { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-test-')); + fs.writeFileSync(path.join(stateDir, 'careful-active.txt'), ''); + try { + fn(stateDir); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +} + // ============================================================ // check-careful.sh tests // ============================================================ describe('check-careful.sh', () => { + describe('no-op without careful-active.txt', () => { + test('allows everything when careful is not active (no state file)', () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-test-')); + try { + const { exitCode, output } = runHook( + CAREFUL_SCRIPT, + carefulInput('rm -rf /var/important'), + { CLAUDE_PLUGIN_DATA: stateDir }, + ); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBeUndefined(); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + }); + // --- 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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /var/data'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -r ./some-dir'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).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(); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf node_modules'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).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(); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf .next dist'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf node_modules /var/data'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).toContain('recursive delete'); + }); }); }); @@ -106,24 +159,30 @@ describe('check-careful.sh', () => { 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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('psql -c DROP TABLE users;'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('mysql -e drop database mydb'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)!.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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('psql -c TRUNCATE orders;'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).toContain('TRUNCATE'); + }); }); }); @@ -131,38 +190,48 @@ describe('check-careful.sh', () => { 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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push --force origin main'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push -f origin main'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git reset --hard HEAD~3'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git checkout .'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git restore .'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).toContain('uncommitted'); + }); }); }); @@ -170,24 +239,30 @@ describe('check-careful.sh', () => { 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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('kubectl delete pod my-pod'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('docker rm -f container123'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).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'); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('docker system prune -a'), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).toContain('Docker'); + }); }); }); @@ -204,9 +279,11 @@ describe('check-careful.sh', () => { for (const cmd of safeCmds) { test(`"${cmd}" allows`, () => { - const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(cmd)); - expect(exitCode).toBe(0); - expect(output.permissionDecision).toBeUndefined(); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(cmd), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBeUndefined(); + }); }); } }); @@ -215,21 +292,27 @@ describe('check-careful.sh', () => { describe('edge cases', () => { test('empty command allows gracefully', () => { - const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('')); - expect(exitCode).toBe(0); - expect(output.permissionDecision).toBeUndefined(); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(''), { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBeUndefined(); + }); }); test('missing command field allows gracefully', () => { - const { exitCode, output } = runHook(CAREFUL_SCRIPT, { tool_input: {} }); - expect(exitCode).toBe(0); - expect(output.permissionDecision).toBeUndefined(); + withCarefulActive((stateDir) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, { tool_input: {} }, { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).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('{}'); + withCarefulActive((stateDir) => { + const { exitCode, raw } = runHookRaw(CAREFUL_SCRIPT, 'this is not json at all{{{{', { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(raw).toBe('{}'); + }); }); test('Python fallback: grep fails on multiline JSON, Python parses it', () => { @@ -237,11 +320,13 @@ describe('check-careful.sh', () => { // 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'); + withCarefulActive((stateDir) => { + const rawJson = '{"tool_input":{"command":\n"rm -rf /tmp/important"}}'; + const { exitCode, output } = runHookRaw(CAREFUL_SCRIPT, rawJson, { CLAUDE_PLUGIN_DATA: stateDir }); + expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBe('ask'); + expect(getReason(output)).toContain('recursive delete'); + }); }); }); }); @@ -260,7 +345,7 @@ describe('check-freeze.sh', () => { { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBeUndefined(); + expect(getPermissionDecision(output)).toBeUndefined(); }); }); @@ -272,7 +357,7 @@ describe('check-freeze.sh', () => { { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBeUndefined(); + expect(getPermissionDecision(output)).toBeUndefined(); }); }); }); @@ -286,9 +371,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(getPermissionDecision(output)).toBe('deny'); + expect(getReason(output)).toContain('freeze'); + expect(getReason(output)).toContain('outside'); }); }); @@ -300,9 +385,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(getPermissionDecision(output)).toBe('deny'); + expect(getReason(output)).toContain('freeze'); + expect(getReason(output)).toContain('outside'); }); }); }); @@ -316,8 +401,8 @@ describe('check-freeze.sh', () => { { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBe('deny'); - expect(output.message).toContain('outside'); + expect(getPermissionDecision(output)).toBe('deny'); + expect(getReason(output)).toContain('outside'); }); }); }); @@ -332,7 +417,7 @@ describe('check-freeze.sh', () => { { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); - expect(output.permissionDecision).toBeUndefined(); + expect(getPermissionDecision(output)).toBeUndefined(); } finally { fs.rmSync(stateDir, { recursive: true, force: true }); } @@ -348,8 +433,36 @@ describe('check-freeze.sh', () => { { CLAUDE_PLUGIN_DATA: stateDir }, ); expect(exitCode).toBe(0); + expect(getPermissionDecision(output)).toBeUndefined(); + }); + }); + }); + + describe('hookSpecificOutput format', () => { + test('deny response uses hookSpecificOutput envelope', () => { + withFreezeDir('/Users/dev/project/src/', (stateDir) => { + const { output } = runHook( + FREEZE_SCRIPT, + freezeInput('/Users/dev/other/index.ts'), + { CLAUDE_PLUGIN_DATA: stateDir }, + ); + expect(output.hookSpecificOutput).toBeDefined(); + expect(output.hookSpecificOutput.hookEventName).toBe('PreToolUse'); + expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(output.hookSpecificOutput.permissionDecisionReason).toBeDefined(); expect(output.permissionDecision).toBeUndefined(); }); }); + + test('allow response is plain {} (no hookSpecificOutput)', () => { + withFreezeDir('/Users/dev/project/src/', (stateDir) => { + const { output } = runHook( + FREEZE_SCRIPT, + freezeInput('/Users/dev/project/src/file.ts'), + { CLAUDE_PLUGIN_DATA: stateDir }, + ); + expect(output.hookSpecificOutput).toBeUndefined(); + }); + }); }); }); From e723c6473b0c5a22b338384d463047400aae9583 Mon Sep 17 00:00:00 2001 From: Nikhilesh Nanduri Date: Fri, 15 May 2026 05:02:06 +0530 Subject: [PATCH 3/3] v1.35.0.1 fix: /freeze and /careful enforcement chain (#1459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent bugs all required for hooks to fire and enforce: Bug 1 — bin/ not symlinked (hook script unreachable) Bug 2 — hooks not registered in settings.json (never fires) Bug 3 — wrong hookSpecificOutput format (fires, deny ignored) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ VERSION | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55cc068f9c..4232ed610e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [1.35.0.1] - 2026-05-15 + +**`/freeze` and `/careful` now actually block. Three independent bugs — no bin/ symlink, no hook registration, wrong response format — were all required for the hooks to fire and enforce.** + +The `/freeze` skill blocks Edit and Write outside a declared directory. `/careful` warns before destructive Bash commands. Both worked during skill invocation (state file written, UI feedback shown) but provided zero actual enforcement. An Edit on a blocked file would succeed silently. Telemetry logged a `boundary_deny` event, which made it look like the hook fired — it did fire, the deny was just ignored. + +### The three numbers that matter + +Root cause: `check-freeze.sh` and `check-careful.sh` ran fine when called directly. The problem was in how they were wired, not in the pattern-matching logic. + +| Bug | Symptom | Root cause | +|-----|---------|-----------| +| Bug 1 | Hook script not found | `link_claude_skill_dirs` symlinked `SKILL.md` but not `bin/` — `${CLAUDE_SKILL_DIR}/bin/check-freeze.sh` resolved to a non-existent path | +| Bug 2 | Hook never fired | SKILL.md frontmatter `hooks:` is documentation — Claude Code only fires hooks registered in `~/.claude/settings.json`. Setup never wrote there | +| Bug 3 | Hook fired, deny ignored | Scripts returned `{"permissionDecision":"deny","message":"..."}` — a flat object Claude Code doesn't recognise. Correct format is `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}}` | + +With all three fixed, `/freeze` enforces the boundary on Edit, Write, and MultiEdit. `/careful` warns on destructive Bash commands when active. Both work in every new session after re-running `./setup`. + +### What this means for users who ran `/freeze` or `/careful` before + +Re-run `./setup` once. Setup now symlinks each skill's `bin/` directory and writes the PreToolUse hook entries to `~/.claude/settings.json`. After that, `/freeze` actually blocks edits outside the declared directory. `/careful` is a no-op unless you invoke it (gated by `~/.gstack/careful-active.txt`), so it won't interfere with sessions where you didn't ask for safety mode. + +### Itemized changes + +#### Fixed +- **Bug 1** — `setup` and `gstack-relink` now symlink each skill's `bin/` directory alongside `SKILL.md` so hook scripts in the frontmatter are reachable at `${CLAUDE_SKILL_DIR}/bin/` +- **Bug 2** — `setup` registers PreToolUse entries for freeze (Edit|Write|MultiEdit) and careful (Bash) in `~/.claude/settings.json` at install time via the extended `gstack-settings-hook`; `gstack-settings-hook` gains `add-pretooluse` and `remove-pretooluse` actions +- **Bug 3** — `check-freeze.sh` now outputs `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}}` as required; `check-careful.sh` does the same for `permissionDecision:"ask"` +- **Bonus** — `check-freeze.sh` resolves its state root via `gstack-paths` to honour `GSTACK_HOME` in addition to `CLAUDE_PLUGIN_DATA`; `check-careful.sh` is a no-op unless `~/.gstack/careful-active.txt` exists (created when `/careful` is invoked) + +#### For contributors +- `test/hook-scripts.test.ts`: all careful tests use `withCarefulActive()` and pass `CLAUDE_PLUGIN_DATA` pointing to a temp dir; freeze tests assert `hookSpecificOutput` shape; two new tests verify the envelope structure and confirm allow responses remain `{}` + ## [1.35.0.0] - 2026-05-13 ## **Docs become a tracked surface, not an afterthought. `/document-generate` writes them from scratch, `/document-release` audits coverage in four Diataxis quadrants.** diff --git a/VERSION b/VERSION index c25c8ba4b1..b6247ec7ef 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.35.0.0 +1.35.0.1