Skip to content

fix(cron): harden lifecycle guard against multi-line payloads, binaries, and killall - #77383

Closed
orcaspainting-dev wants to merge 1 commit into
NousResearch:mainfrom
orcaspainting-dev:fix/lifecycle-guard-tokenization
Closed

fix(cron): harden lifecycle guard against multi-line payloads, binaries, and killall#77383
orcaspainting-dev wants to merge 1 commit into
NousResearch:mainfrom
orcaspainting-dev:fix/lifecycle-guard-tokenization

Conversation

@orcaspainting-dev

Copy link
Copy Markdown

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_segments tokenized 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 a python3 -c payload) 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_script skip binary content — but two paths still crashed:

  • A local binary invoked by absolute path (e.g. /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, and os.open(path) raises an uncaught ValueError (only OSError was caught) — killing the whole command.
  • Any NUL-bearing path reaching os.open crashes the same way.

3. Bypass: killall hermes-gateway slipped through

Branch D used p?kill\b; \b fails between "kill" and "all", so the killall variant 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.sh on 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 — tolerates ValueError (embedded NUL) at os.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.
  • Branch Dp?kill[a-z]*\b now matches killall/pkill variants.

Verification

  • 9 new regression tests in 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.
  • Guard test file: 91 passed.
  • Full suite: 984 passed, 4 skipped; the single failure (test_resume_quiet_stderr) is a pre-existing order-dependent flake — identical on unpatched main.
  • Security invariants preserved: hermes gateway restart, systemctl restart hermes-gateway, launchctl submit/bootstrap (incl. neutral labels), referenced-script scanning, and the .py special-case all still block.

Related

…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).
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cron Cron scheduler and job management comp/tools Tool registry, model_tools, toolsets tool/terminal Terminal execution and process management sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 3, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

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.

@orcaspainting-dev

Copy link
Copy Markdown
Author

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):

  • NUL-byte ValueError crash (os.open / pathlib) — both fix it
  • Multi-line python3 -c payloads false-blocking — both fix it (different approaches)
  • Directory tokens treated as unsafe scripts — both fix it
  • Binary payloads (~30MB venv python) false-blocking — both fix it

What this PR adds beyond #77151 (unique pieces worth porting if #77151 merges first):

  1. _split_logical_lines — quote-aware splitting at unquoted newlines (shell-faithful). fix(cron): stop the lifecycle guard crashing and false-blocking terminal calls #77151 restricts candidates; this keeps multi-line quoted strings intact so parenthesized paths inside python -c payloads aren't promoted to script references in the first place. Works for heredocs and ANSI-C quoted strings too.
  2. killall / pkill process-termination detection — the Branch D regex p?kill[a-z]*\b catches process-termination variants targeting hermes-gateway; fix(cron): stop the lifecycle guard crashing and false-blocking terminal calls #77151 does not touch this and that variant is currently a live bypass.
  3. NUL-text recursion guard_contains_unsafe_gateway_action skips script text containing NUL bytes (remote backends returning binaries decoded as text) before recursion.

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.

@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Summary

Two 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 consolidation

keep 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.

@alt-glitch alt-glitch added needs-decision Awaiting maintainer decision before any implementation and removed sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 3, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: #77151 and #76797 address the lifecycle-guard false-block/crash family with different candidate-selection policies. This PR additionally covers multi-line quote context and killall/pkill behavior; please choose a consolidated approach.

@orcaspainting-dev

Copy link
Copy Markdown
Author

New data point for the consolidation decision: #78201 (opened 2026-08-04) independently fixes the same NUL-byte crash with the identical except (OSError, ValueError) at the os.open() site, and adds a sibling case this PR's ValueError handling doesn't cover: pathlib.resolve() raises RuntimeError (not OSError) on symlink loops, crashing the guard the same way. It ships 6 regression tests including the remote-backend and symlink-loop paths.

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 yuzilongleif-collab 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.

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 False

On 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 False

Actual: 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.

@yuzilongleif-collab

Copy link
Copy Markdown
Contributor

Two additional remote-execution regressions are reproducible on current head 3721eca5f; these are separate from the quote/FIFO/regex findings in my review.

1. NUL-bearing remote text is skipped before scanning, but its prefix can execute

_contains_unsafe_gateway_action() currently does:

if "\x00" in script_text:
    continue

before recursively scanning the returned text. With a remote reader returning:

#!/bin/sh
hermes gateway restart
\x00ignored

this head reports detected=False (the callback is called once). A harmless /bin/sh control using printf 'NUL_PREFIX_EXECUTED\n' before the NUL printed the marker before the shell exited with 127. Therefore “contains any NUL” does not prove that no executable prefix exists; skipping the whole buffer creates a lifecycle-command bypass.

Please scan the executable prefix safely or reject/fail closed, rather than continue before the direct lifecycle regex. Add a regression where the blocked command occurs before the NUL.

2. Local binary classification suppresses the remote reader for a direct absolute executable

For the direct command:

/usr/bin/python3

this host's /usr/bin/python3 resolves to an ELF, so _looks_like_script(candidate) returns False. On this head the candidate is not yielded at all:

remote callback calls=0
detected=False

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 exists() / binary classification to decide whether an SSH/container path deserves remote inspection. Path classification must be execution-environment-aware; otherwise a host binary can mask a remote script at the same pathname.

@orcaspainting-dev

Copy link
Copy Markdown
Author

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.

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

Labels

comp/cron Cron scheduler and job management comp/tools Tool registry, model_tools, toolsets needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists tool/terminal Terminal execution and process management type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants