fix(cron): strip launch external-source names too, and make the scope refresh replace

Two credential-isolation gaps found in review of the previous head.

1. strip_launch_profile_env() only knows dotenv- and terminal-config-owned names,
but external secret sources (vault, 1Password, ...) also write their names into
the shared os.environ and are tracked in secret_source_names(). A name the LAUNCH
profile's source supplied therefore still reached a routed no_agent child. Drop
every non-global source-owned name from the base; the routed scope overlay that
follows puts back exactly the ones that profile's OWN sources supply, since
build_profile_secret_scope folds get_secret_source_values(home) in.

2. refresh_installed_secret_scope() merged the rebuild with dict.update(), so a
name a source had stopped supplying -- rotated, revoked, source removed -- kept
its old value for the rest of the fire. Replace the mapping contents instead: the
rebuild is the profile's current truth.

Regressions: a routed child sees <unset> for a launch-source name while its own
source value comes through, and a refresh whose rebuild omits a name drops it.
Both fail if the corresponding change is reverted.

(cherry picked from commit ecd51517c4828a75acb5f458ed99aea0bc3e5e9f)
This commit is contained in:
John Paul Soliva
2026-09-13 00:30:38 +09:00
committed by kshitij
parent 023e4f997f
commit 0943e77136
4 changed files with 69 additions and 2 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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|<unset>"
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:-<unset>}|${ROUTED_VAULT_ONLY:-<unset>}"\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() == "<unset>|routed-vault-value"
assert os.environ["LAUNCH_VAULT_ONLY"] == "launch-vault-value" # parent untouched