Skip to content

Commit 0fa15d1

Browse files
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 <noreply@anthropic.com>
1 parent d0e4421 commit 0fa15d1

7 files changed

Lines changed: 295 additions & 104 deletions

File tree

careful/SKILL.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ mkdir -p ~/.gstack/analytics
3636
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
3737
```
3838

39+
Activate the careful guard (writes the state file that check-careful.sh checks):
40+
41+
```bash
42+
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
43+
mkdir -p "$GSTACK_STATE_ROOT"
44+
touch "$GSTACK_STATE_ROOT/careful-active.txt"
45+
echo "CAREFUL_STATE: $GSTACK_STATE_ROOT/careful-active.txt"
46+
```
47+
3948
## What's protected
4049

4150
| Pattern | Example | Risk |
@@ -60,4 +69,13 @@ The hook reads the command from the tool input JSON, checks it against the
6069
patterns above, and returns `permissionDecision: "ask"` with a warning message
6170
if a match is found. You can always override the warning and proceed.
6271

63-
To deactivate, end the conversation or start a new one. Hooks are session-scoped.
72+
The hook is registered globally at install time but is a no-op unless
73+
`~/.gstack/careful-active.txt` exists. The file is created when `/careful` is
74+
invoked and deleted when the session ends or when the user explicitly removes it.
75+
76+
To deactivate, run:
77+
```bash
78+
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
79+
rm -f "$GSTACK_STATE_ROOT/careful-active.txt"
80+
echo "careful guardrails deactivated"
81+
```

careful/SKILL.md.tmpl

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ mkdir -p ~/.gstack/analytics
3535
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
3636
```
3737

38+
Activate the careful guard (writes the state file that check-careful.sh checks):
39+
40+
```bash
41+
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
42+
mkdir -p "$GSTACK_STATE_ROOT"
43+
touch "$GSTACK_STATE_ROOT/careful-active.txt"
44+
echo "CAREFUL_STATE: $GSTACK_STATE_ROOT/careful-active.txt"
45+
```
46+
3847
## What's protected
3948

4049
| Pattern | Example | Risk |
@@ -59,4 +68,13 @@ The hook reads the command from the tool input JSON, checks it against the
5968
patterns above, and returns `permissionDecision: "ask"` with a warning message
6069
if a match is found. You can always override the warning and proceed.
6170

62-
To deactivate, end the conversation or start a new one. Hooks are session-scoped.
71+
The hook is registered globally at install time but is a no-op unless
72+
`~/.gstack/careful-active.txt` exists. The file is created when `/careful` is
73+
invoked and deleted when the session ends or when the user explicitly removes it.
74+
75+
To deactivate, run:
76+
```bash
77+
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
78+
rm -f "$GSTACK_STATE_ROOT/careful-active.txt"
79+
echo "careful guardrails deactivated"
80+
```

careful/bin/check-careful.sh

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,32 @@
11
#!/usr/bin/env bash
22
# check-careful.sh — PreToolUse hook for /careful skill
33
# Reads JSON from stdin, checks Bash command for destructive patterns.
4-
# Returns {"permissionDecision":"ask","message":"..."} to warn, or {} to allow.
4+
# Returns hookSpecificOutput with permissionDecision "ask" to warn, or {} to allow.
55
set -euo pipefail
66

7+
# Resolve state root (consistent with freeze and gstack-paths)
8+
_PATHS_BIN=""
9+
for _p in \
10+
"${CLAUDE_SKILL_DIR:-}/../gstack/bin/gstack-paths" \
11+
"$HOME/.claude/skills/gstack/bin/gstack-paths"; do
12+
[ -x "$_p" ] && { _PATHS_BIN="$_p"; break; }
13+
done
14+
15+
if [ -n "$_PATHS_BIN" ]; then
16+
eval "$("$_PATHS_BIN" 2>/dev/null)" 2>/dev/null || true
17+
_STATE_DIR="${GSTACK_STATE_ROOT:-${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}}"
18+
else
19+
_STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}"
20+
fi
21+
22+
# No-op unless /careful has been activated (wrote careful-active.txt)
23+
# This allows the hook to be registered globally without interfering in
24+
# sessions where /careful was never invoked.
25+
if [ ! -f "$_STATE_DIR/careful-active.txt" ]; then
26+
echo '{}'
27+
exit 0
28+
fi
29+
730
# Read stdin (JSON with tool_input)
831
INPUT=$(cat)
932

