fix(cron): route update_job() through the gateway-lifecycle guard - #94655
pierrenode wants to merge 1 commit into
Conversation
create_job() enforces cron.lifecycle_guard.check_gateway_lifecycle() against prompt+script before a job is scheduled, specifically to prevent an agent-driven self-restart/self-stop SIGTERM-respawn loop under launchd/systemd KeepAlive (NousResearch#30719). The comment right above that call is explicit that this exists precisely so a bypass via the agent's own `cronjob` model tool (which calls create_job directly) is covered, not just `hermes cron create`. update_job() — the only other write path for the same prompt/script fields, reachable from `hermes cron edit`, the agent's own `cronjob(action="update", ...)` tool, and the dashboard's `PATCH /api/jobs/{job_id}` REST endpoint (gateway/platforms/ api_server.py::_handle_update_job) — never called the guard at all. A job created with a harmless script could be updated afterward to embed `hermes gateway restart` / `systemctl restart hermes-gateway` / `launchctl kickstart ...` with zero guard in between. For a `no_agent` job (cron/scheduler.py::_run_job_script executes the script via a bare subprocess.Popen, with no HERMES_GATEWAY flag and no terminal_tool mediation) this is a complete, unguarded bypass; for a prompt-based job it downgrades to the LLM's own choice to run it as a shell command, which terminal_tool's separate HERMES_GATEWAY=1 guard would still catch. Empirically verified before writing the fix: create_job() correctly raises GatewayLifecycleBlocked for a script containing `hermes gateway restart`, but update_job() lets the exact same script through onto an already-created, previously-benign job with no error at all. Fix: update_job() re-runs check_gateway_lifecycle() against the merged (post-update) prompt+script whenever either field is part of the update — matching create_job()'s own "both fields scanned together" contract, which also closes the split-across-fields evasion the guard's docstring calls out (a command spread across prompt and script so neither field alone looks dangerous). Updates that don't touch either field are unaffected, so a legacy record whose stored script became dangerous through some other means (a hand-edit, a pre-guard record) doesn't suddenly block an unrelated rename/reschedule. New regression tests (tests/hermes_cli/test_gateway_restart_loop.py, TestUpdateJobBlocksLifecycleCommands, 6 tests) mirror the existing TestCreateJobBlocksLifecycleCommands class: prompt-only block, script-only block, the merged-record/split-fields case, benign updates still succeed, an unrelated-field update on a legacy-dangerous record doesn't rescan, and the end-to-end cronjob(action="update", ...) tool surfaces the block as an error with the NousResearch#30719 hint (matching the create-path coverage exactly). Mutation-verified: reverting the fix reproduces exactly 4 failures (the blocking cases); the 2 benign-update controls pass either way.
This PR closes a real security gap: A few technical concerns:
Overall, this is a well-reasoned, well-tested fix for a genuine privilege-escalation path through the cron update API. |
Summary
create_job()enforcescron.lifecycle_guard.check_gateway_lifecycle()againstprompt+scriptbefore a job is scheduled, specifically to prevent an agent-driven self-restart/self-stop SIGTERM-respawn loop under launchd/systemdKeepAlive(#30719). The comment right above that call is explicit that this exists precisely so a bypass via the agent's owncronjobmodel tool (which callscreate_jobdirectly) is covered, not justhermes cron create.update_job()— the only other write path for the sameprompt/scriptfields, reachable fromhermes cron edit, the agent's owncronjob(action="update", ...)tool, and the dashboard'sPATCH /api/jobs/{job_id}REST endpoint (gateway/platforms/api_server.py::_handle_update_job) — never called the guard at all.Empirically verified before writing the fix, against the real module:
For a
no_agentjob,cron/scheduler.py::_run_job_scriptexecutes the script via a baresubprocess.Popen— noHERMES_GATEWAYflag, noterminal_toolmediation — so this is a complete, unguarded bypass once a benign job is updated to point at (or embed) a lifecycle command. For a prompt-based (agent) job it downgrades to the LLM's own choice to run it as a shell command, whichterminal_tool's separateHERMES_GATEWAY=1guard would still catch as a second layer.Fix
update_job()re-runscheck_gateway_lifecycle()against the merged (post-update)prompt+scriptwhenever either field is part of the update — matchingcreate_job()'s own "both fields scanned together" contract, which also closes the split-across-fields evasion the guard's own docstring calls out (a command spread acrosspromptandscriptso neither field alone looks dangerous). Updates that don't touch either field are unaffected, so a legacy record whose stored script became dangerous through some other means (a hand-edit, a pre-guard record) doesn't suddenly block an unrelated rename/reschedule.Verification
tests/hermes_cli/test_gateway_restart_loop.py::TestUpdateJobBlocksLifecycleCommands, 6 tests), mirroring the existingTestCreateJobBlocksLifecycleCommandsclass exactly: prompt-only block, script-only block, the merged-record/split-fields case, benign updates still succeed, an unrelated-field update on a legacy-dangerous record doesn't rescan, and the end-to-endcronjob(action="update", ...)tool surfaces the block as an error with the#30719hint (matching the create-path coverage).tests/hermes_cli/test_gateway_restart_loop.py(303 tests) passes.tests/cron/+tests/tools/test_cronjob_tools.py/test_cronjob_run_background.py/test_cronjob_run_immediate.py+tests/hermes_cli/test_cron.py(1054 tests, 1 pre-existing skip) all pass.ruff checkclean on both changed files.Competitor / related-PR notes (checked fresh before opening)
fix(cron): block gateway lifecycle commands on job update) patches this exact bug at a narrower layer: it adds the guard check insidetools/cronjob_tools.py'scronjob(action="update", ...)handler only, never touchingcron/jobs.py::update_job()itself. I confirmed viagrepthatupdate_job()has two other real callers besides that tool wrapper:cron/monitor.py/cron/scheduler.py(internal, non-attacker-controlled field updates, unaffected either way) and, critically,gateway/platforms/api_server.py::_handle_update_job— the dashboard'sPATCH /api/jobs/{job_id}REST endpoint, which passespromptthrough toupdate_job()with no lifecycle check of its own. Even if fix(cron): block gateway lifecycle commands on job update #51980 merges as-is, that dashboard path would remain exposed for prompt-based lifecycle commands. This PR fixes the shared chokepoint itself, so it covershermes cron edit, the agent tool, the dashboard, and any future caller in one place. If fix(cron): block gateway lifecycle commands on job update #51980 also lands, its tool-layer check becomes a harmless, redundant double-check — not a conflict — though both PRs add a new test class at the same anchor point intests/hermes_cli/test_gateway_restart_loop.py, so whichever merges second will need a small rebase there.cron/jobs.py::update_job()'s relationship to the lifecycle guard.