fix(cron): harden lifecycle guard against multi-line payloads, binaries, and killall - #77383
Conversation
…es, and killall
The gateway lifecycle guard (cron/lifecycle_guard.py) crashed or false-
blocked innocent terminal commands in three ways:
1. False block: the tokenizer split commands with splitlines(), destroying
quote context in multi-line payloads (python -c "...", heredocs). A
parenthesized path like open('/x/y.json') was promoted to a segment
executable, the named file was read and scanned, and any text file that
merely mentions a lifecycle command blocked the whole command.
2. Crash: a local binary invoked by absolute path was read and decoded as
text; the recursion re-tokenized NUL-laden machine code into NUL-bearing
paths, and os.open raised an uncaught ValueError: embedded null byte.
3. Bypass: the p?kill\b pattern missed the killall variant of the gateway
kill command.
Fixes:
- _split_logical_lines: split only at unquoted newlines so quotes span
physical lines (shell-faithful) while each real command line keeps its
own segment.
- _looks_like_script: skip local binaries (NUL bytes in header) in the
referenced-script walk; non-local paths still yield for remote backends.
- _read_referenced_script: tolerate ValueError (embedded NUL) at open/read,
and treat directories as "nothing to scan" while FIFOs/devices/sockets
still fail closed.
- _contains_unsafe_gateway_action: skip NUL-containing script text before
recursion (remote backends may return binaries decoded as text).
- terminal_tool._read_script_in_env: skip binaries before decoding.
- Branch D: p?kill[a-z]*\b now catches killall/pkill variants.
Tests: 9 new regression tests in test_gateway_restart_loop.py; 5 fail on
the previous code (crash, false block, killall bypass). Full guard file:
91 passed. Full suite: 984 passed, 1 pre-existing order-dependent flake
(test_resume_quiet_stderr) identical on unpatched main.
References NousResearch#76762 (residual os.open crash), NousResearch#77173 (directory/binaries).
Related: #77151 fixes the same lifecycle-guard false-block/crash family with a different candidate-restriction approach. This PR additionally changes multi-line quote handling and killall/pkill detection; maintainers should select or consolidate the policy. |
|
Thanks for the triage note on #77151 — acknowledging the overlap so maintainers can consolidate. Confirmed overlap with @avifenesh's #77151 (same files, same bug family):
What this PR adds beyond #77151 (unique pieces worth porting if #77151 merges first):
Both PRs preserve all security invariants (lifecycle commands, referenced-script scanning, the .py special case still block). Happy to rebase/rework either direction — whatever's easiest for maintainers to merge. |
SummaryTwo open PRs address the reported lifecycle-guard crash and false-blocking causes. #77151 narrows unsafe-candidate handling and adds regression coverage, while #77383 overlaps those fixes and additionally preserves multi-line quote context and detects killall/pkill gateway termination variants. Related pull requests
Duplicates#77151 and #77383 substantially duplicate the NUL-crash, directory false-positive, binary false-positive, and multi-line-payload fixes; #77383 additionally covers quote-aware tokenization and killall/pkill detection. Suggested consolidationkeep open with a salvage path for #77383: retain its quote-aware _split_logical_lines handling, remote-binary recursion guard, and killall/pkill detection, then reconcile its candidate/file-classification policy with #77151. Do not merge either PR from this lane because no verify best_fix verdict is recorded; after maintainers select the policy, close the superseded PR as a duplicate. Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 25 kB of PR diffs, 8 kB of issue/PR text, 2 kB of discussion (3 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
|
New data point for the consolidation decision: #78201 (opened 2026-08-04) independently fixes the same NUL-byte crash with the identical Suggest porting the RuntimeError catch + symlink-loop regression test into whichever fix survives consolidation — they're small and independent of the tokenizer policy choice. Happy to rebase this PR onto current main whenever the policy decision lands. |
yuzilongleif-collab
left a comment
There was a problem hiding this comment.
I reproduced four gaps on current head 3721eca5f. The focused official runner is green here (91 passed, 0 failed), but these cases are outside the current assertions; the first two are blocking.
1. Blocking: an ordinary apostrophe can disable nested-script detection
_split_logical_lines() tracks quotes before shlex and does not understand # comments. An unmatched apostrophe in prompt/prose causes all following physical lines to become one unterminated logical line; shlex raises ValueError, and _iter_command_segments() silently skips the whole input.
Concrete RED/GREEN check:
inner.write_text("#!/bin/sh\nhermes gateway restart\n")
outer.write_text(f"#!/bin/sh\nbash {inner}\n")
check_gateway_lifecycle("Don't restart anything", str(outer))Expected: GatewayLifecycleBlocked. Actual on this PR: returns cleanly; current main / #78201 blocks it. The same shape can occur in a comment such as # don't kill this before a nested script reference. Please make malformed/unmatched quote handling fail safely (for example, strip comments before quote tracking and fall back to physical-line parsing rather than dropping the entire scan) and add this exact regression.
2. Blocking: _looks_like_script() can hang forever on a bare FIFO
The existing scanner deliberately uses os.open(..., O_NONBLOCK). The new _looks_like_script() calls ordinary open(path, "rb"). For a bare executable candidate that is a FIFO, candidate.exists() is true and the pre-scan blocks waiting for a writer.
I ran the public guard in a child process against a real FIFO:
- this PR: still alive after 2 seconds (
bare_fifo_guard_hung=True); - current main / #78201: returns immediately (
False).
The existing FIFO test uses /bin/bash {fifo}, which takes the shell-argument branch and never reaches _looks_like_script(). Please use a nonblocking bounded probe or avoid opening non-regular candidates in this pre-filter, and add a bare-FIFO regression.
3. p?kill[a-z]* accepts arbitrary command-name suffixes
The Branch D widening fixes killall, but it newly classifies unrelated executable names as termination commands:
assert contains_gateway_lifecycle_command("killall hermes-gateway") is True
assert contains_gateway_lifecycle_command("killjoy hermes gateway") is False
assert contains_gateway_lifecycle_command("killer gateway hermes") is False
assert contains_gateway_lifecycle_command("pkillhelper gateway hermes") is FalseOn this PR all four return True; on current main the last three return False. Please enumerate exact intended commands (kill, pkill, killall) with command boundaries and add adjacent-identifier/prose negatives.
4. The heredoc test does not cover the path-promotion shape claimed by the PR
_split_logical_lines() preserves newlines inside ordinary quotes, which fixes the multi-line python -c "..." case, but it is not heredoc-aware. The current test assigns the path as data (p = '/path'), so the path is never promoted to a command token. This still false-blocks on the PR exactly as on main:
data.write_text("note: " + "hermes " + "gateway " + "restart\n")
cmd = "python3 - <<'EOF'\n" + f"open('{data}').read()\n" + "EOF\n"
assert contains_gateway_lifecycle_command_or_referenced_script(cmd) is FalseActual: True. Please either make heredoc bodies opaque to shell referenced-script discovery and add this regression, or narrow the PR/test claim to the quoted multi-line payload case it actually fixes.
|
Two additional remote-execution regressions are reproducible on current head 1. NUL-bearing remote text is skipped before scanning, but its prefix can execute
if "\x00" in script_text:
continuebefore recursively scanning the returned text. With a remote reader returning: this head reports Please scan the executable prefix safely or reject/fail closed, rather than 2. Local binary classification suppresses the remote reader for a direct absolute executableFor the direct command: this host's A remote backend can legitimately have a script at the same absolute path. The same fake remote callback returning a lifecycle command is called once and detected by the comparison branch without the host prefilter. The lifecycle layer should not use the gateway host's |
|
Closing as superseded by #80258 (merged 2026-08-06), which sanitizes lifecycle-guard candidates at ingestion rather than per-syscall. That landed the crash/false-block class this PR targeted. Residual review notes here are stale against current main, and the branch conflicts. |
Summary
The gateway lifecycle guard (
cron/lifecycle_guard.py) — the defence-in-depth layer that blocks gateway-restart commands from inside the gateway process — crashed or false-blocked innocent terminal commands in three distinct ways. This PR fixes the root causes rather than one symptom.The three bugs
1. False block: multi-line payloads promoted paths to "executables" (new root cause)
_iter_command_segmentstokenized the command line by line (splitlines()), destroying quote context. A multi-line payload —python -c "..."spanning newlines, a heredoc body,$'...'— had its quotes terminated at each physical newline, so unquoted fragments became standalone segments. Any parenthesized path (e.g.open('/x/y.json')in a python one-liner) was promoted to a segment's first token and treated as a referenced shell script. The named file was then read and scanned — so an innocent command touching any text file that merely mentions a lifecycle command (a session transcript, a config file, a JSON log) got blocked with the "cannot restart or stop the gateway" error.Real reproduction: reading our own session transcript (
open('/home/.../session_....json')inside apython3 -cpayload) was blocked because the transcript contained the string from an earlier, correctly-blocked restart attempt.2. Crash:
ValueError: embedded null byte(residual of #76762)The merged fix for #76762 (commit
037825c1f) made_read_referenced_scriptskip binary content — but two paths still crashed:/path/to/venv/bin/python) is skipped by_read_referenced_script, then re-read by_read_script_in_env(the terminal tool's remote-read fallback), which decodes it as text. NUL bytes survive as valid UTF-8, so the recursion re-tokenizes machine code into NUL-bearing paths, andos.open(path)raises an uncaughtValueError(onlyOSErrorwas caught) — killing the whole command.os.opencrashes the same way.3. Bypass:
killall hermes-gatewayslipped throughBranch D used
p?kill\b;\bfails between "kill" and "all", so thekillallvariant of the kill command was not detected.The fix
_split_logical_lines— split the command only at unquoted newlines. Quoted strings now span physical lines (shell-faithful), while each real command line keeps its own segment, so./script.shon its own line is still seen as an executable reference._looks_like_script— the referenced-script walk now skips local binaries (NUL bytes in the first 4 KiB header) instead of reading+decoding them. Paths that don't exist locally are still yielded so remote backends can fetch them._read_referenced_script— toleratesValueError(embedded NUL) atos.open/os.read; directories are now "nothing to scan" (a directory can never be a shell script; bare/separators and pathlib division resolve here) while FIFOs/devices/sockets still fail closed._contains_unsafe_gateway_action— NUL-containing script text (a remote backend returning a binary decoded as text) is skipped before recursion instead of tokenized.tools/terminal_tool.py::_read_script_in_env— mirrors_read_referenced_script: binaries are not decoded into the scan.p?kill[a-z]*\bnow matcheskillall/pkillvariants.Verification
tests/hermes_cli/test_gateway_restart_loop.py; 5 of them fail on the previous code (crash ×2, false-block, directory block, killall bypass), all pass with the fix.test_resume_quiet_stderr) is a pre-existing order-dependent flake — identical on unpatchedmain.hermes gateway restart,systemctl restart hermes-gateway,launchctl submit/bootstrap(incl. neutral labels), referenced-script scanning, and the.pyspecial-case all still block.Related
os.opencrash class), Gateway terminal guard false-positives on full-path binary execution #77173 (directory/binary false positives)