Skip to content

Plugin hook policy: hung or raising guards fail closed without wedging, block outranks approve, shell approve reaches the gate, async pre_gateway_dispatch awaited (#105223 #109624 #87420 #92553 #105827 #110241) - #118844

Merged
teknium1 merged 8 commits into
mainfrom
fix/hooks-policy
Sep 22, 2026

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

Guard/policy plugins can no longer be silently defeated on the hook path: a raising or hung pre_tool_call guard fails closed without wedging the agent until restart, a plugin's veto outranks another plugin's approve, shell hooks can escalate to the approval gate, execution middleware fires on cold surfaces, and async pre_gateway_dispatch callbacks run on the gateway loop.

Root cause in one sentence: the dispatcher treated an abandoned worker as "running forever", treated a raising guard as "no opinion", picked the first approve/block in registration order, and only resolved async callbacks through a helper thread.

Validation

Issue Live repro before (real PluginManager / shell hook script / GatewayRunner) After Test (red on base → green)
#105223 hung guard: 8 calls → 1 worker, every call blocked forever 8 calls → 3 workers (cap), still blocked with a named WARNING; 2nd call after the window allowed test_hung_worker_does_not_fail_closed_forever, test_hung_worker_caps_new_call_identities_after_suppression
#109624 results=[] — tool ran (fail-open) [{'action':'block','message':'pre_tool_call plugin callback boom raised RuntimeError: …'}] test_pre_tool_call_callback_exception_fails_closed[2]
#87420 ('approve', 'earlier plugin approves') ('block', 'later security plugin blocks') test_later_block_outranks_earlier_approve, test_first_approve_wins_among_approves_and_keeps_later_modify
#92553 shell approve → None, tool ran unprompted {'action':'approve','message':…} → human gate test_approve_is_parsed_like_the_plugin_directive[4], test_approve_reaches_the_human_gate_through_plugin_manager
#105827 middleware skipped, terminal ran gate fired, terminal not run test_execution_chain_lazily_discovers
#110241 sync bridge, 2.00s thread timeout, skip lost in-loop await, 0.05s, skip honoured test_async_hook_callback_is_awaited_on_the_gateway_loop, test_narrow_legacy_signature_still_gets_only_its_fields

Existing-test changes (justified in commit bodies): test_hung_worker_blocks_new_call_identity_after_suppression re-pinned at the new bound (#105223); #109632's two tests merged into one parametrized test; three sync hook stubs in tests/gateway/test_bot_loop_guard.py made async (#110241). No other existing assertion changed.

Lane run scripts/run_tests.sh tests/hermes_cli tests/agent/test_shell_hooks.py tests/gateway: 22775 passed, 13 failed — all 13 in untouched files, 9 pass in isolation (40-worker load), 4 test_dashboard_auth_gate SystemExit: 75 fail identically on a pristine base worktree (port held on the host). Post-rebase on ee8a919fd27: touched test files 163 passed, 0 failed.

Prompt-cache invariant: nothing here mutates past context or the system prompt mid-conversation.

Fixes #105223. Fixes #109624. Fixes #87420. Fixes #92553. Fixes #105827. Fixes #110241.
Salvages #105832, #109632, #110265. Supersedes #105241, #109441, #104763, #68644, #87449, #92562, #110325, #102485, #110253.

Infographic

Plugin hook policy: guards fail closed, never wedge

@teknium1 teknium1 added the ci-reviewed applied to manually approve dangerous changes label Sep 22, 2026
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 6505462 — fix(plugins): ainvoke_hook shares the sync path's failure co

debug info

CI timings

CI timings · View report · View job

Wall time 5m47s vs 6m24s (-9.6%). 6 job(s) slower, 4 faster, 3 unchanged.

  • Docs Site / docs-site-checks: +62.0s
  • Python tests / Run tests: -57.0s
  • OS-specific tests / Windows-only tests: -44.0s
  • Python tests / e2e: -14.0s
  • Check no committed infographics / check-no-committed-infographics: -11.0s

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins area/auth Authentication, OAuth, credential pools sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 22, 2026
liuhao1024 and others added 8 commits September 21, 2026 23:40
)

`_run_execution_chain` read `get_plugin_manager()._middleware` directly,
bypassing the lazy discovery every other delivery entry point gained in
the #64178 parity work, so `tool_execution`/`llm_execution` middleware
registered by user plugins silently failed to fire (fail-open) on
surfaces that never run discovery at startup: query mode `chat -q`/`-z`,
cron delivery, dashboard, TUI slash workers. Route it through
`_delivery_manager()` like invoke_hook/invoke_middleware/has_middleware.

(cherry picked from commit 06b9d44)

Fixes #105827
Salvages #105832
A `pre_tool_call` guard that raised failed OPEN (only `_report_hook_failure`,
results stayed empty, the tool ran) while one that timed out failed CLOSED with
a block directive. For a veto hook a crashing guard is the control quietly
disappearing; make both failure modes of `_HOOK_TIMEOUT_FAIL_CLOSED_HOOKS`
consistent: an exception appends a block directive in addition to the warning.

Fixes #109624
Salvages #109632

(cherry picked from commit 6ccf5d8)
Follow-up trim of the #109632 salvage: the block directive a raising
`pre_tool_call` guard produces reused the timeout wording, so an operator could
not tell a crashing guard from a slow one from the tool result. Build it from
the callback name and `TypeError: ...` (error text truncated like
`_report_hook_failure`). The two salvaged tests collapse into one parametrized
invariant (caller-thread and bounded-worker path) that also pins the message
shape and that a sibling callback's result still flows.

Part of #109624
Salvages #109632
…call until restart

`_run_hook_callback_bounded` treated any live abandoned worker for a callback
(`bool(self._hook_abandoned.get(suppression_key))`) as "still running", so one
never-returning `pre_tool_call` callback made every later tool call fail closed
with the timeout message until the process restarted. The timeout path
self-heals through the 60s suppression window; the abandoned path never did.

Policy now: while the suppression window is open the callback is skipped as
before. After it expires a fresh call id may start a new worker even though the
abandoned one is still alive — capped at `_HOOK_MAX_ABANDONED_WORKERS` (3) live
abandoned workers per callback so a hung plugin cannot leak a thread per call
(the #98382 constraint). At the cap the callback keeps being skipped (fail-closed
for pre_tool_call) with a WARNING naming the callback and its module, until one
of its workers finishes and frees a slot.

Test changes: `test_hung_worker_blocks_new_call_identity_after_suppression`
encoded the removed behaviour (exactly one worker, forever); it becomes
`test_hung_worker_caps_new_call_identities_after_suppression`, which pins the
same invariant it was protecting — bounded leak, never one per call — at the new
bound and checks the warning. `test_hung_worker_does_not_fail_closed_forever`
is the #105223 regression (red on base: call-c returned the block directive).

Redone slim against the per-call-id gate that landed in #111177; #105241
targeted the pre-#111177 shape and needed plugins_ledger/__init__ changes for a
one-retry-then-quarantine policy. Its analysis and shape informed this fix.

Fixes #105223
Supersedes #105241
Co-authored-by: fangliquanflq <fangliquan@qq.com>
`_get_pre_tool_call_directive_details` returned the first valid block-or-approve
in registration order, so a plugin registered earlier that returned `approve`
hid a later security plugin's `block`; under `approvals.mode: off` an approve
means no prompt at all, so the veto was dropped silently. Precedence is now
`block` > `approve` > none: a valid block still returns immediately (modify
directives seen before it stay attached, as before), a valid approve is held
back until the whole result list has been scanned for a veto, and among approves
the first valid one (with its rule_key) still wins. Modify accumulation is
unchanged and now also keeps modify directives that follow the winning approve,
since the scan no longer stops there. Docstring and hooks.md no longer describe
"first valid directive wins".

Slim redo of #68644 (earliest) and #87449 against the modify-aware shape of the
function on main; both PRs predate it and could not be cherry-picked.

Fixes #87420
Supersedes #68644
Supersedes #87449
Co-authored-by: synscott <1563043+synscott@users.noreply.github.com>
Co-authored-by: Jack Lau <72348727+jackulau@users.noreply.github.com>
…te with "approve"

`agent/shell_hooks.py::_parse_pre_tool_call` translated only the block and
modify dialects, so a shell hook printing the documented
`{"action": "approve", ...}` parsed to None and the tool ran with no approval
prompt — silently, with exit 0, valid JSON and `hermes hooks doctor` green.
The Python-plugin side already accepts approve and routes it through
`_resolve_block_from_details` → `request_tool_approval`; the shell parser now
yields the same `{"action": "approve", "message"?, "rule_key"?}` shape (optional
fields kept only as non-empty stripped strings), so `hermes hooks test` prints
it under `parsed:` and the dispatcher escalates it. the `decision` dialect's
`{"decision": "approve"}` means auto-ALLOW, not "ask a human", so it is
deliberately not mapped; that dialect has no top-level ask dialect to mirror.

Slim redo with credit: #92562 (earliest) bundled a larger policy-authority
rework; #110325 carried the same parser change plus an unrelated rule_key
default change and 10+ tests.

Fixes #92553
Supersedes #92562
Supersedes #110325
Co-authored-by: fangliquanflq <fangliquan@qq.com>
… loop

`GatewayInboundMixin._hm_pre_gateway_dispatch_hook` was a plain `def` calling
the sync `hermes_cli.lifecycle.invoke_hook` from the async `_hm_admit_event`,
so an `async def pre_gateway_dispatch` callback was resolved through
`resolve_plugin_command_result` on a helper thread with its own loop: the
gateway loop blocked for the callback's whole duration and any loop-bound await
(an `asyncio.Event` set by a loop task, a loop-bound aiohttp session,
`asyncio.to_thread`) could never complete, failing at 30s.

Add `PluginManager.ainvoke_hook` (+ `hermes_cli.plugins.ainvoke_hook` /
`hermes_cli.lifecycle.ainvoke_hook`): same payload narrowing (shared
`_hook_callback_kwargs`), observer + isolation semantics and result contract as
`invoke_hook`, but awaitable results are awaited on the caller's loop.
`pre_gateway_dispatch` stays intentionally unbounded. The inbound hook becomes
`async def` and `_hm_admit_event` awaits it; the sync `invoke_hook` is untouched
for every other caller. Existing tests that stubbed the hook synchronously are
adapted to the async seam.

Fixes #110241
Salvages #110265

(cherry picked from commit 22bb10d)
Follow-up trim of the #110265 salvage. `ainvoke_hook` logged raising callbacks
with a bare warning; route them through `_report_hook_failure` (warn-once per
distinct failure, #111922) and, for `_HOOK_TIMEOUT_FAIL_CLOSED_HOOKS`, append
the same named block directive the sync path emits (#109624), so the async twin
cannot drift into a fail-open policy path. Tests trimmed to the salvage bar: the
in-loop await is proven once through the real `_handle_message` path
(`test_async_hook_callback_is_awaited_on_the_gateway_loop`); the manager-level
duplicate is dropped and the narrowing test also pins failure isolation. Docs:
`pre_gateway_dispatch` callbacks may be `async def` and stay unbounded.

Credit order for the three PRs fixing this gap: #102485 (dmspark, earliest,
pre-decomposition `gateway/run.py`), #110253 (KoNit-K, bounded the hook —
rejected by design: neither fail mode is acceptable for a policy gate), #110265
(twidtwid, reporter; cherry-picked because it matches the ainvoke_hook shape,
keeps the hook unbounded, and adapts the existing sync test seams honestly).

Part of #110241
Supersedes #102485
Supersedes #110253
Co-authored-by: David Marcus <dmspark@users.noreply.github.com>
Co-authored-by: KoNit-K <konit.block@protonmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools ci-reviewed applied to manually approve dangerous changes comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

4 participants