fix(security): scope the cron-session approval marker to the job context - #58663
fix(security): scope the cron-session approval marker to the job context#58663Adolanium wants to merge 3 commits into
Conversation
AmirF194
left a comment
There was a problem hiding this comment.
This is a well-scoped fix for a genuine security bug, and the diagnosis holds up. On main, run_job sets HERMES_CRON_SESSION=1 in os.environ and never clears it, and since the default scheduler ticks in-process, after the first tick every interactive user is misclassified as cron: cron_mode: deny hard-blocks their commands, cron_mode: approve silently auto-approves them. I ran your test file in a clean Python 3.11 container matching CI and reverted the three source files to main: the two core tests fail on the real assertions (run_job leaves HERMES_CRON_SESSION=1, and a bound session is misrouted to cron), the other three fail on the missing _is_cron_session helper. With the fix restored, 5 passed.
Putting the marker in _VAR_MAP is what makes this complete rather than a symptom patch: it plugs into the existing _inject_session_context_env leak-guard, so a cron job's subprocess or delegated child still inherits the marker while concurrent interactive sessions get it stripped. Combined with the per-job copy_context() dispatch, overlapping jobs and child sessions are both handled, and moving off os.environ also closes the across-restart and reused-session-id angles. Reader coverage looks complete (all four consumers route through _is_cron_session).
Two non-blocking notes. The marker .set("1") sits just outside the try/finally that clears it, so an exception in that window could leave it set in the standalone hermes cron loop thread (harmless in the in-process default since the context is discarded). Tucking it inside the try would match the delivery vars. And since three of the five tests only fail on the missing helper import when source is reverted, a test that drives two real run_job calls in overlapping contexts would round out the raw-contextvar isolation test. Neither blocks.
|
Thanks for the thorough read, and for reverting the three source files in a clean 3.11 container to check the two core tests fail on the real assertions rather than just the missing import. That is the signal I wanted from them. Both notes are addressed in the follow-up commit. The marker I also added the test you suggested: two real Left the |
fc56c37 to
fe7ae53
Compare
|
suggesting changes I reviewed a run-owned patch replay of this change against current GitHub The cron marker fix still leaves the advertised Blocking check: python -m pytest -p no:cacheprovider tests/cron/ tests/tools/test_cron_approval_mode.py::TestCronDenyMode::test_dangerous_command_blocked_in_cron_deny_mode -qThat fails with python -m pytest -p no:cacheprovider tests/cron/ tests/tools/test_approval.py tests/tools/test_hardline_blocklist.py tests/tools/test_cron_approval_mode.py tests/tools/test_execute_code_approval_cluster.py -qSecurity evidence:
Please clear the cron marker back to the Signed: GPT-5.5-xhigh in Codex |
|
Thanks for moving the @egilewski's blocking find is real, I reproduced it in a clean Python 3.11 container against the current head. Running his command as a single pytest process: fails with the dangerous command auto-approved: The mechanism is the reset value. One thing worth flagging: this is invisible to Resetting to from gateway.session_context import ..., _UNSET
_VAR_MAP["HERMES_CRON_SESSION"].set(_UNSET)With that change the same single-process command plus I should be straight that my earlier read missed this. I verified the two core tests fail-first but ran them through the per-file runner, which masked exactly this cross-context interaction. Good catch. |
fe7ae53 to
6604654
Compare
|
Confirmed and fixed, thanks both. The reset value was the bug: the finally cleared the marker with Went with the token pair as suggested: Also rebased onto current On the broader ordered command: your blocking check now passes, and while chasing the remaining failures I found the Local runs on the new head: your reduced blocking command passes, and the full ordered command ( @AmirF194 thanks for the independent repro and for flagging that the per-file runner masks this class entirely. That also explains how it got past the original fail-first runs. |
|
Confirmed this exact failure in a live in-process Telegram gateway: after cron ran, an interactive DM's execute_code call was rejected with the cron-only 'no user present' message. I deployed this PR's commit to that gateway; the same interactive guard now returns pending_approval and routes to the normal interactive approval path. The focused cron/approval suite passes (298 tests). |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for preserving the cron policy while moving the marker off process-global state. The underlying bug remains present on current main: cron/scheduler.py:2812 writes a persistent process-global marker, and the gateway starts the default in-process ticker at gateway/run.py:21110-21128.
Problems
tools/approval.py:2041changes the shared gate to_is_cron_session(), buttests/tools/test_request_tool_approval.py:103-120still monkeypatchesenv_var_enabledto simulate cron. That stub no longer controls the branch:_is_cron_session()reads session context /os.environ. The deny case therefore reaches the non-cron fail-closed path, and the approve case does not establish cron mode.
Suggested changes
- Update those tests to set/reset
_VAR_MAP["HERMES_CRON_SESSION"]or mock_is_cron_session()directly; update the non-cron case attests/tools/test_request_tool_approval.py:148-155to use the same seam.
This is an automated hermes-sweeper review.
| @@ -2022,7 +2041,7 @@ def _run_approval_gate( | |||
|
|
|||
There was a problem hiding this comment.
The existing shared-gate cron tests at tests/tools/test_request_tool_approval.py:103-120 only monkeypatch env_var_enabled. After this replacement, that stub no longer establishes a cron context because _is_cron_session() reads session context/env directly. Update those tests to set/reset _VAR_MAP["HERMES_CRON_SESSION"] or mock _is_cron_session(); otherwise the deny/approve cases exercise non-cron behavior.
There was a problem hiding this comment.
Good catch. The gate now keys off _is_cron_session(), so stubbing env_var_enabled("HERMES_CRON_SESSION") no longer put those cases on the cron branch (deny hit fail-closed, approve never got cron_mode).
I updated tests/tools/test_request_tool_approval.py and squashed it into the PR commit (559be6bdd):
test_cron_deny_mode_blocks/test_cron_approve_mode_allowsmock_is_cron_sessionto Truetest_no_human_non_cron_fails_closedmocks_is_cron_sessionto False
Full file is 13 passed.
|
Addressed the hermes-sweeper review (review body + inline on What was wrong What I did
Checks
|
a51e98f to
559be6b
Compare
|
suggesting changes Please rebase this change and update the new cron regression-test mock for the current runtime-provider interface. On a patch replay onto current Make the stub accept the current keyword arguments (while retaining the provider fixture), then rerun the cron-marker coverage. The ContextVar approach itself is directionally appropriate, but the submitted test suite does not pass against current Security evidence:
Signed: GPT-5.6-sol-xhigh in Codex |
559be6b to
21d3e92
Compare
|
Addressed @egilewski's latest review. On a patch replay onto current main the four new run_job-driven isolation tests never reached their marker assertions. Changes in this push:
Approach otherwise unchanged: marker in Checks:
Head: |
21d3e92 to
234b775
Compare
run_job set os.environ["HERMES_CRON_SESSION"]=1 and never cleared it. The
default deployment ticks the scheduler in-process inside the gateway, so
after the first job the process-global marker persisted and the approval
gate treated every later interactive user as a cron session: cron_mode
deny hard-blocked their dangerous commands with a misleading "no user
present" message, and cron_mode approve auto-approved them with no prompt,
a security bypass.
Carry the marker on a per-job ContextVar instead. Add HERMES_CRON_SESSION
to gateway.session_context._VAR_MAP so it rides the existing per-job
copy_context() dispatch and the _inject_session_context_env leak-guard: a
cron job's subprocess or delegated child still inherits it while
concurrent interactive sessions get it stripped. run_job sets it as the
first statement inside its try, so the finally always restores it and a
raise before dispatch cannot leave it set on a reused loop-thread context,
and it still precedes copy_context() so the conversation pool thread
inherits it. approval.py gains a _is_cron_session() helper (contextvar
first, os.environ fallback for the standalone hermes cron process) and all
four readers route through it. Moving off os.environ also closes the
across-restart and reused-session-id angles.
The finally restores the marker with reset(token), not set("):
get_session_env treats any explicitly-set value, including ", as
authoritative and never falls back to os.environ, so a leftover " would
misclassify a later env-marked cron read in the same context as non-cron
and skip cron_mode entirely. That mattered to any caller that runs
run_job directly in its own context (the standalone path and
single-process test runs), where after the first job a dangerous command
was auto-approved instead of blocked under cron_mode deny.
Tests: run_job leaves no marker in os.environ and a bound gateway session
is still recognized as gateway after a cron job runs in-process (both fail
on the pre-fix code), the marker is active during the job so cron_mode
still applies, two real run_job calls in separate contexts keep the marker
to their own job, the os.environ fallback still classifies an env-marked
read as cron after a completed run_job (fails when the marker is cleared
with set(")), and the contextvar-first-then-env resolution and
per-context isolation are unit-covered. Shared-gate unit tests in
test_request_tool_approval.py mock _is_cron_session() directly, since the
gate no longer consults env_var_enabled for cron. A shared autouse fixture
in tests/cron/conftest.py resets every session-context var to its _UNSET
default around each test: cron tests drive the real run_job directly in
the pytest context, where its clear_session_vars finally intentionally
pins every session var to " (production confines that to the per-job
copy_context()), and in a single-process ordered run that leaked into the
approval timeout tests' session-key resolution. That ordering failure
predates this change (reproducible on current main with
tests/cron/test_scheduler.py followed by the timeout tests) and the
fixture makes the cron suite order-independent.
234b775 to
0014794
Compare
|
looks mergeable Security evidence:
Signed: GPT-5.6-sol-xhigh in Codex |
im47cn
left a comment
There was a problem hiding this comment.
Two enhancements from a duplicate PR (#69766) that diagnosed this bug in production (Feishu gateway).
1. Defense-in-depth: gate the cron flag in set_session_vars()
In set_session_vars(), explicitly set the cron flag to "":
_CRON_SESSION_FLAG.set("")Why: Every set_session_vars() call is binding a non-cron session (gateway, TUI, CLI, API server). Setting the flag to "" suppresses the os.environ fallback. Even if another process leaked HERMES_CRON_SESSION=1 into the environment, a gateway session that binds its contextvars can never be miscategorised as cron.
Without this, the os.environ fallback in _is_cron_session() is the last line of defense — if something sets the env var, the bug returns.
2. Feishu reply-to-cron-message scenario test
We hit this bug in production when a Feishu user replied to a cron-delivered message card. The exact reproduction:
- Cron job fires → delivers report card to Feishu group chat
- User replies to the thread with a follow-up task
execute_codeblocked → agent falls back toterminalcall-by-call
A test case (tests/gateway/test_cron_session_contextvar.py, 19 tests) covers this end-to-end:
HERMES_CRON_SESSION=1leaked inos.environHERMES_GATEWAY_SESSION=1set- Gateway handler calls
reset_session_vars()→set_session_vars() is_cron_session()→ Falsecheck_execute_code_guard()→ approved (not cron-blocked)
Happy to contribute these as a PR against this branch if you prefer.
|
@im47cn thanks for bringing the Feishu production case over. I checked both suggestions against the current head. The extra I would rather keep that protection in The current regression coverage also has most of the Feishu scenario already. The one combination not asserted directly is |
…esearch#58663) The one combination not directly asserted: stale HERMES_CRON_SESSION=1 in os.environ + a bound interactive gateway session + check_execute_code_guard. The existing tests cover check_dangerous_command for this scenario, but execute_code has its own cron branch and needs its own assertion. Production trigger (NousResearch#73195): a Feishu user replies to a cron-delivered message card. After NousResearch#58663 the gateway session reaches the normal interactive approval path (approval_pending), not the cron hard-block.
|
Pulled in @im47cn's focused Feishu I added a small follow-up in Checks:
|
What does this PR do?
The cron scheduler marks its execution as a cron session so the approval gate can apply
approvals.cron_mode. It did this with a process-global env var:run_jobsetos.environ["HERMES_CRON_SESSION"] = "1"and never cleared it.The default deployment runs the cron ticker in-process inside the gateway (
InProcessCronSchedulerin a daemon thread of the same process that serves Telegram/Discord/Slack). So after the first cron tick the marker is set for the whole gateway process, permanently._is_gateway_approval_context()checksHERMES_CRON_SESSIONfirst and returnsFalse, shadowing the per-sessionHERMES_SESSION_PLATFORMcontextvar the concurrent gateway path relies on (the gateway sets noHERMES_GATEWAY_SESSIONenv var). Every interactive user is then misclassified as a cron session:cron_mode: deny: the user's dangerous command is hard-blocked with a "cron jobs run without a user present" message and the interactive approve flow is never reached.cron_mode: approve: the user's dangerous command is auto-approved with no prompt, silently bypassing the human approval gate.The fix carries the marker on a per-job
HERMES_CRON_SESSIONcontextvar (set inrun_job, cleared in itsfinally), mirroring the cron delivery targets that were already moved to contextvars for the same process-global reason. The four approval readers go through a new_is_cron_session()helper that reads the contextvar first viaget_session_envand falls back toos.environfor the standalonehermes cronprocess and for tests. The marker is now isolated to the cron job's own context, so concurrent interactive sessions are unaffected and cron jobs still getcron_modeapplied.Related Issue
Fixes #58662
Type of Change
Changes Made
gateway/session_context.py: add aHERMES_CRON_SESSIONContextVar and register it in_VAR_MAPsoget_session_envresolves it (contextvar first,os.environfallback).cron/scheduler.py: inrun_job, set the marker via the ContextVar instead ofos.environ, and reset it to a falsy value in the existingfinallyalongside the cron delivery vars.tools/approval.py: add_is_cron_session()(reads the marker viaget_session_env) and route the fourHERMES_CRON_SESSIONreaders (_is_gateway_approval_context,check_dangerous_command,check_all_command_guards,check_execute_code_guard) through it.tests/cron/test_cron_session_marker_isolation.py: new regression tests.How to Test
main: callcron.scheduler.run_job(...)once, thenos.environ.get("HERMES_CRON_SESSION")is"1", and a bound gateway session (set_session_vars(platform="telegram", ...)) has_is_gateway_approval_context()returnFalse.run_jobleaves noHERMES_CRON_SESSIONinos.environ, and the bound gateway session's_is_gateway_approval_context()returnsTrue.tools/approval.py+cron/scheduler.py+gateway/session_context.pychanges reverted,test_run_job_does_not_leak_cron_marker_into_process_envandtest_bound_gateway_session_not_shadowed_by_in_process_cronfail. With the change in place all five pass.python -m pytest tests/cron/ tests/tools/test_approval.py tests/tools/test_hardline_blocklist.py tests/tools/test_cron_approval_mode.py tests/tools/test_execute_code_approval_cluster.py -q- 1140 passed (the 7 remaining failures are pre-existing Windows-only file-mode / tilde-expansion tests, identical with the change reverted).ruff check tools/approval.py cron/scheduler.py gateway/session_context.py tests/cron/test_cron_session_marker_isolation.py- clean.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/A (docstrings updated in place)cli-config.yaml.exampleif I added/changed config keys — or N/A (no config keys)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/AScreenshots / Logs
Behavior before and after, reproduced against
run_joband the approval context: