fix(freeze): hook reads the same state root /freeze writes — fails closed under GSTACK_HOME (#1459, #1509)

check-freeze.sh resolved its state dir as ${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}
while every writer (/freeze, /guard, /unfreeze, /investigate) resolves through
bin/gstack-paths, GSTACK_HOME first. With GSTACK_HOME set, /freeze wrote
freeze-dir.txt under GSTACK_HOME, the hook read $HOME/.gstack, found no file,
and allowed everything — a deny-tier boundary failing open.

One resolver now: gstack_hook_state_root() in careful/bin/hook-extract.sh
(already sourced by both check-freeze.sh and check-careful.sh) implements the
exact gstack-paths chain, including the CLAUDE_PLUGIN_ROOT guard that keeps a
CLAUDE_PLUGIN_DATA leaked from another plugin from redirecting our state.
check-freeze.sh and gstack_hook_log_fire both call it; nothing spawns
gstack-paths from a hook.

Tests: the GSTACK_HOME deny regression, GSTACK_HOME-over-CLAUDE_PLUGIN_DATA
precedence, plugin-root guard both ways, and a byte-parity check against
bin/gstack-paths across six env combinations. Existing freeze tests now pass
CLAUDE_PLUGIN_ROOT like a real plugin install would.

Idea from PR #1509 (@NikhileshNanduri); implemented natively against the shared
resolver rather than a second fallback chain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-03 01:18:18 +00:00
co-authored by Claude Fable 5.1
parent 0d1bd5616c
commit 17733302cc
3 changed files with 151 additions and 20 deletions
+26 -3
View File
@@ -63,12 +63,35 @@ gstack_hook_decision() {
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":%s}}\n' "$_ghd_decision" "$_ghd_encoded"
}
# gstack_hook_state_root
# Print the gstack state root, resolved with EXACTLY the chain bin/gstack-paths
# uses (GSTACK_STATE_ROOT): GSTACK_HOME, then CLAUDE_PLUGIN_DATA only when
# CLAUDE_PLUGIN_ROOT names gstack (a CLAUDE_PLUGIN_DATA leaked from another
# plugin via CLAUDE_ENV_FILE must not redirect our state), then $HOME/.gstack,
# then a project-local .gstack. Hooks run on every Edit/Bash call, so this is
# pure bash — never spawn gstack-paths from a hook. The writers (/freeze,
# /guard, /unfreeze, /investigate) resolve through gstack-paths; a reader that
# used a different chain failed OPEN whenever GSTACK_HOME was set (#1459).
# test/hook-scripts.test.ts pins parity against gstack-paths.
gstack_hook_state_root() {
if [ -n "${GSTACK_HOME:-}" ]; then
printf '%s\n' "$GSTACK_HOME"
elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ] && printf '%s' "${CLAUDE_PLUGIN_ROOT:-}" | grep -qi "gstack"; then
printf '%s\n' "$CLAUDE_PLUGIN_DATA"
elif [ -n "${HOME:-}" ]; then
printf '%s\n' "$HOME/.gstack"
else
printf '%s\n' ".gstack"
fi
}
# gstack_hook_log_fire SKILL PATTERN
# Append a hook_fire analytics record (pattern name only, never command
# content). Respects GSTACK_HOME so tests never pollute the operator's real
# analytics file. Best-effort: failures never affect the hook decision.
# content). Resolves the state root through gstack_hook_state_root so tests
# never pollute the operator's real analytics file. Best-effort: failures
# never affect the hook decision.
gstack_hook_log_fire() {
_ghlf_dir="${GSTACK_HOME:-$HOME/.gstack}/analytics"
_ghlf_dir="$(gstack_hook_state_root)/analytics"
mkdir -p "$_ghlf_dir" 2>/dev/null || true
# Fields are JSON-encoded (a repo basename can carry quotes/backslashes) —
# same rule this file states for decisions: never raw-interpolate into JSON.