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 diff --git a/cron/scheduler.py b/cron/scheduler.py index 039bf451eba..413b582b125 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.copy()), **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..ee02d043017 100644 --- a/tests/cron/test_cron_script.py +++ b/tests/cron/test_cron_script.py @@ -132,6 +132,31 @@ 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 + + # 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" + 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