@@ -106,7 +129,7 @@ if [ -n "$WARN" ]; then
106129
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
107130

108131
WARN_ESCAPED=$(printf '%s' "$WARN" | sed 's/"/\\"/g')
109-
printf '{"permissionDecision":"ask","message":"[careful] %s"}\n' "$WARN_ESCAPED"
132+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[careful] %s"}}\n' "$WARN_ESCAPED"
110133
else
111134
echo '{}'
112135
fi

freeze/SKILL.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,12 @@ again. To remove it, run `/unfreeze` or end the session."
7474

7575
The hook reads `file_path` from the Edit/Write tool input JSON, then checks
7676
whether the path starts with the freeze directory. If not, it returns
77-
`permissionDecision: "deny"` to block the operation.
77+
a `hookSpecificOutput` deny decision to block the operation.
7878

79-
The freeze boundary persists for the session via the state file. The hook
80-
script reads it on every Edit/Write invocation.
79+
The hook is registered globally in `~/.claude/settings.json` by the gstack
80+
installer. It is a no-op when no freeze state file exists, so it does not
81+
interfere in sessions where `/freeze` has not been invoked. The freeze
82+
boundary persists via the state file; `/unfreeze` clears it.
8183

8284
## Notes
8385

freeze/SKILL.md.tmpl

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,12 @@ again. To remove it, run `/unfreeze` or end the session."
7373

7474
The hook reads `file_path` from the Edit/Write tool input JSON, then checks
7575
whether the path starts with the freeze directory. If not, it returns
76-
`permissionDecision: "deny"` to block the operation.
76+
a `hookSpecificOutput` deny decision to block the operation.
7777

78-
The freeze boundary persists for the session via the state file. The hook
79-
script reads it on every Edit/Write invocation.
78+
The hook is registered globally in `~/.claude/settings.json` by the gstack
79+
installer. It is a no-op when no freeze state file exists, so it does not
80+
interfere in sessions where `/freeze` has not been invoked. The freeze
81+
boundary persists via the state file; `/unfreeze` clears it.
8082

8183
## Notes
8284

freeze/bin/check-freeze.sh

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
11
#!/usr/bin/env bash
22
# check-freeze.sh — PreToolUse hook for /freeze skill
33
# Reads JSON from stdin, checks if file_path is within the freeze boundary.
4-
# Returns {"permissionDecision":"deny","message":"..."} to block, or {} to allow.
4+
# Returns hookSpecificOutput with permissionDecision "deny" to block, or {} to allow.
55
set -euo pipefail
66

77
# Read stdin
88
INPUT=$(cat)
99

10-
# Locate the freeze directory state file
11-
STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}"
10+
# Locate the freeze directory state file via gstack-paths for consistent resolution
11+
# across GSTACK_HOME / CLAUDE_PLUGIN_DATA / $HOME/.gstack fallback chain.
12+
_PATHS_BIN=""
13+
for _p in \
14+
"${CLAUDE_SKILL_DIR:-}/../gstack/bin/gstack-paths" \
15+
"$HOME/.claude/skills/gstack/bin/gstack-paths"; do
16+
[ -x "$_p" ] && { _PATHS_BIN="$_p"; break; }
17+
done
18+
19+
if [ -n "$_PATHS_BIN" ]; then
20+
eval "$("$_PATHS_BIN" 2>/dev/null)" 2>/dev/null || true
21+
STATE_DIR="${GSTACK_STATE_ROOT:-${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}}"
22+
else
23+
STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}"
24+
fi
25+
1226
FREEZE_FILE="$STATE_DIR/freeze-dir.txt"
1327

1428
# If no freeze file exists, allow everything (not yet configured)
@@ -74,6 +88,7 @@ case "$FILE_PATH" in
7488
mkdir -p ~/.gstack/analytics 2>/dev/null || true
7589
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
7690

77-
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"
91+
_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')
92+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$_REASON"
7893
;;
7994
esac

0 commit comments

Comments
 (0)