fix(hooks): emit permissionDecision under hookSpecificOutput — /freeze and /careful never fired#2331
Open
jawadakram20 wants to merge 1 commit into
Open
Conversation
…e and /careful never fired
`careful/bin/check-careful.sh`, `freeze/bin/check-freeze.sh` and the
`check-gstack.sh` hook generated by `bin/gstack-team-init` all printed their
decision at the TOP LEVEL of the hook JSON:
{"permissionDecision":"deny","message":"..."}
Claude Code reads a PreToolUse decision from
`hookSpecificOutput.permissionDecision` (with the human-readable string in
`permissionDecisionReason`). A top-level `permissionDecision` matches neither
the current schema nor the deprecated `decision`/`reason` shape, so it is
ignored entirely and the hook is treated as "no opinion".
Impact — these are guardrails that silently did nothing:
- `/freeze` did NOT block edits outside the freeze boundary. It is installed
as a PreToolUse hook on Edit and Write specifically to deny them.
- `/careful` did NOT warn before destructive Bash commands (rm -rf, git reset
--hard, DROP TABLE, kubectl delete, docker prune, ...).
- A team repo without gstack installed was NOT blocked by check-gstack.sh.
In every case the user believes a safety net is armed while the tool call
proceeds unimpeded. The same repo's own TypeScript hook
(`hosts/claude/hooks/question-preference-hook.ts`) already uses the correct
nested shape, so this was an inconsistency within gstack, not an unknown.
Why it survived: `test/hook-scripts.test.ts` asserted on the script's raw
stdout (`output.permissionDecision`) rather than the shape Claude Code
consumes, so all 39 tests passed against a hook that could never fire. Those
assertions now read `output.hookSpecificOutput?.permissionDecision` /
`.permissionDecisionReason`, which fails loudly if the shape regresses.
Verified: 67 pass / 0 fail across hook-scripts, team-mode and
investigate-freeze-path. Manually confirmed the deny/ask/allow paths emit the
nested shape and that in-boundary edits still return `{}`.
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
careful/bin/check-careful.sh,freeze/bin/check-freeze.sh, and thecheck-gstack.shhook generated bybin/gstack-team-initall printed their decision at the top level of the hook JSON:{"permissionDecision":"deny","message":"..."}Claude Code reads a PreToolUse decision from
hookSpecificOutput.permissionDecision, with the human-readable string inpermissionDecisionReason. A top-levelpermissionDecisionmatches neither the current schema nor the deprecateddecision/reasonshape, so it is ignored entirely and the hook is treated as "no opinion".Impact — these are guardrails that silently did nothing
/freeze/carefulrm -rf,git reset --hard,DROP TABLE,kubectl delete, …check-gstack.sh/freezeis registered as a PreToolUse hook onEditandWritespecifically to deny them. In each case the user believes a safety net is armed while the tool call proceeds unimpeded — the failure mode is silent.This was an inconsistency within gstack, not an unknown: the repo's own TypeScript hook (
hosts/claude/hooks/question-preference-hook.ts) already emits the correct nested shape.Why it survived
test/hook-scripts.test.tsasserted on the script's raw stdout:That tests what the script prints, not what Claude Code consumes — so all 39 tests passed against a hook that could never fire. The assertions now read
output.hookSpecificOutput?.permissionDecision/.permissionDecisionReason, which fails loudly if the shape regresses.Verification
Manually confirmed the emitted JSON:
Allow paths still return
{}, and in-boundary freeze edits still return{}.