fix(hooks): fail closed on PreToolUse hooks that time out, crash, or are missing (#7339) - #7352
fix(hooks): fail closed on PreToolUse hooks that time out, crash, or are missing (#7339)#7352bolichen97 wants to merge 1 commit into
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Right default in the right layer: fail-closed only where the gate actually exists, per-hook escape hatch, fail-soft on load, strict at the write boundary. Suggestions
[DESIGN-REVIEWED] e028248 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence gathered. Producing the review. First-Principles-Verdict: CONCERNS The fix is real and cause-level, but What this change shipsIntent: make a PreToolUse policy hook that times out, crashes, or is missing deny the tool instead of silently approving it (#7339) — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] e028248 |
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: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/hooks.py:3127 -- False positive or not applicable? A repository writer can comment: |
…are missing (#7339) A PreToolUse hook that cannot deliver a verdict was treated as an allow, so a timeout, a crash, or a missing hook binary silently opened the gate it was installed to close — including on the autonomous path. Make an undeliverable verdict deny by default, make each hook's on_error fail direction reachable through the API and normalized on update, and keep the prompt-structure summary coherent when the gate refuses.
ee162da to
e028248
Compare
|
Superseded: issue #7339 is already fixed on main by merged PR #7422 (fix(hooks): fail closed when a PreToolUse hook delivers no verdict), which added the fail-closed handling for timeout/crash (-1) and unexecutable (126/127) exit codes now live in src/kiro_crew/dashboard/chat_runner.py. This PR's core fix is fully covered. Its only unique piece -- a per-hook on_error: fail_closed|fail_open opt-out knob -- is a security-gate opt-out that needs a maintainer decision, not a mechanical rebase, so closing rather than driving to green. Happy to re-open scoped to just that knob if it's wanted. |
Fixes #7339.
Problem
PreToolUse script hooks are the policy gate on the auto-approve path, but every failure mode of the hook itself resolved to allow. A hook that exits 2 within its window blocks correctly; the same hook made slow, killed, or removed silently stopped blocking anything.
Root cause (as diagnosed in the issue):
run_script_hook(src/kiro_crew/hooks.py) maps a timed-out hook toexit_code=-1and a missing binary to127;blockedis defined asexit_code == 2, so both resolve to "no opinion."_fire(src/kiro_crew/dashboard/chat_runner.py) drops any non-0/non-2 result, and_pre_tool_hooks_should_blocktreats the empty list as pass-through, so the tool is approved.Design decision
The issue's maintainer-bot comment flagged that the remedy carries a design decision (default direction, per-hook override, autonomous-path contract) needing an owner. The decision made here:
on_errorfield onScriptHookwith valuesfail_closed/fail_openand a""sentinel that resolves at read time: fail-closed by default for PreToolUse, fail-open for every other (non-gating) event. This preserves historic behavior for non-gating hooks and for anyone who explicitly opts intofail_open.Changes
on_errorfield,ScriptHookResult.failed_to_run, andshould_block_pre_tool_use(). Config parsed fail-soft infrom_dict(junk resolves to sentinel, never raises)._firenow emits aBLOCKED:marker for a fail-closed failed-to-run PreToolUse hook, so_pre_tool_hooks_should_blockblocks it. Explicitfail_openhooks keep the historic pass-through.fire_tool_hooksreceives the tool-call event after kiro-cli has already auto-approved and started the tool, so it architecturally cannot block. Rather than a false guarantee, it now surfaces the fail-open gap loudly (non-fatal WARNING naming the hook, tool, and PreToolUse script hooks fail open: a deny hook that times out, crashes, or is missing is silently approved #7339) instead of silently discarding results. Its docstring was rewritten to state this contract precisely and point to chat_runner as the gating surface.on_errorvalidated at the write boundary (create + update), reachable viaHOOK_CREATE_SCHEMA/HOOK_UPDATE_SCHEMA;update()normalizes identically tocreate().Tests
New
test/test_fire_tool_hooks.pycovers, with real subprocesses: timeout / missing-binary / crash cases now block under the fail-closed default (assertions that fail against pre-fix code); per-hookfail_openoverride still allows; non-gating events never block; config plumbing and API-schema flow-to-store; create/update normalization parity; and the autonomous-path WARNING + non-fatal contract.Testing notes
This branch was developed under a network-restricted sandbox (repository access only), where PyPI is unreachable and the full pytest suite could not run (aiohttp/yarl/hypothesis/pytest-asyncio unavailable). Verification performed in-sandbox:
py_compilepasses on all changed files;import kiro_crew.hookssucceeds.Please run
python -m pytest test/test_fire_tool_hooks.pyin CI to confirm the new tests pass with real dependencies.Follow-up (non-blocking)
The dashboard UI control for
on_errorinwebsite/src/pages/HooksPage.tsxwas intentionally deferred (the override is reachable via the API and the fail-closed default needs no UI; the TS/i18n build could not be verified under the restricted network mode). Worth tracking as a follow-up.Related
Pattern harvest
Rule candidate: A test that asserts a spawned child's own exit code must first neutralize the spawn chokepoint, or it is asserting about the host. In this repo
run_script_hook, cron scripts, app backends and MCP spawns all route argv throughsandbox.sandboxed_spawn_argv->wrap_argv+cgroup_scope_argv, so the process that runs is the sandbox launcher and the reportedreturncodeis the launcher's whenever it refuses — 1 withsandbox: BLOCKEDwhere a hiding/sealing mount fails (dev container), -1 whereunshare(CLONE_NEWNS)is EPERM (GitHub runners) or where there is no backend at all (Windows). If the test is about the child's semantics rather than about confinement, patchkiro_crew.sandbox.wrap_argvandkiro_crew.sandbox.cgroup_scope_argvto identity at the sandbox module (they are imported lazily at call time) and say in a docstring why. Two corollaries, both of which bit here: (a) prefer the passthrough patch overskipif(not _sandbox_backend_available())— that guard trustsdetect_backend(), which answers "available" on a host whose launcher then fails at the mount step, which is exactly whytest_stop_hook_continuation.py::TestRealHookProcessis red on main in this environment; (b) a fail-direction test must assert WHICH failure branch ran ("Timed out" in error, the injected exception's text, a specific exit code) and not only the umbrella predicate —failed_to_runis satisfied by any spawn failure, so three tests here were green on all three hosts while the timeout branch they claimed to cover never executed. The same reasoning kills non-portable hook commands:sleep 8; exit 2andtruecannot run undercmd /c, so use the quoted-interpreter + quoted-script shape (test_script_hooks._script_command) and assert shell-specific exit codes like 127 underif not _IS_WINDOWS.