From da7253215d69ceb18ce5756c1fcf25d1e8c473eb Mon Sep 17 00:00:00 2001 From: 0z1-ghb <162235745+0z1-ghb@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:29:02 +0300 Subject: [PATCH 1/4] fix(cron): sanitize env for job script subprocesses Cron no_agent and pre-check scripts ran with the full gateway/agent environment, allowing scripts under HERMES_HOME/scripts/ to read provider credentials. Apply _sanitize_subprocess_env like terminal and MCP paths (SECURITY.md section 2.3). Add regression test asserting blocklisted provider vars are absent in the child process. --- cron/scheduler.py | 7 +++++++ tests/cron/test_cron_script.py | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/cron/scheduler.py b/cron/scheduler.py index 039bf451eba..3e7d783f663 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -961,6 +961,10 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: Shell support lets ``no_agent=True`` jobs ship classic bash watchdogs (the `memory-watchdog.sh` pattern) without wrapping them in Python. + Subprocess environment is passed through ``_sanitize_subprocess_env`` so + provider credentials and other Hermes-managed secrets are not inherited + (SECURITY.md §2.3), matching terminal and MCP child processes. + Args: script_path: Path to the script. Relative paths are resolved against HERMES_HOME/scripts/. Absolute and ~-prefixed paths @@ -1022,6 +1026,8 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: argv = [sys.executable, str(path)] try: + from tools.environments.local import _sanitize_subprocess_env + popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {} result = subprocess.run( argv, @@ -1029,6 +1035,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: text=True, timeout=script_timeout, cwd=str(path.parent), + env=_sanitize_subprocess_env(os.environ), **popen_kwargs, ) stdout = (result.stdout or "").strip() diff --git a/tests/cron/test_cron_script.py b/tests/cron/test_cron_script.py index 7a6a06d5348..783320728bd 100644 --- a/tests/cron/test_cron_script.py +++ b/tests/cron/test_cron_script.py @@ -132,6 +132,29 @@ class TestRunJobScript: assert "exited with code 1" in output assert "error info" in output + def test_script_subprocess_env_sanitized(self, cron_env, monkeypatch): + """Cron scripts must not inherit Hermes provider env (SECURITY.md §2.3).""" + from tools.environments.local import _HERMES_PROVIDER_ENV_BLOCKLIST + from cron.scheduler import _run_job_script + + blocked_var = next(iter(_HERMES_PROVIDER_ENV_BLOCKLIST)) + monkeypatch.setenv(blocked_var, "must_not_leak") + + script = cron_env / "scripts" / "env_probe.py" + script.write_text( + textwrap.dedent( + f"""\ + import os + key = {blocked_var!r} + print("PRESENT" if os.environ.get(key) else "ABSENT") + """ + ) + ) + + success, output = _run_job_script("env_probe.py") + assert success is True + assert output == "ABSENT" + def test_script_empty_output(self, cron_env): from cron.scheduler import _run_job_script From 2d978bf44a7a8126198cd97b43fe8a8deac1af4a Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:22:55 +0530 Subject: [PATCH 2/4] test(cron): make env-sanitize probe var deterministic next(iter(frozenset)) picked a different blocklist var each run (PYTHONHASHSEED-dependent), hurting reproducibility. sorted()[0] keeps the invariant-style assertion (any real blocklisted var) while making failures reproducible. Follow-up to salvaged PR #49207. --- tests/cron/test_cron_script.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/cron/test_cron_script.py b/tests/cron/test_cron_script.py index 783320728bd..ee02d043017 100644 --- a/tests/cron/test_cron_script.py +++ b/tests/cron/test_cron_script.py @@ -137,7 +137,9 @@ class TestRunJobScript: from tools.environments.local import _HERMES_PROVIDER_ENV_BLOCKLIST from cron.scheduler import _run_job_script - blocked_var = next(iter(_HERMES_PROVIDER_ENV_BLOCKLIST)) + # sorted() so the probed var is deterministic across runs + # (frozenset iteration order varies with PYTHONHASHSEED). + blocked_var = sorted(_HERMES_PROVIDER_ENV_BLOCKLIST)[0] monkeypatch.setenv(blocked_var, "must_not_leak") script = cron_env / "scripts" / "env_probe.py" From 8dc0b18894e25522d180fe30971a83a58b14f199 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:29:46 +0530 Subject: [PATCH 3/4] refactor(cron): copy os.environ before sanitizing for subprocess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the env= callsite convention at the other sanitized subprocess spawns (cua_backend dict(os.environ), gateway os.environ.copy()). Functionally equivalent — _sanitize_subprocess_env never mutates its input — but avoids handing the live mapping to the helper. Follow-up to salvaged PR #49207. --- cron/scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 3e7d783f663..413b582b125 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -1035,7 +1035,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: text=True, timeout=script_timeout, cwd=str(path.parent), - env=_sanitize_subprocess_env(os.environ), + env=_sanitize_subprocess_env(os.environ.copy()), **popen_kwargs, ) stdout = (result.stdout or "").strip() From f06508836dd4e5c56ffc14912725c12c6d941291 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:30:42 +0530 Subject: [PATCH 4/4] =?UTF-8?q?docs(security):=20enumerate=20cron=20job=20?= =?UTF-8?q?scripts=20in=20=C2=A72.3=20credential=20scoping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cron-script subprocess is now sanitized alongside shell/MCP/ code-exec children; §2.3 listed only the original three. Makes the _run_job_script docstring's §2.3 citation fully accurate. Follow-up to salvaged PR #49207. --- SECURITY.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index c58e348b579..2579c6eaec5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -121,10 +121,11 @@ outside the supported security posture. ### 2.3 Credential Scoping Hermes Agent filters the environment it passes to its lower-trust -in-process components: shell subprocesses, MCP subprocesses, and -the code-execution child. Credentials like provider API keys and -gateway tokens are stripped by default; variables explicitly -declared by the operator or by a loaded skill are passed through. +in-process components: shell subprocesses, MCP subprocesses, +cron job scripts, and the code-execution child. Credentials like +provider API keys and gateway tokens are stripped by default; +variables explicitly declared by the operator or by a loaded +skill are passed through. This reduces casual exfiltration. It is not containment. Any component running inside the agent process (skills, plugins, hook