diff --git a/cron/scheduler_script.py b/cron/scheduler_script.py index f640fe8f14..9f1ec885fd 100644 --- a/cron/scheduler_script.py +++ b/cron/scheduler_script.py @@ -350,11 +350,15 @@ def _run_job_script( "encoding": "utf-8", "errors": "replace"} # A routed profile's script (desktop multi-profile ticker, multiplex gateway) must see ITS - # profile's .env + vault values — the process env holds the launch profile's. Overlay the - # installed scope onto the base BEFORE sanitizing, so the same scrub / passthrough rules - # apply to those values as to any other; the parent process is never mutated. + # profile's .env + vault values — the process env holds the launch profile's. Drop the + # launch profile's dotenv-owned residue first (a name only the launch .env defines must + # come through UNSET, not with the launch value — the scrub only knows classified secrets), + # then overlay the installed scope, then sanitize, so routed values pass the same scrub / + # passthrough rules as any other. No-op outside multiplex or for the launch profile's own + # fires; the parent process is never mutated. from agent.secret_scope import current_secret_scope - base = dict(os.environ) + from tools.environments.local import strip_launch_profile_env + base = strip_launch_profile_env(dict(os.environ)) scope = current_secret_scope() if scope: base.update(scope) diff --git a/tests/cron/test_cron_no_agent.py b/tests/cron/test_cron_no_agent.py index 0bc29e8d72..b13fdab910 100644 --- a/tests/cron/test_cron_no_agent.py +++ b/tests/cron/test_cron_no_agent.py @@ -395,3 +395,39 @@ def test_a_routed_profile_script_receives_its_own_profile_env(hermes_env, monkey assert ok, output assert output.strip() == "routed|routed-only" assert os.environ["CUSTOM_CRON_VALUE"] == "launch" # the parent process was not mutated + + +def test_a_routed_profile_script_never_receives_a_launch_profile_only_value(hermes_env, monkeypatch): + """Negative control for the overlay above (#107695 review): a name the LAUNCH profile's .env + defines and the routed scope does not must reach the routed child UNSET — not with the launch + value. The secret scrub only knows classified names, so a custom or unclassified secret would + otherwise cross the profile boundary; the launch profile's dotenv residue is dropped first.""" + import os + + from agent import secret_scope + from cron.scheduler_script import _run_job_script + from hermes_constants import get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override + + launch = get_process_hermes_home() + (launch / ".env").write_text("LAUNCH_ONLY_VALUE=launch-only\nCUSTOM_CRON_VALUE=launch\n", encoding="utf-8") + monkeypatch.setenv("LAUNCH_ONLY_VALUE", "launch-only") + monkeypatch.setenv("CUSTOM_CRON_VALUE", "launch") + routed = launch / "profiles" / "ops" + (routed / "scripts").mkdir(parents=True, exist_ok=True) + # Under the routed home override the runner resolves scripts against THAT profile's scripts dir. + script = routed / "scripts" / "probe_launch_only.sh" + script.write_text('#!/bin/bash\necho "${CUSTOM_CRON_VALUE}|${LAUNCH_ONLY_VALUE:-}"\n') + + home_token = set_hermes_home_override(str(routed)) + context_token = secret_scope.set_multiplex_context(True) + scope_token = secret_scope.set_secret_scope({"CUSTOM_CRON_VALUE": "routed"}) + try: + ok, output = _run_job_script("probe_launch_only.sh") + finally: + secret_scope.reset_secret_scope(scope_token) + secret_scope.reset_multiplex_context(context_token) + reset_hermes_home_override(home_token) + + assert ok, output + assert output.strip() == "routed|" + assert os.environ["LAUNCH_ONLY_VALUE"] == "launch-only" # the parent process was not mutated