fix(security): lifecycle_guard must not crash or over-block on non-script paths - #78332
fix(security): lifecycle_guard must not crash or over-block on non-script paths#78332temorian1 wants to merge 2 commits into
Conversation
…allback
_read_referenced_script detects binaries (NUL byte in the first chunk) and
returns None. The caller treats that None as "not present locally" and falls
through to read_remote_script, which reads via `cat` and decodes with
errors="replace" — NUL bytes are valid UTF-8 and survive.
The decoded machine code was then tokenized as a command line. The linker path
in the ELF string table yields a candidate containing a NUL byte, which reaches
os.open and raises:
ValueError: embedded null byte
cron/lifecycle_guard.py:260 in _read_referenced_script
The guard crashes instead of deciding, so any command invoked through an
absolute binary or interpreter path fails while _HERMES_GATEWAY=1 — e.g.
`/path/to/chrome --version` or `/path/to/venv/bin/python3 -c "..."`.
Only tools/terminal_tool.py supplies this callback, so the crash never
reproduces when the guard is called directly. That is why it survives manual
testing: every check outside a gateway worker is green.
Fix mirrors the local rule (NousResearch#76762): remote-read text containing a NUL byte is
"nothing to scan", not a suspicion. os.open additionally catches ValueError,
the same defensive handling already present for Path.resolve twelve lines
below.
Protection is unchanged — `systemctl restart hermes-gateway` and
`hermes gateway restart` are still blocked, with and without the callback.
Adds a regression test that raises ValueError without the fix.
_read_referenced_script has no S_ISDIR branch, so a directory falls into the
conservative `not S_ISREG` case and the whole command is reported unsafe.
Tokenizing a Python line such as
for candidate in (Path("/opt/ms-playwright"), ...)
can yield the directory as a "referenced script". On v0.20.0 this blocked every
command referencing one inside a gateway worker, which silently disabled our
PDF export for several hours — being blocked is a normal guard outcome, so
nothing was logged as an error.
This is the exact counterpart to the binary handling introduced in NousResearch#76762: a
directory cannot carry a command, so "not a script" should not mean "suspicious".
The `not S_ISREG` branch stays conservative for FIFOs, sockets and devices,
where the content genuinely can change between check and execution.
Submitted as a defensive change, and flagged as such: on current main we could
not construct a command that still yields a directory as a candidate, so there
is no failing test to accompany it. Drop this commit if you would rather not
carry a guard without a reproduction.
|
Closing as superseded by #80258, which fixes this whole bug class architecturally rather than per-callsite: path candidates are sanitized once at the ingestion boundary (NUL/empty/unexpandable tokens rejected before any OS call), text from any Your report and fix targeted a real member of this class — thank you. The per-callsite patches kept leaving sibling frames exposed (#76762 → #77703 → #77780 → #78256 each crashed one frame away from the previous fix), which is why we went with the boundary fix instead of merging the fragments individually. #80258 carries regression tests for the NUL-path, binary-callback, oversized-read, unset-HOME, and walk-crash cases plus an adversarial never-raises sweep. |
What and why
_read_referenced_scriptdetects binaries (NUL byte in the first chunk) and returnsNone. The caller treats thatNoneas "not present locally" and falls through toread_remote_script:That callback reads via
catand decodes witherrors="replace". NUL bytes are valid UTF-8 and survive, so the machine code comes back as a string and is tokenized as a command line. The linker path in the ELF string table produces a candidate containing a NUL byte, which reachesos.open:The guard crashes instead of deciding. Any command invoked through an absolute binary or interpreter path is affected while
_HERMES_GATEWAY=1, for example/path/to/chrome --versionor/path/to/venv/bin/python3 -c "...".The root cause is that
script_text is Nonemeans two different things — "not present locally" and "deliberately skipped as a binary". Only the second is a decision; the fallback treats both as the first.Why it survives testing
tools/terminal_tool.pyis the only caller that suppliesread_remote_script. Called directly, the guard never crashes. Every check outside a gateway worker is green, which is exactly where manual verification happens.How to test
Fails on
main, passes with this PR:The included regression test builds a minimal ELF-shaped file rather than depending on a system binary, so it is hermetic.
test_remote_read_of_binary_does_not_crash_the_guardfails withValueError: embedded null byteProtection is unchanged
Both lifecycle forms remain blocked, with and without the callback:
A binary cannot carry a command, so declaring it "nothing to scan" removes no coverage. This mirrors the rule the local read path already applies (#76762).
Platforms tested
Linux (Ubuntu, x86-64), Python 3.11, in a gateway worker with
_HERMES_GATEWAY=1and via the test suite. Not tested on macOS or WSL2 — the change touches no platform-specific behaviour, only exception handling and a string check.Related
Follow-up to #76762, which introduced the binary-is-not-a-script rule for the local read path. This PR extends the same rule to the remote path.
Second commit: the directory case
_read_referenced_scriptalso has noS_ISDIRbranch, so a directory falls into the conservativenot S_ISREGcase and the command is reported unsafe. Tokenizing a line likefor candidate in (Path("/opt/ms-playwright"), ...)can yield a directory as a "referenced script". On v0.20.0 this blocked every command referencing one inside a gateway worker and silently disabled our PDF export for hours — being blocked is a normal guard outcome, so nothing surfaced as an error.It is the exact counterpart to #76762: a directory cannot carry a command.
Flagged honestly: on current
mainwe could not construct a command that still yields a directory as a candidate, so this commit ships without a failing test. It is defensive. Drop it if you would rather not carry a guard without a reproduction — the first commit stands on its own.