fix(hooks): stop shell hook approval requests from bypassing confirmation - #92562
fix(hooks): stop shell hook approval requests from bypassing confirmation#92562fangliquanflq wants to merge 2 commits into
Conversation
andrexibiza
left a comment
There was a problem hiding this comment.
Blocking review (GitHub will not permit this identity to set REQUEST_CHANGES without explicit repository review access).
The shell bridge itself is now doing the right thing: the exact approve shape from #92553 survives parsing, malformed/unsupported pre_tool_call directives become visible and honor fail_closed, doctor and runtime share one semantic validator, and the one-hook production path reaches the existing human gate with the supplied reason/rule key. Exact-head CI/Docker/Nix are all green.
There is still one authority blocker before this should become the documented shell-hook contract, though: the shared pre-tool aggregator lets an earlier approve erase a later hard block.
agent.shell_hooks.register_from_config() appends configured shell callbacks to manager._hooks[event] in config order. The lifecycle dispatcher runs the callbacks and returns their results in that order. _get_pre_tool_call_directive_details() then walks those results and immediately returns on the first valid block or approve directive. That means this deterministic policy stack is unsafe:
- shell hook A returns
{"action":"approve","message":"confirm sensitive write","rule_key":"write_file:ssh"}; - later shell hook B returns
{"action":"block","message":"this exact target is forbidden"}; - the aggregator returns A's
approveand discards B's block result; _resolve_block_from_details()invokesrequest_tool_approval(); aoncedecision — or an already-cached session/permanent grant for A's rule key — returnsNone;- the tool executes even though another configured policy produced an unconditional veto.
The callbacks all ran, so this is not an execution-order theory; it is the result-selection logic in hermes_cli/plugins.py. approve is an additive request for consent, not authority to cancel an independent deny. Current check_all_command_guards() already uses the correct repository-wide shape for this class: gather every independent finding once, then decide, with a hard block retaining dominance and approval keys remaining independently authoritative.
Please repair the shared aggregator rather than special-casing shell hooks:
- invoke every
pre_tool_callhook exactly once and preserve #87482's accumulatedmodifybehavior; - gather all valid control directives before resolving them;
- if any valid
blockexists, block regardless of registration order or any cached/interactive approval; - otherwise aggregate the approval reasons and rule keys into one explicit request whose persistence records every approved key, or require independent decisions. A grant for one rule must not satisfy another rule;
- add deterministic regressions for
approve → block, cached-approvedapprove → block, and two independent approve keys, alongside the existing single-invocation/modify tests.
This is materially the same authority lesson as the terminal gather-then-decide machinery: status as “approved” cannot collapse independent policy findings. #92562 expands the existing Python-plugin surface into operator-authored shell policy, so leaving first-valid-wins here turns the newly documented feature into a way for hook ordering to weaken policy.
Topology/provenance:
- #92553 by @hubbadubbadubdab owns the concrete shell-parser/doctor bug; this PR by @fangliquanflq is the correct owner of that bridge repair.
- Merged #60504 re-landed plugin approval authority while explicitly preserving @kshitijk4poor's #58698 and @doncazper's #59163 rule-key work. Those are antecedent architecture, not duplicates. In fact, the later-block bypass defeats #59163's invariant that one approval key must not authorize a different rule.
- Merged #87482 preserves @NikolaRHristov's #28953 (and credits @elasticdotventures' #19305) for single-fire
modifyaggregation. The repair here must compose with that implementation rather than re-invoke hooks or discard accumulated modifications.
Exact object reviewed: head 944b9d340b08273d10b68105f7afbaad88de51aa, merge-base/PR base 987064caa4f8845f605ac7346fed5b72fddfb21c, current main 530028c213ae9eed5d7f1a826451e0edf24a11d2. The branch is 1 commit ahead / 4 behind current main; those four main commits touch the gateway control-socket/update lane rather than these hook files. Hosted receipts are genuinely green: CI 32605161706, Docker 32605161110, Nix 32605161158. They verify the submitted object, but the present tests cover only one approval hook and therefore do not exercise the cross-hook veto boundary above.
|
Addressed the authority issue in d82e616.
Verification: 68/68 tests passed in |
What does this PR do?
Shell
pre_tool_callhooks can now require human confirmation with the documented{"action":"approve"}response instead of silently allowing the tool call. Unsupported directive values are reported and honorfail_closed, andhermes hooks doctornow rejects syntactically valid JSON that the runtime cannot honor.Symptom
A configured shell hook that returns
{"action":"approve"}is reported as healthy byhermes hooks doctor, but its response is discarded and the tool executes without an approval prompt.Impact
Operators relying on shell hooks as approval policy can have protected tool calls proceed without the requested confirmation. The affected population is limited to users with
pre_tool_callshell hooks that emit approval directives.Bug Cause
Trigger:
agent/shell_hooks.py/_parse_response()when apre_tool_callhook returns an approval directive.Causal chain:
actionset toapprove._parse_response()recognizes onlyblockandmodify, so it returns no directive.Why it is wrong: The shell bridge silently drops a documented control directive even though the downstream plugin hook path already supports approval escalation.
Working sibling / contrast: Python
pre_tool_callhooks returning the same canonical approval shape already reachresolve_pre_tool_block()and the shared approval gate. Shellblockandmodifyresponses are also normalized correctly.Ruled out: The approval gate itself is not bypassing the request. An end-to-end regression test proves that once the shell response reaches the existing directive dispatcher,
request_tool_approval()receives the expected tool, reason, and rule key.Fix
The shell response parser now preserves canonical approval directives and their optional
messageandrule_key. A shared semantic validator reports malformed or unsupported directives, lets valid no-op objects remain no-ops, blocks invalid directives whenfail_closedis enabled, and powers the doctor smoke test so runtime and diagnostics agree.Related Issue
Fixes #92553
Type of Change
Changes Made
agent/shell_hooks.py- parse shell approval directives and centralize semantic response validation.hermes_cli/hooks.py- make hooks doctor reject unsupported directive JSON.tests/agent/test_shell_hooks.py- cover parsing, approval escalation, and fail-closed behavior.tests/hermes_cli/test_hooks_cli.py- cover doctor diagnostics for unsupported directives.website/docs/user-guide/features/hooks.md- document the shell approval response and unsupported-directive failure semantics.How to Test
pre_tool_callshell hook that prints{"action":"approve","message":"confirm"}and verify the normal approval gate is invoked.{"action":"permit"}and verifyhermes hooks doctorreports an unsupported response; withfail_closed: true, verify the runtime blocks the call.All 35 selected tests pass on Windows 11.
Checklist
Code
fix(scope):,feat(scope):, etc.)Documentation & Housekeeping
cli-config.yaml.exampleupdate is not applicable because no config key changedCONTRIBUTING.mdandAGENTS.mdupdates are not applicable because no architecture or workflow changedScreenshots / Logs
Not applicable. The focused automated regression suites above exercise the shell response bridge, approval escalation, fail-closed behavior, and doctor diagnostic path.