Skip to content

fix(hooks): emit permissionDecision under hookSpecificOutput — /freeze and /careful never fired#2331

Open
jawadakram20 wants to merge 1 commit into
garrytan:mainfrom
jawadakram20:fix/hook-permission-decision-shape
Open

fix(hooks): emit permissionDecision under hookSpecificOutput — /freeze and /careful never fired#2331
jawadakram20 wants to merge 1 commit into
garrytan:mainfrom
jawadakram20:fix/hook-permission-decision-shape

Conversation

@jawadakram20

Copy link
Copy Markdown

The bug

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

Skill Intended Actual
/freeze Deny edits outside the freeze boundary Edits proceeded
/careful Ask before rm -rf, git reset --hard, DROP TABLE, kubectl delete, … No prompt
team-mode check-gstack.sh Block work when gstack isn't installed Not blocked

/freeze is registered as a PreToolUse hook on Edit and Write specifically 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.ts asserted on the script's raw stdout:

expect(output.permissionDecision).toBe('ask');

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

bun test test/hook-scripts.test.ts test/team-mode.test.ts test/investigate-freeze-path.test.ts
 67 pass
 0 fail

Manually confirmed the emitted JSON:

$ echo '{"tool_input":{"command":"rm -rf /var/data"}}' | bash careful/bin/check-careful.sh
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[careful] Destructive: recursive delete (rm -r). This permanently removes files."}}

Allow paths still return {}, and in-boundary freeze edits still return {}.

…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 `{}`.
@trunk-io

trunk-io Bot commented Jul 23, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant