fix(cron): scope lifecycle guard to real shell scripts; per-job cron flag via ContextVar - #76797
fix(cron): scope lifecycle guard to real shell scripts; per-job cron flag via ContextVar#76797samson-mak wants to merge 3 commits into
Conversation
…flag via ContextVar
Three approval-gate defects after gateway restart:
1. lifecycle_guard treated any full-path executable as a shell script —
venv python3 / claude binaries were classified, decoded as UTF-8 (with
embedded NUL bytes) and could crash the resolver (ValueError: embedded
null byte) or block legit dispatches with a false 'cannot restart or
stop the gateway' verdict.
2. _is_shell_script_file had a 4096-byte total-size cap, so large
extensionless shell scripts (#!/bin/sh + gateway restart) bypassed the
guard; it now reads only the first 512 bytes for shebang classification.
3. dot-source (`.` / `source`) never scanned scripts because
Path(".").name is ''.
4. scheduler set HERMES_CRON_SESSION=1 process-wide, so after a gateway
restart every live session inherited cron mode and execute_code was
hard-denied; replaced with a per-job ContextVar set inside the lock-guarded
try and reset in finally (lock-leak-safe).
approval: _is_cron_session() reads the ContextVar first, falls back to env.
gateway/session_context: new HERMES_CRON_SESSION_CONTEXTVAR (default False).
Tests: tests/cron/test_lifecycle_guard_regressions.py — 12 tests covering
real full-path binary, dot-source, bash, large extensionless scripts, and the
real cron.scheduler.run_job cleanup path (ContextVar reset + writer lock
release). Reviewed by gpt-5.6-sol (3 rounds; final ACCEPT).
Related to #76773 and the cron ContextVar cluster (#56796, #58663). The lifecycle-guard patches differ on whether an extensionless text script without a shell shebang remains scanable, while this PR also contains the separate cron-context repair; please choose the intended guard policy before consolidating. |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for isolating the cron approval marker; the underlying leak is present on current main at cron/scheduler.py:3011-3014, and the existing worker handoff copies ContextVars at cron/scheduler.py:3564-3569.
Problems
cron/lifecycle_guard.py:222-226narrows referenced-script scanning to extensions or recognized shell shebangs. That drops directly executed extensionless text scripts without a shebang from the lifecycle scan, weakening the intentional defense-in-depth guard. The current implementation deliberately scans slash-containing paths (cron/lifecycle_guard.py:222-223), and the member note correctly flags this policy decision.tests/cron/test_lifecycle_guard_regressions.py:111-161forces an exception before the agent worker submission, so it does not verify the actual scheduler worker propagation or concurrent gateway isolation.
Suggested changes
- Preserve extensionless text-script scanning while skipping clearly binary headers (the related #76773 approach uses a NUL-byte header distinction).
- Add a real worker-handoff/concurrent-context regression around
cron/scheduler.py:3568-3569.
Automated hermes-sweeper review.
| if "/" in executable or executable.endswith((".sh", ".bash", ".zsh")): | ||
| if executable.endswith((".sh", ".bash", ".zsh")): | ||
| yield _resolve_terminal_script_path(executable, cwd) | ||
| elif "/" in executable: |
There was a problem hiding this comment.
This excludes a directly executed extensionless text script with no shebang from scanning, so ./restart-helper can contain hermes gateway restart and evade the lifecycle guard. Please preserve scanning for text candidates and skip only clearly binary files (for example, a bounded NUL-byte header check).
There was a problem hiding this comment.
Resolved in 7deebd1a0 — _is_shell_script_file now uses a bounded NUL-byte header check: text candidates (no NUL in first 512 bytes) are scanned, clearly binary files are skipped. ./restart-helper (extensionless, no shebang) is covered by the new test_extensionless_text_script_without_shebang_is_scanned.
|
Guard policy decision: bounded NUL-byte header check — scan text candidates (no NUL in first 512 bytes), skip clearly binary files. Landed in 7deebd1a0, aligned with the #76773 approach; ready to consolidate. |
|
@teknium1 both findings addressed in 7deebd1a0. @alt-glitch guard policy decided. 1. lifecycle_guard — NUL-byte header check ( 2. Real worker-handoff regression — Validation (Hermes venv, worktree at 7deebd1a0):
Guard policy (for consolidation with #76773): bounded NUL-byte header check — scan text candidates, skip binary. |
The gw_session fixture now snapshots/restores tools.approval._permanent_approved so tests don't depend on developer config.yaml command_allowlist entries (this dev machine allowlists execute_code, which short-circuited the gateway approval branch and broke 3 one-shot/smart-approval tests). Adds test_guard_permanent_allowlist_is_isolated to pin the allowlist shortcut.
7deebd1 to
f39ef75
Compare
… regression test Addresses teknium1/hermes-sweeper review on NousResearch#76797: - _is_shell_script_file now uses a bounded NUL-byte header check: scan text candidates (POSIX shells execute extensionless shebang-less text scripts via ENOEXEC fallback), skip clearly binary files. Fixes the ./restart-helper bypass class while keeping binary NUL-crash fixes. - test_full_path_non_shell_binary: payload now NUL-padded (2MiB no-NUL 'x' payload would be classified as text and trip the 1MiB cap). - New: extensionless no-shebang text script is scanned. - New: real run_job worker-handoff regression — ContextVar propagates into the worker thread via copy_context (scheduler.py:3568-3569), concurrent non-cron thread stays False, scheduler thread resets after. (restored via cherry-pick of 7deebd1a0 onto PR head f39ef75)
|
+1 — I hit this same bug in the wild and opened #77233 (now closed in favor of this PR), so I can confirm this fix is needed and correct. Independent reproduction: a terminal command referencing a venv interpreter by path (e.g. Validating the policy here: my own testing confirmed the exact behavior this PR implements — a NUL-byte-laden binary executable passes through cleanly (no crash, no false block), while real shell scripts ( Also appreciated: the dot-source |
Graph note (no action implied — a maintainer has already reviewed this thread). Our triage graph places this PR in a complex with 1 related pull request ( Full neighbourhood: https://hermes-triage.gottz.de/?node=76797 This note exists so the relationship stays discoverable from the thread itself. |
|
Hey! I ran into the same wall and made a complementary fix — there are a couple of holes left even with your scoping. Verified on a live machine (macOS, gateway under launchd with no
My PR (#78056): reject NUL bytes before touching pathlib/files + catch On the live box: full-path I think both PRs should land together — they fix adjacent layers of the same guard. Happy to rebase if needed. |
Summary
Fixes three approval-gate defects that appear after a gateway restart:
1.
cron/lifecycle_guard.py— false-positive "cannot restart or stop the gateway" blocks_iter_referenced_shell_scriptstreated any full-path executable (/Users/.../venv/bin/python3,~/.hermes/bin/claude, …) as a shell script to read and scan. Real binaries were decoded as UTF-8 witherrors="replace"— which embeds NUL bytes — and the resolver then crashed withValueError: embedded null byte(onlyOSErrorwas caught). In the common case it returnedunsafe=True, hard-blocking legitimate dispatch commands with a fake gateway-lifecycle verdict.Fix: only treat files as shell scripts when they end in
.sh/.bash/.zshor their first 512 bytes carry a POSIX-shell shebang (_is_shell_script_file). Also fixed dot-source (Path(".").nameis""so. script.shwas never scanned) and added(OSError, ValueError)guards on allos.open/resolvepaths.2.
cron/scheduler.py— process-wideHERMES_CRON_SESSIONleakrun_jobsetos.environ["HERMES_CRON_SESSION"] = "1"process-wide. After a gateway restart, the long-lived scheduler process kept the flag set, so every live session (Slack/Discord/…) was misclassified as a cron job andexecute_codewas hard-denied viaapprovals.cron_mode: deny.Fix: per-job
ContextVar(HERMES_CRON_SESSION_CONTEXTVAR) set inside the lock-guardedtry:and reset infinally:(with aNonetoken guard), so an exception can't leak the terminal-cwd lock.3.
tools/approval.py—_is_cron_session()All four approval-gating sites now check the ContextVar first with an env fallback (backward compatible for standalone cron processes).
Tests
New
tests/cron/test_lifecycle_guard_regressions.py(12 tests):venv/bin/python3 -c ...) → not flagged. /path/evil.shandsource /path/evil.sh→ flaggedbash /path/evil.sh→ flagged#!/bin/shscript with a restart command → flaggedcron.scheduler.run_jobexception path → ContextVar reset + writer lock released (no leak)tests/tools/test_execute_code_approval_cluster.py: 21 passed — the 3 one-shot/smart-approval tests were failing on machines whoseconfig.yamlallowlistsexecute_code(the permanent allowlist short-circuited the gateway approval branch). Thegw_sessionfixture now snapshots/restores_permanent_approved(second commit), plus a newtest_guard_permanent_allowlist_is_isolatedpins the allowlist shortcut.Suite results:
test_lifecycle_guard_regressions.py12 passed ·test_terminal_cwd_lock.py4 passed ·test_cron_approval_mode.py26 passed ·test_execute_code_approval_cluster.py21 passed.Notes
HERMES_CRON_SESSIONno longer set, full-pathpython3no longer blocked.