fix(hooks): fail closed when a PreToolUse hook delivers no verdict - #7422
Conversation
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Fail-closed on the gate is right and deliberately scoped; the exit-1 breadth flip and the still-open autonomous-path hole are the risks to watch. Watch
Suggestions
[DESIGN-REVIEWED] 5fbce9d |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims in the description verified against the repo: First-Principles-Verdict: PASS A dead, slow, or missing deny hook no longer approves the tool; every item is the fix, its declared breadth, or the mandated spec move. What this change shipsIntent: stop a broken/timed-out PreToolUse deny hook from silently approving tools on the dashboard auto-approve path — a FIX (#7339).
Counted: gating consumers of hook exit codes = 1 ( [FIRST-PRINCIPLES-REVIEWED] 5fbce9d |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
|
c13facc to
5fbce9d
Compare
|
|
|
|
|
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
PR #7422 changed the PreToolUse script-hook exit-code contract: any exit that is neither 0 nor 2 now BLOCKS the tool (fail closed), where it previously only warned. Three places still described the old warn-only rule or under-pinned the new one: - docs/reference/kiro-cli/hooks.md: the Hook output bullets and the PreToolUse section now state 0=allow (stdout injected), 2=deny (stderr returned to the LLM), and any other exit (timeout, crash, unexecutable command) blocks the tool with detail preferring result.error, then stderr, then "exited with code N". Non-gating events keep warn-only semantics. A note points out there is no per-hook advisory/fail-open opt-out yet (#7547). - src/kiro_crew/hooks.py: the ScriptHook dataclass docstring now states the same contract instead of the pre-#7422 warn-only rule. - test/test_dashboard_approval.py: the two tests added by #7422 built their hook results with bare MagicMock, so a renamed production field would silently keep passing. They now construct real ScriptHookResult instances, so a field rename fails loudly as a TypeError. Documentation and test hardening only; no runtime behaviour changes. Closes #7744
…8402) PR #7422 changed the PreToolUse script-hook exit-code contract: any exit that is neither 0 nor 2 now BLOCKS the tool (fail closed), where it previously only warned. Three places still described the old warn-only rule or under-pinned the new one: - docs/reference/kiro-cli/hooks.md: the Hook output bullets and the PreToolUse section now state 0=allow (stdout injected), 2=deny (stderr returned to the LLM), and any other exit (timeout, crash, unexecutable command) blocks the tool with detail preferring result.error, then stderr, then "exited with code N". Non-gating events keep warn-only semantics. A note points out there is no per-hook advisory/fail-open opt-out yet (#7547). - src/kiro_crew/hooks.py: the ScriptHook dataclass docstring now states the same contract instead of the pre-#7422 warn-only rule. - test/test_dashboard_approval.py: the two tests added by #7422 built their hook results with bare MagicMock, so a renamed production field would silently keep passing. They now construct real ScriptHookResult instances, so a field rename fails loudly as a TypeError. Documentation and test hardening only; no runtime behaviour changes. Closes #7744 Co-authored-by: Zezhen Xu <zezhexu@dev-dsk-zezhexu-2b-15d11a49.us-west-2.amazon.com>
Problem / Motivation
A PreToolUse script hook is the policy gate on the dashboard auto-approve path, but every failure mode of the hook itself resolved to allow. A hook that exits 2 inside its window blocks correctly; the same hook made slow, killed, or removed stops blocking anything, with no refusal and nothing beyond a log warning.
run_script_hook'sasyncio.TimeoutErrorand generic-exception branches return aScriptHookResultwith noexit_code, so it keeps the dataclass default-1; a hook binary the shell cannot exec comes back as127._fireconverted only exit0(inject stdout) and exit2(emit aBLOCKED:marker) into results — every other code fell into a log-only branch and contributed nothing, so_pre_tool_hooks_should_block([])hit the documented empty-list pass-through and the tool proceeded.Why it matters
Slowing, breaking, or deleting the deny hook silently disabled the policy it enforces, at the moment enforcement mattered. The reporter hit this wiring an external approval tool behind a preToolUse hook: any decision slower than the hook window — a human reading the command before approving it — was converted into an approval.
Two neighbouring paths on the same branch already fail closed (an uninitialized hook store emits
BLOCKED:system:hook store not initialized; an exception out offire()rejects via_reject_hook_error), so the intent existed — the timeout and crash cases just never reached either one.What changed (motivation → approach → change)
Symptom: a timed-out or crashed deny hook approves the tool. Root cause:
_firetreats "no verdict" and "allow" as the same thing, because both produce an empty results list. Change: forHOOK_EVENT_PRE_TOOL_USE, any hook result whose exit code is neither 0 nor 2 now emits aBLOCKED:<hook>:<detail>marker, logs at error level, and broadcasts the block to the activity feed — the same shape as the two neighbouring fail-closed paths.A policy gate has a two-valued contract: exit 0 is a delivered allow, exit 2 is a delivered deny. Any third value means the gate did not decide, and for a gate that must resolve to deny. Exit 0 still approves, so a healthy silent-allow hook is untouched.
The detail string prefers
result.error(already redacted insiderun_script_hook) overstderr, falling back to the exit code when both are empty — a missing binary produces neither. Non-gating events (postToolUse,userPromptSubmit,stop) keep the previous warn-only behaviour, so nothing outside the gate changes.This deliberately covers more than the undelivered shapes: a hook that runs to completion and exits 1 now blocks where it previously warned. That breadth is stated in the comment, because it is a behaviour change for an existing warn-style preToolUse hook. It is the right direction — a hook's own uncaught error surfaces as exit 1 and is indistinguishable from a deliberate one, and the exit code a failed exec produces is shell- and platform-specific (
cmd /cyields 9009 or 1 where/bin/shyields 127), so an allowlist of "real" failure codes would fail open on Windows for exactly this class. The hook store already calls every nonzero non-2 exit an error (last_status = "error"); this branch gives the gate the matching direction.The spec moved with the code:
docs/system-specs/modules/learn-cron-dashboard.md's Tool-Refusal Recovery section described onlyexit 2→BLOCKED:<hook>:<stderr>and now also names any other nonzero exit. NoCHANGELOG.mdentry — that file holds shipped releases only, so the behaviour change is one for the release editor to pick up rather than something this PR can add.Scope held deliberately narrow: the subagent / task-runner path (
fire_tool_hooks) is informational-only by design — the tool is already running when hooks fire and results are discarded — so a preToolUse hook cannot deny there at any exit code or speed. Closing that means moving the hook ahead of execution on the autonomous paths, which is a control-flow change to the autonomous runtime rather than a classification fix. It is tracked on its own in #7547, so merging this and closing #7339 does not lose the tracker for the remaining fail-open path. #7547 also carries the per-hook direction switch the issue names as the escape hatch, and the point at which the verdict predicate is worth hoisting out of_fire— with the second caller in hand, the shape of the shared predicate is finally known.Tests
test_auto_approve_blocked_when_pretooluse_hook_delivers_no_verdict— parametrized over the three failure shapes from the issue's repro (timeoutexit_code=-1+error, crashexit_code=-1+error, missing binaryexit_code=127). Drives the full_run_chatauto-approve path and locks in thatreject_toolis called and the user-facing pill reports the hook blocked.test_auto_approve_allowed_when_pretooluse_hook_exits_zero— pins the new branch to non-0/2 exits, so a delivered allow is not caught by it.chat_runner.pychange stashed, all three failure-shape cases fail (reject_toolcalled 0 times) and the exit-0 case still passes.Manual verification
N/A — unit coverage is sufficient: the tests drive the real
_run_chatauto-approve path end to end rather than the helper in isolation, and the issue's repro is reproduced as the parametrized failure shapes.Related Issues
Fixes #7339
Related: #7547 — the autonomous-path (
fire_tool_hooks) half, deliberately not closed by this PR.Pattern harvest
Rule candidate: review-prompt
Pattern: a security verdict derived from an equality test against a permissive-defaulting field, so an error path that never sets the field reads as "allow" (
exit_code: int = -1withblocked = exit_code == 2, and a[]results list meaning both "no hooks" and "the hook died").Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)