Files
hermes-agent/tests/agent/test_secret_scope.py
T
John Paul Soliva 0943e77136 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)
2026-09-15 11:03:39 +05:30

452 lines
18 KiB
Python

"""Tests for the profile-scoped credential primitive (Workstream A / Phase 2)."""
import pytest
from agent import secret_scope as ss
@pytest.fixture(autouse=True)
def _reset_multiplex():
"""Ensure each test starts and ends with multiplexing off (it's a global)."""
ss.set_multiplex_active(False)
yield
ss.set_multiplex_active(False)
class TestMultiplexInactiveBackwardCompat:
"""Default deployment: get_secret transparently reads os.environ."""
def test_reads_environ(self, monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
assert ss.get_secret("ANTHROPIC_API_KEY") == "sk-test"
def test_missing_returns_default(self, monkeypatch):
monkeypatch.delenv("NOPE_KEY", raising=False)
assert ss.get_secret("NOPE_KEY") is None
assert ss.get_secret("NOPE_KEY", "fallback") == "fallback"
def test_no_raise_without_scope(self, monkeypatch):
monkeypatch.delenv("SOME_KEY", raising=False)
# multiplex off => unscoped read is fine, returns default
assert ss.get_secret("SOME_KEY") is None
class TestMultiplexActiveFailClosed:
"""Multiplex on: an unscoped secret read raises instead of leaking."""
def test_unscoped_read_raises(self, monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-leaky")
ss.set_multiplex_active(True)
with pytest.raises(ss.UnscopedSecretError):
ss.get_secret("ANTHROPIC_API_KEY")
def test_scoped_missing_key_returns_default_not_environ(self, monkeypatch):
# Even though the value exists in os.environ, a scope is authoritative:
# an absent scope key must NOT fall through to the (cross-profile) env.
monkeypatch.setenv("OPENAI_API_KEY", "sk-other-profile")
ss.set_multiplex_active(True)
token = ss.set_secret_scope({"ANTHROPIC_API_KEY": "sk-mine"})
try:
assert ss.get_secret("OPENAI_API_KEY") is None
assert ss.get_secret("OPENAI_API_KEY", "d") == "d"
finally:
ss.reset_secret_scope(token)
class TestScopedSingleProfile:
"""Multiplex OFF with a scope installed: the scope is an overlay, not a
blindfold. The cron scheduler installs a ``<home>/.env`` scope around every
job unconditionally, and single-profile deployments legitimately supply
credentials via the process environment only (systemd ``Environment=``,
``pass-cli run`` / ``op run`` wrappers) — those must keep resolving."""
def test_scope_hit_wins_over_environ(self, monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-from-environ")
token = ss.set_secret_scope({"ANTHROPIC_API_KEY": "sk-from-env-file"})
try:
assert ss.get_secret("ANTHROPIC_API_KEY") == "sk-from-env-file"
finally:
ss.reset_secret_scope(token)
def test_scope_miss_absent_everywhere_returns_default(self, monkeypatch):
monkeypatch.delenv("NOPE_KEY", raising=False)
token = ss.set_secret_scope({})
try:
assert ss.get_secret("NOPE_KEY") is None
assert ss.get_secret("NOPE_KEY", "d") == "d"
finally:
ss.reset_secret_scope(token)
def test_multiplex_on_still_authoritative(self, monkeypatch):
# The fallthrough is strictly multiplex-off behavior: turning
# multiplexing on must restore scope-authoritative semantics.
monkeypatch.setenv("OPENAI_API_KEY", "sk-other-profile")
ss.set_multiplex_active(True)
token = ss.set_secret_scope({})
try:
assert ss.get_secret("OPENAI_API_KEY") is None
finally:
ss.reset_secret_scope(token)
class TestScopeIsolation:
"""Two scopes never see each other's secrets."""
def test_nested_scopes_restore(self):
ss.set_multiplex_active(True)
t1 = ss.set_secret_scope({"K": "a"})
try:
assert ss.get_secret("K") == "a"
t2 = ss.set_secret_scope({"K": "b"})
try:
assert ss.get_secret("K") == "b"
finally:
ss.reset_secret_scope(t2)
assert ss.get_secret("K") == "a"
finally:
ss.reset_secret_scope(t1)
class TestEnvFileParsing:
"""load_env_file parses without mutating os.environ."""
def test_load_env_file_unescapes_quoted_values(self, tmp_path):
"""Values written by save_env_value must round-trip byte-exactly.
Regression: load_env_file stripped only the outer quotes, leaving
the writer's \\" and \\\\ escapes literal — credentials containing
'\"' or '\\' worked interactively but were corrupted under scoped
(cron / multiplex) resolution.
"""
from hermes_cli.config import _quote_env_value
original = 'tok"en\\with spaces'
(tmp_path / ".env").write_text(f"MY_TOKEN={_quote_env_value(original)}\n")
assert ss.load_env_file(tmp_path / ".env") == {"MY_TOKEN": original}
def test_load_env_file_single_quotes_and_plain_values(self, tmp_path):
(tmp_path / ".env").write_text(
"PLAIN=abc123\nQUOTED='single quoted'\nEMPTY=\n"
)
assert ss.load_env_file(tmp_path / ".env") == {
"PLAIN": "abc123",
"QUOTED": "single quoted",
"EMPTY": "",
}
def test_inline_comment_stripped_from_unquoted_value(self, tmp_path):
"""`KEY=value # comment` → `value` (python-dotenv semantics)."""
(tmp_path / ".env").write_text("KEY=value # comment\nTABBED=foo\t#tabbed\n")
assert ss.load_env_file(tmp_path / ".env") == {
"KEY": "value",
"TABBED": "foo",
}
def test_hash_without_preceding_whitespace_is_not_a_comment(self, tmp_path):
"""`KEY=foo#bar` stays intact — dotenv only strips `#` after whitespace."""
(tmp_path / ".env").write_text("KEY=foo#bar\nLEAD=#leading\n")
assert ss.load_env_file(tmp_path / ".env") == {
"KEY": "foo#bar",
"LEAD": "#leading",
}
def test_inline_comment_after_quoted_value(self, tmp_path):
"""Quotes strip AND the trailing comment drops; inner `#` survives."""
(tmp_path / ".env").write_text(
"DQ=\"has # inside\" # trailing\n"
"SQ='single # inside' # trailing\n"
)
assert ss.load_env_file(tmp_path / ".env") == {
"DQ": "has # inside",
"SQ": "single # inside",
}
def test_inline_comment_with_escaped_quote_inside_value(self, tmp_path):
r"""Escape-aware close-quote scan: `\"` must not terminate the value."""
(tmp_path / ".env").write_text(
'KEY="a \\" quote # x" # trail\n'
)
assert ss.load_env_file(tmp_path / ".env") == {"KEY": 'a " quote # x'}
def test_round_trip_writer_value_with_trailing_comment(self, tmp_path):
"""A value quoted by the save_env_value writer survives an appended
inline comment byte-exactly."""
from hermes_cli.config import _quote_env_value
original = 'we#ird "tok\\en" # not a comment'
quoted = _quote_env_value(original)
(tmp_path / ".env").write_text(f"MY_TOKEN={quoted} # rotated 2026-08\n")
assert ss.load_env_file(tmp_path / ".env") == {"MY_TOKEN": original}
def test_strips_utf8_bom_from_first_key(self, tmp_path):
"""Windows editors often save .env as UTF-8 with BOM (EF BB BF).
Plain utf-8 keeps U+FEFF on the first key name, so get_secret('NAME')
misses under an installed scope. utf-8-sig strips the leading BOM.
"""
env = tmp_path / ".env"
env.write_bytes(
b"\xef\xbb\xbfANTHROPIC_API_KEY=sk-x\nOPENAI_API_KEY=sk-y\n"
)
out = ss.load_env_file(env)
assert out == {
"ANTHROPIC_API_KEY": "sk-x",
"OPENAI_API_KEY": "sk-y",
}
assert "\ufeffANTHROPIC_API_KEY" not in out
scope = ss.build_profile_secret_scope(tmp_path)
ss.set_multiplex_active(True)
token = ss.set_secret_scope(scope)
try:
assert ss.get_secret("ANTHROPIC_API_KEY") == "sk-x"
assert ss.get_secret("OPENAI_API_KEY") == "sk-y"
finally:
ss.reset_secret_scope(token)
ss.set_multiplex_active(False)
def test_build_profile_secret_scope(self, tmp_path):
(tmp_path / ".env").write_text("ANTHROPIC_API_KEY=sk-profile\n")
assert ss.build_profile_secret_scope(tmp_path) == {
"ANTHROPIC_API_KEY": "sk-profile"
}
def test_build_profile_secret_scope_includes_home_external_secrets(
self, tmp_path, monkeypatch
):
(tmp_path / ".env").write_text("XIAOMI_API_KEY=placeholder\n")
from hermes_cli import env_loader
home_key = str(tmp_path.resolve())
monkeypatch.setitem(
env_loader._SECRET_SOURCE_VALUES_BY_HOME,
home_key,
{"XIAOMI_API_KEY": "sk-from-bitwarden"},
)
assert ss.build_profile_secret_scope(tmp_path) == {
"XIAOMI_API_KEY": "sk-from-bitwarden"
}
def test_build_profile_secret_scope_ignores_other_home_external_secrets(
self, tmp_path, monkeypatch
):
profile = tmp_path / "profile"
other = tmp_path / "other"
profile.mkdir()
other.mkdir()
from hermes_cli import env_loader
monkeypatch.setitem(
env_loader._SECRET_SOURCE_VALUES_BY_HOME,
str(other.resolve()),
{"XIAOMI_API_KEY": "sk-other-profile"},
)
assert ss.build_profile_secret_scope(profile) == {}
class TestApiServerListenerGlobals:
"""API_SERVER listener settings are deployment config (#69379), not
profile secrets: the scoped runner reload must keep seeing container env
(Docker compose ``environment:`` block). API_SERVER_KEY IS a credential
and stays profile-scoped."""
LISTENER_VARS = (
"API_SERVER_ENABLED",
"API_SERVER_HOST",
"API_SERVER_PORT",
"API_SERVER_CORS_ORIGINS",
)
def test_listener_vars_read_environ_even_when_scoped_multiplex(self, monkeypatch):
for name in self.LISTENER_VARS:
monkeypatch.setenv(name, f"container-{name.lower()}")
ss.set_multiplex_active(True)
token = ss.set_secret_scope({"TELEGRAM_BOT_TOKEN": "scoped"})
try:
for name in self.LISTENER_VARS:
assert ss.get_secret(name) == f"container-{name.lower()}"
finally:
ss.reset_secret_scope(token)
def test_api_server_key_stays_profile_scoped(self, monkeypatch):
monkeypatch.setenv("API_SERVER_KEY", "default-profile-key-0123456789abcdef")
ss.set_multiplex_active(True)
token = ss.set_secret_scope({"OTHER": "x"})
try:
# A scoped miss must NOT borrow the (potentially cross-profile)
# environ value: API_SERVER_KEY is a credential.
assert ss.get_secret("API_SERVER_KEY") is None
finally:
ss.reset_secret_scope(token)
assert not ss._is_global_env("API_SERVER_KEY")
class TestRelayRoutingStampGlobals:
"""GATEWAY_RELAY_* ROUTING stamps are deployment config, not profile
secrets: config's relay enablement/sweep and gateway.relay's readers
(relay_url(), registration, self-provision) must resolve the same
process-env value under any scope, or the gateway enters a split-brain
state (adapter registered but Platform.RELAY absent from config, or vice
versa). Auth material (GATEWAY_RELAY_SECRET / _ID / _DELIVERY_KEY and the
IDP_* credentials) stays profile-scoped with the fail-closed guard —
mirroring the API_SERVER_KEY line above and the terminal env blocklist
(tools/environments/local.py)."""
ROUTING_VARS = (
"GATEWAY_RELAY_URL",
"GATEWAY_RELAY_ENDPOINT",
"GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS",
"GATEWAY_RELAY_PLATFORMS",
"GATEWAY_RELAY_BOT_IDS",
"GATEWAY_RELAY_ROUTE_KEYS",
"GATEWAY_RELAY_INSTANCE_ID",
"GATEWAY_RELAY_WAKE_URL",
"GATEWAY_RELAY_DISPLAY_NAME",
)
AUTH_VARS = (
"GATEWAY_RELAY_SECRET",
"GATEWAY_RELAY_ID",
"GATEWAY_RELAY_DELIVERY_KEY",
"GATEWAY_RELAY_IDP_CLIENT_SECRET",
"GATEWAY_RELAY_IDP_CLIENT_ID",
"GATEWAY_RELAY_IDP_TOKEN_URL",
)
def test_routing_stamps_read_environ_even_when_scoped_multiplex(self, monkeypatch):
for name in self.ROUTING_VARS:
monkeypatch.setenv(name, f"deploy-{name.lower()}")
ss.set_multiplex_active(True)
token = ss.set_secret_scope({"TELEGRAM_BOT_TOKEN": "scoped"})
try:
for name in self.ROUTING_VARS:
assert ss.get_secret(name) == f"deploy-{name.lower()}", name
finally:
ss.reset_secret_scope(token)
ss.set_multiplex_active(False)
def test_relay_auth_material_stays_profile_scoped(self, monkeypatch):
for name in self.AUTH_VARS:
monkeypatch.setenv(name, "cross-profile-credential")
ss.set_multiplex_active(True)
token = ss.set_secret_scope({"OTHER": "x"})
try:
for name in self.AUTH_VARS:
# A scoped miss must NOT borrow the (potentially
# cross-profile) environ value: relay auth is a credential.
assert ss.get_secret(name) is None, name
finally:
ss.reset_secret_scope(token)
ss.set_multiplex_active(False)
for name in self.AUTH_VARS:
assert not ss._is_global_env(name), name
class TestSecretScopeAcrossExecutorThreads:
"""Multiplexed profile state must reach pool workers (see #95119).
The context-compression timeout fence runs auxiliary LLM calls in a
daemon thread pool. Bundled CPython runtime builds omit
``ThreadPoolExecutor``'s context propagation, so the profile secret
scope was absent in the worker and ``get_secret`` failed closed with
``UnscopedSecretError``, silently degrading compression to lossy
deterministic summaries. ``DaemonThreadPoolExecutor.submit`` restores
stdlib context semantics; these tests lock that in.
"""
def test_scoped_read_works_in_daemon_pool_worker(self, monkeypatch):
from tools.daemon_pool import DaemonThreadPoolExecutor
monkeypatch.setenv("SURPLUS_API_KEY", "env-key")
ss.set_multiplex_active(True)
token = ss.set_secret_scope({"SURPLUS_API_KEY": "scope-key"})
pool = DaemonThreadPoolExecutor(max_workers=1)
try:
# The scope (authoritative under multiplex) must reach the worker.
seen = pool.submit(ss.get_secret, "SURPLUS_API_KEY").result(timeout=10)
assert seen == "scope-key"
# A scoped miss must still not borrow the (cross-profile) env value.
monkeypatch.setenv("OPENAI_API_KEY", "env-leak")
assert pool.submit(ss.get_secret, "OPENAI_API_KEY").result(timeout=10) is None
finally:
pool.shutdown(wait=True)
ss.reset_secret_scope(token)
class TestMultiplexContext:
"""A task can run under multiplex semantics without flipping the process flag: the desktop
backend ticks sibling profiles' cron jobs from a process whose own turns stay single-profile."""
def test_context_turns_multiplex_on_for_the_task_only(self):
assert ss.is_multiplex_active() is False
token = ss.set_multiplex_context(True)
try:
assert ss.is_multiplex_active() is True
finally:
ss.reset_multiplex_context(token)
assert ss.is_multiplex_active() is False
def test_context_propagates_through_copy_context_like_the_pool_dispatch(self):
import contextvars
import threading
token = ss.set_multiplex_context(True)
try:
ctx = contextvars.copy_context() # what cron's _submit_with_guard hands the worker
finally:
ss.reset_multiplex_context(token)
seen = {}
worker = threading.Thread(target=lambda: seen.update(v=ctx.run(ss.is_multiplex_active)))
worker.start()
worker.join()
assert seen["v"] is True
assert ss.is_multiplex_active() is False # the caller's own context is untouched
def test_scoped_miss_under_context_never_reads_the_process_env(self, monkeypatch):
monkeypatch.setenv("LAUNCH_ONLY_TOKEN", "launch-token")
token = ss.set_multiplex_context(True)
scope_token = ss.set_secret_scope({"ROUTED_KEY": "routed"})
try:
assert ss.get_secret("ROUTED_KEY") == "routed"
assert ss.get_secret("LAUNCH_ONLY_TOKEN") is None
finally:
ss.reset_secret_scope(scope_token)
ss.reset_multiplex_context(token)
assert ss.get_secret("LAUNCH_ONLY_TOKEN") == "launch-token" # single-profile semantics resume
def test_refresh_installed_secret_scope_folds_in_values_learned_after_the_freeze(self, tmp_path):
(tmp_path / ".env").write_text("EARLY_KEY=early\n", encoding="utf-8")
scope_token = ss.set_secret_scope(ss.build_profile_secret_scope(tmp_path))
try:
(tmp_path / ".env").write_text("EARLY_KEY=early\nLATE_KEY=late\n", encoding="utf-8")
assert ss.get_secret("LATE_KEY") is None
assert ss.refresh_installed_secret_scope(tmp_path) is True
assert ss.get_secret("LATE_KEY") == "late"
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