Skip to content

fix(agent): fail closed when a fail_closed shell hook exits non-zero without a directive - #122651

Closed
bablobanov wants to merge 1 commit into
NousResearch:mainfrom
bablobanov:fix/shell-hooks-fail-closed-nonzero-exit
Closed

bablobanov wants to merge 1 commit into
NousResearch:mainfrom
bablobanov:fix/shell-hooks-fail-closed-nonzero-exit

Conversation

@bablobanov

Copy link
Copy Markdown
Contributor

What does this PR do?

A pre_tool_call shell hook declared with fail_closed: true is meant to deny the action whenever the hook cannot be trusted to have produced a directive. One failure shape slipped through and permitted instead: the hook process exits non-zero with nothing on stdout, which is exactly what an uncaught exception in a Python hook script looks like (traceback on stderr, stdout empty).

_evaluate_result keyed the fail-closed decision off "is stdout non-empty and unparseable". With empty stdout, parsed was None, the guard was false, and None reached the caller as "no directive", i.e. permit. The non-zero exit code only produced a warning a few lines earlier.

Now, on a fail-closed hook, a non-zero exit without a directive blocks with hook <command> failed closed: exited <code> without a directive. Everything else keeps its current meaning:

  • exit 0 with empty stdout still permits ("ran fine, nothing to say")
  • a non-zero exit that carries a JSON block directive on stdout still uses that directive
  • exit 2 on a blocking event is handled before this point, as before
  • hooks without fail_closed are unchanged (non-zero exit with empty stdout still permits)

Related Issue

Fixes #102405

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • 🔒 Security fix (a documented deny-by-default gate permitted on the most common hook failure)

Changes Made

  • agent/shell_hooks.py: in _evaluate_result, the fail-closed branch also blocks when parsed is None and returncode != 0; docstring updated
  • tests/agent/test_shell_hooks.py: TestEvaluateResult gains the regression case (exit 1, traceback on stderr, empty stdout, fail_closed=True → block) and three controls: the same result without fail_closed still permits, exit 0 with empty stdout under fail_closed still permits, and a non-zero exit with a JSON directive under fail_closed still uses the directive

How to Test

  1. scripts/run_tests.sh tests/agent/test_shell_hooks.py::TestEvaluateResult -q
  2. On main without the fix the regression case fails and the three controls pass (they pin existing behaviour); with the fix all pass:
fail_closed returncode stdout main this PR
true 1 empty (traceback on stderr) permit block
false 1 empty permit permit
true 0 empty permit permit
true 1 {"decision": "block", ...} block (directive) block (directive)
true 0 garbage text block (unparseable) block (unparseable)
  1. scripts/run_tests.sh tests/agent/test_shell_hooks.py -q: 46 passed, 10 skipped.
  2. The 17 test files that reference shell_hooks: 304 passed, 1 failed; the one failure (tests/hermes_cli/test_plugins.py::TestPluginDiscovery::test_enabled_portable_plugin_registers_components) fails the same way on unmodified main on this machine.
  3. scripts/check-windows-footguns.py: no findings in the changed lines (the two bare read_text() calls it reports in tests/agent/test_shell_hooks.py are pre-existing, lines 199 and 217, and untouched here).

Checklist

Code

Documentation & Housekeeping

  • Documentation — N/A (the module docstring already describes fail_closed as deny-by-default; behaviour now matches it)
  • cli-config.yaml.example — N/A
  • CONTRIBUTING.md / AGENTS.md — N/A
  • Cross-platform impact — N/A (no process or path handling changed)
  • Tool descriptions/schemas — N/A

…without a directive

`_evaluate_result` decided the fail-closed outcome from stdout alone. A
hook that died with an uncaught exception writes its traceback to stderr
and leaves stdout empty, so `parsed` was None and the action was
permitted: the failure shape most likely in practice was the one the
gate did not cover. A non-zero exit without a directive now blocks on a
fail_closed hook; exit 0 with empty stdout still permits, and a non-zero
exit that carries a JSON directive still uses that directive.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/config Config system, migrations, profiles labels Sep 25, 2026

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head e3ae5f21d882454e81f2b0922a343846f7dc4def against current main / PR base 59004a62356f3a4697ab0fe8ad5086d2b405e2a6. The merge base is fdec926ef54391edcf6caad5f7f6761fdcccdaa2; this branch is one commit ahead and four commits behind current main.

The source change itself is correct. I traced the path from the registered callback through _spawn, _evaluate_result, _parse_response, and the pre_tool_call dialect parser. The important boundary is parsed is None: that means the hook produced no recognized directive, not merely that stdout was empty. On this head a non-zero exit with empty stdout blocks, a non-zero exit with a syntactically valid JSON object that contains no directive also blocks, a clean exit with no directive still permits, and a recognized block / modify / approve directive still returns before the fail-closed guard. Exit code 2 retains its existing blocking precedence. I do not see a runtime defect in the two-file implementation.

