diff --git a/agent/secret_scope.py b/agent/secret_scope.py index c27fd99e9b..23fc5cd226 100644 --- a/agent/secret_scope.py +++ b/agent/secret_scope.py @@ -262,5 +262,10 @@ def refresh_installed_secret_scope(hermes_home: Path) -> bool: scope = _SECRET_SCOPE.get() if not isinstance(scope, dict): return False - scope.update(build_profile_secret_scope(hermes_home)) + # REPLACE, don't merge: the rebuild is the profile's current truth, so a name a source has + # stopped supplying (rotated, revoked, source removed) must disappear from the fire's scope + # rather than survive as the stale value dict.update() would keep. + rebuilt = build_profile_secret_scope(hermes_home) + scope.clear() + scope.update(rebuilt) return True diff --git a/cron/scheduler_script.py b/cron/scheduler_script.py index 9f1ec885fd..36b63f8836 100644 --- a/cron/scheduler_script.py +++ b/cron/scheduler_script.py @@ -356,9 +356,18 @@ def _run_job_script( # 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 + from agent.secret_scope import _is_global_env, current_secret_scope + from hermes_cli.env_loader import secret_source_names from tools.environments.local import strip_launch_profile_env base = strip_launch_profile_env(dict(os.environ)) + # strip_launch_profile_env only knows dotenv- and terminal-config-owned names. External + # secret sources (vault, 1Password, ...) also write their names into the shared os.environ, + # tracked in secret_source_names(), and a name the LAUNCH profile's source supplied is not + # this profile's to see. Drop them all here; the overlay below puts back exactly the ones + # the routed profile's own sources supply (build_profile_secret_scope folds them in). + for name in secret_source_names(): + if not _is_global_env(name): + base.pop(name, None) scope = current_secret_scope() if scope: base.update(scope) diff --git a/tests/agent/test_secret_scope.py b/tests/agent/test_secret_scope.py index d192bd14ce..857af0c936 100644 --- a/tests/agent/test_secret_scope.py +++ b/tests/agent/test_secret_scope.py @@ -432,3 +432,20 @@ class TestMultiplexContext: finally: ss.reset_secret_scope(scope_token) assert ss.refresh_installed_secret_scope(tmp_path) is False # nothing installed + + +def test_refresh_drops_a_name_the_rebuild_no_longer_supplies(tmp_path, monkeypatch): + """refresh_installed_secret_scope must REPLACE the installed mapping, not merge into it: a + rotated/revoked source value would otherwise survive for the rest of the fire (#107695 review).""" + from agent import secret_scope + + (tmp_path / ".env").write_text("KEPT=new\n", encoding="utf-8") + token = secret_scope.set_secret_scope({"REVOKED_PLUGIN_TOKEN": "old", "KEPT": "stale"}) + try: + assert secret_scope.refresh_installed_secret_scope(tmp_path) is True + live = dict(secret_scope.current_secret_scope() or {}) + finally: + secret_scope.reset_secret_scope(token) + + assert live == {"KEPT": "new"}, live + assert "REVOKED_PLUGIN_TOKEN" not in live diff --git a/tests/cron/test_cron_no_agent.py b/tests/cron/test_cron_no_agent.py index b13fdab910..a258973447 100644 --- a/tests/cron/test_cron_no_agent.py +++ b/tests/cron/test_cron_no_agent.py @@ -431,3 +431,39 @@ def test_a_routed_profile_script_never_receives_a_launch_profile_only_value(herm assert ok, output assert output.strip() == "routed|" assert os.environ["LAUNCH_ONLY_VALUE"] == "launch-only" # the parent process was not mutated + + +def test_a_routed_profile_script_never_receives_a_launch_external_source_value(hermes_env, monkeypatch): + """External secret sources (vault, 1Password, ...) write their names into the shared + ``os.environ`` too, and ``strip_launch_profile_env`` only knows dotenv- and terminal-owned + names. A name the LAUNCH profile's source supplied must still reach the routed child unset + (#107695 review); a name the ROUTED profile's own source supplies must come through.""" + import os + + from agent import secret_scope + from cron.scheduler_script import _run_job_script + from hermes_cli import env_loader + from hermes_constants import get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override + + launch = get_process_hermes_home() + routed = launch / "profiles" / "ops" + (routed / "scripts").mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("LAUNCH_VAULT_ONLY", "launch-vault-value") + monkeypatch.setitem(env_loader._SECRET_SOURCES, "LAUNCH_VAULT_ONLY", "vault") + monkeypatch.setitem(env_loader._SECRET_SOURCES, "ROUTED_VAULT_ONLY", "vault") + script = routed / "scripts" / "probe_vault.sh" + script.write_text('#!/bin/bash\necho "${LAUNCH_VAULT_ONLY:-}|${ROUTED_VAULT_ONLY:-}"\n') + + home_token = set_hermes_home_override(str(routed)) + context_token = secret_scope.set_multiplex_context(True) + scope_token = secret_scope.set_secret_scope({"ROUTED_VAULT_ONLY": "routed-vault-value"}) + try: + ok, output = _run_job_script("probe_vault.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-vault-value" + assert os.environ["LAUNCH_VAULT_ONLY"] == "launch-vault-value" # parent untouched