There is, however, a repository-level blocker before this should merge: this is a second live carrier for the same defect class. #122576 was opened at 11:35 UTC, fixes the same #102405 invariant, changes the same agent/shell_hooks.py and tests/agent/test_shell_hooks.py surfaces, and already carries the lineage from jorgefusterr's original report and marcelobernardo-cf-stikcky's closed-unmerged #108664. I reviewed #122576 at 1e9104b360befce1d02d9bcc9809b2b96bfcab10 earlier today: #122576 (review). That review also verified the broader response matrix and the exact historical counterexample that made the earlier narrow fix insufficient.

This PR's body mentions #108664 and the unavailable #102417, but it does not acknowledge the already-open #122576. We should not land both implementations and erase the earlier carrier's work by accident. Pick one carrier explicitly. If #122651 is retained, preserve #122576 / Enough1122 and #108664 / marcelobernardo-cf-stikcky as prior work, carry over the stronger regression coverage, and close/reconcile the other PR as superseded rather than silently duplicating it. If #122576 remains the carrier, #122651 should close as the later duplicate. Either route is technically workable; two independent merges are not.

There is one verification gap on this branch that matters because of that history. The new tests pin the common empty-stdout crash, the default fail-open control, the clean-empty control, and a recognized block directive. They do not pin the exact second escape discovered during #108664: non-zero exit + valid JSON + no recognized directive (for example {"foo":"bar"}). The implementation here handles it correctly because parsed stays None, but that is precisely the case an earlier attempted fix missed. If this branch becomes the carrier, add that regression rather than relying on reasoning alone; keep recognized approve / modify as positive controls so the guard does not become broader than the contract.

The other open shell-hook work is adjacent rather than substitutive. #91017 also touches agent/shell_hooks.py / its tests but changes cwd projection in _serialize_payload; #32613 touches the spawn path for slow-hook visibility; #84439 adds gateway route context. #111315 and #119421 address registration on other execution surfaces, and #100942 remains the separate non-TTY registration/fail-open problem. Those compose with this evaluator fix: a correct evaluator cannot protect a hook that was never registered. #92553, the separate dropped-approve parser bug, is closed; the current parser recognizes approve, and this change preserves that path.

Exact-head GitHub checks have not executed. CI (https://github.com/NousResearch/hermes-agent/actions/runs/36139420670), Docker (https://github.com/NousResearch/hermes-agent/actions/runs/36139420151), and Nix (https://github.com/NousResearch/hermes-agent/actions/runs/36139420170) are all action_required, and there are no commit status contexts. I am not treating action_required as a test failure, but it is also not green evidence. The local counts in the PR description are useful author receipts; they do not make this exact commit CI-green. Whichever branch survives consolidation still needs passing exact-head checks before merge.

So the code path is sound. The remaining work is repository truth: consolidate the duplicate carrier with credit intact, pin the previously discovered JSON/no-directive counterexample on the surviving branch, and get that resulting exact head green.

Comment thread agent/shell_hooks.py
if parsed is None and fail_closed and stdout and not _is_json_object(stdout):
# A fail-closed gate must not silently allow on garbage stdout (e.g. a stack trace).
return _fail_closed_block(spec, "unparseable stdout (expected a JSON object)")
if parsed is None and fail_closed:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right class-level boundary: parsed is None means “no recognized directive,” so this also correctly blocks exit 1 + {"foo":"bar"}, not just empty stdout. Please pin that historical counterexample if this branch survives consolidation. #108664's closing analysis is where an empty-output-only fix was shown to be insufficient, and #122576 already carries regression coverage for it. The current tests here only exercise empty stdout plus a recognized block directive; keep the valid-JSON/no-directive case (and a recognized approve/modify positive control) so the invariant cannot narrow again later.

@bablobanov

Copy link
Copy Markdown
Contributor Author

Closing in favour of #122576, which was opened two hours earlier, fixes the same invariant in _evaluate_result and already carries the exit 1 + valid-JSON-without-directive regression that @andrexibiza asked to pin here. Thanks for the review and for catching the overlap; one carrier per defect is the right outcome.

For the record, this branch reached the same boundary independently (parsed is None on a fail-closed hook with a non-zero exit blocks; exit 0 with empty stdout still permits; a directive on a non-zero exit is still honoured), so #122576's behaviour is confirmed from a second implementation.

@bablobanov

Copy link
Copy Markdown
Contributor Author

For anyone reading the three red checks here: the CI, Docker Build, Test, and Publish and Nix flake check runs on e3ae5f21d8 have zero jobs. They were queued at 13:11 UTC awaiting first-contributor approval and were marked failure at 15:25 UTC, the moment this PR was closed, so nothing in this branch was actually built or tested. Not a flake and not a code failure; the live carrier for the fix is #122576.

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

Labels

area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

shell hooks: fail_closed does not block when the hook exits non-zero with empty stdout

3 participants