- gateway/platforms/yuanbao.py::AutoSetHomeMiddleware: the first authorized DM to a SECONDARY Yuanbao bot wrote YUANBAO_HOME_CHANNEL into os.environ, making that tenant's chat the default profile's cron/notification home. The write now only happens unscoped; reads go through the scoped reader + config. - tools/env_passthrough.py::_config_passthrough: one module slot froze the first profile's terminal.env_passthrough for every profile's sandbox children; keyed by hermes_home_key(). - gateway/run.py::_slack_ignored_channels_from_gateway_config: the runner-level fail-safe only had the DEFAULT profile's GatewayConfig, so a secondary Slack bot's traffic was judged by the default's ignored list. It now takes the source's routed adapter (whose extra is the secondary's own config) and reads the env fallback through the scoped gate reader.
467 lines
20 KiB
Python
467 lines
20 KiB
Python
"""Tests for tools.env_passthrough — skill and config env var passthrough."""
|
|
|
|
import os
|
|
import pytest
|
|
import yaml
|
|
|
|
from agent import secret_scope as ss
|
|
import tools.env_passthrough as _ep_mod
|
|
from tools.env_passthrough import (
|
|
clear_env_passthrough,
|
|
get_all_passthrough,
|
|
is_env_passthrough,
|
|
register_env_passthrough,
|
|
resolve_passthrough_value,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_passthrough():
|
|
"""Ensure a clean passthrough state for every test."""
|
|
clear_env_passthrough()
|
|
_ep_mod._config_passthrough.clear()
|
|
ss.set_multiplex_active(False)
|
|
yield
|
|
clear_env_passthrough()
|
|
_ep_mod._config_passthrough.clear()
|
|
ss.set_multiplex_active(False)
|
|
|
|
|
|
class TestSkillScopedPassthrough:
|
|
def test_register_and_check(self):
|
|
assert not is_env_passthrough("TENOR_API_KEY")
|
|
register_env_passthrough(["TENOR_API_KEY"])
|
|
assert is_env_passthrough("TENOR_API_KEY")
|
|
|
|
|
|
def test_skips_empty(self):
|
|
register_env_passthrough(["", " ", "VALID_KEY"])
|
|
assert is_env_passthrough("VALID_KEY")
|
|
assert not is_env_passthrough("")
|
|
|
|
|
|
class TestConfigPassthrough:
|
|
def test_reads_from_config(self, tmp_path, monkeypatch):
|
|
config = {"terminal": {"env_passthrough": ["MY_CUSTOM_KEY", "ANOTHER_TOKEN"]}}
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text(yaml.dump(config), encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
_ep_mod._config_passthrough.clear()
|
|
|
|
assert is_env_passthrough("MY_CUSTOM_KEY")
|
|
assert is_env_passthrough("ANOTHER_TOKEN")
|
|
assert not is_env_passthrough("UNRELATED_VAR")
|
|
|
|
|
|
def test_union_of_skill_and_config(self, tmp_path, monkeypatch):
|
|
config = {"terminal": {"env_passthrough": ["CONFIG_KEY"]}}
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text(yaml.dump(config), encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
_ep_mod._config_passthrough.clear()
|
|
|
|
register_env_passthrough(["SKILL_KEY"])
|
|
all_pt = get_all_passthrough()
|
|
assert "CONFIG_KEY" in all_pt
|
|
assert "SKILL_KEY" in all_pt
|
|
|
|
|
|
class TestProfileScopedResolution:
|
|
def test_active_scope_overrides_process_fallback(self):
|
|
ss.set_multiplex_active(True)
|
|
token = ss.set_secret_scope({"SERVICE_TOKEN": "profile-b"})
|
|
try:
|
|
assert resolve_passthrough_value("SERVICE_TOKEN", "profile-a") == "profile-b"
|
|
finally:
|
|
ss.reset_secret_scope(token)
|
|
|
|
def test_active_scope_does_not_fall_back_to_another_profile(self):
|
|
ss.set_multiplex_active(True)
|
|
token = ss.set_secret_scope({})
|
|
try:
|
|
assert resolve_passthrough_value("SERVICE_TOKEN", "profile-a") is None
|
|
finally:
|
|
ss.reset_secret_scope(token)
|
|
|
|
def test_unscoped_multiplex_read_fails_closed(self):
|
|
ss.set_multiplex_active(True)
|
|
with pytest.raises(ss.UnscopedSecretError):
|
|
resolve_passthrough_value("SERVICE_TOKEN", "profile-a")
|
|
|
|
def test_single_profile_keeps_callers_fallback(self):
|
|
assert resolve_passthrough_value("SERVICE_TOKEN", "profile-a") == "profile-a"
|
|
|
|
def test_active_scope_keeps_explicit_global_override(self, monkeypatch):
|
|
"""Global terminal settings still honor a caller-provided override."""
|
|
monkeypatch.setenv("TERMINAL_CWD", "/default")
|
|
ss.set_multiplex_active(True)
|
|
token = ss.set_secret_scope({})
|
|
try:
|
|
assert resolve_passthrough_value("TERMINAL_CWD", "/explicit") == "/explicit"
|
|
finally:
|
|
ss.reset_secret_scope(token)
|
|
|
|
|
|
class TestExecuteCodeIntegration:
|
|
"""Verify that the passthrough is checked in execute_code's env filtering."""
|
|
|
|
def test_secret_substring_blocked_by_default(self):
|
|
"""TENOR_API_KEY should be blocked without passthrough."""
|
|
_SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM",
|
|
"TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME",
|
|
"XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA")
|
|
_SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL",
|
|
"PASSWD", "AUTH")
|
|
|
|
test_env = {"PATH": "/usr/bin", "TENOR_API_KEY": "test123", "HOME": "/home/user"}
|
|
child_env = {}
|
|
for k, v in test_env.items():
|
|
if is_env_passthrough(k):
|
|
child_env[k] = v
|
|
continue
|
|
if any(s in k.upper() for s in _SECRET_SUBSTRINGS):
|
|
continue
|
|
if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES):
|
|
child_env[k] = v
|
|
|
|
assert "PATH" in child_env
|
|
assert "HOME" in child_env
|
|
assert "TENOR_API_KEY" not in child_env
|
|
|
|
def test_passthrough_allows_secret_through(self):
|
|
"""TENOR_API_KEY should pass through when registered."""
|
|
_SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM",
|
|
"TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME",
|
|
"XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA")
|
|
_SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL",
|
|
"PASSWD", "AUTH")
|
|
|
|
register_env_passthrough(["TENOR_API_KEY"])
|
|
|
|
test_env = {"PATH": "/usr/bin", "TENOR_API_KEY": "test123", "HOME": "/home/user"}
|
|
child_env = {}
|
|
for k, v in test_env.items():
|
|
if is_env_passthrough(k):
|
|
child_env[k] = v
|
|
continue
|
|
if any(s in k.upper() for s in _SECRET_SUBSTRINGS):
|
|
continue
|
|
if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES):
|
|
child_env[k] = v
|
|
|
|
assert "PATH" in child_env
|
|
assert "HOME" in child_env
|
|
assert "TENOR_API_KEY" in child_env
|
|
assert child_env["TENOR_API_KEY"] == "test123"
|
|
|
|
def test_execute_code_uses_active_profile_for_passthrough(self, monkeypatch):
|
|
"""The execute_code child must receive the routed profile's value."""
|
|
from tools.code_execution_env import _scrub_child_env
|
|
|
|
register_env_passthrough(["SERVICE_TOKEN"])
|
|
monkeypatch.setenv("SERVICE_TOKEN", "token-for-default")
|
|
ss.set_multiplex_active(True)
|
|
token = ss.set_secret_scope({"SERVICE_TOKEN": "token-for-routed-profile"})
|
|
try:
|
|
child_env = _scrub_child_env({"SERVICE_TOKEN": "token-for-default"})
|
|
finally:
|
|
ss.reset_secret_scope(token)
|
|
ss.set_multiplex_active(False)
|
|
|
|
assert child_env["SERVICE_TOKEN"] == "token-for-routed-profile"
|
|
|
|
def test_execute_code_omits_missing_scoped_passthrough(self, monkeypatch):
|
|
"""A missing routed secret must not leak into the execute_code child."""
|
|
from tools.code_execution_env import _scrub_child_env
|
|
|
|
register_env_passthrough(["SERVICE_TOKEN"])
|
|
monkeypatch.setenv("SERVICE_TOKEN", "token-for-default")
|
|
ss.set_multiplex_active(True)
|
|
token = ss.set_secret_scope({})
|
|
try:
|
|
child_env = _scrub_child_env({"SERVICE_TOKEN": "token-for-default"})
|
|
finally:
|
|
ss.reset_secret_scope(token)
|
|
ss.set_multiplex_active(False)
|
|
|
|
assert "SERVICE_TOKEN" not in child_env
|
|
|
|
def test_execute_code_strips_buzz_vars(self):
|
|
"""BUZZ_* credentials must stay out of the execute_code child even
|
|
though they pass through to terminal children (issue #78026): the
|
|
carve-out is terminal-only.
|
|
|
|
- BUZZ_PRIVATE_KEY matches the KEY secret substring.
|
|
- BUZZ_AUTH_TAG matches the AUTH secret substring.
|
|
- BUZZ_RELAY_URL matches no secret substring but is not on the safe
|
|
prefix allowlist, so it is dropped too.
|
|
"""
|
|
from tools.code_execution_env import _scrub_child_env
|
|
|
|
buzz_vars = {
|
|
"BUZZ_PRIVATE_KEY": "nsec1fake",
|
|
"BUZZ_AUTH_TAG": '["tag","data","kind","sig"]',
|
|
"BUZZ_RELAY_URL": "https://mycommunity.communities.buzz.xyz",
|
|
"PATH": "/usr/bin",
|
|
"HOME": "/home/user",
|
|
}
|
|
child_env = _scrub_child_env(buzz_vars)
|
|
|
|
assert "BUZZ_PRIVATE_KEY" not in child_env
|
|
assert "BUZZ_AUTH_TAG" not in child_env
|
|
assert "BUZZ_RELAY_URL" not in child_env
|
|
assert child_env["PATH"] == "/usr/bin"
|
|
assert child_env["HOME"] == "/home/user"
|
|
|
|
|
|
class TestTerminalIntegration:
|
|
"""Verify that the passthrough is checked in terminal's env sanitizers."""
|
|
|
|
def test_background_terminal_uses_active_profile_for_passthrough(self, monkeypatch):
|
|
"""Background/PTY terminal children must use the routed profile value."""
|
|
from tools.environments.local import _sanitize_subprocess_env
|
|
|
|
register_env_passthrough(["SERVICE_TOKEN"])
|
|
monkeypatch.setenv("SERVICE_TOKEN", "token-for-default")
|
|
ss.set_multiplex_active(True)
|
|
token = ss.set_secret_scope({"SERVICE_TOKEN": "token-for-routed-profile"})
|
|
try:
|
|
child_env = _sanitize_subprocess_env(
|
|
{"SERVICE_TOKEN": "token-for-default"},
|
|
{"SERVICE_TOKEN": "token-for-default"},
|
|
)
|
|
finally:
|
|
ss.reset_secret_scope(token)
|
|
ss.set_multiplex_active(False)
|
|
|
|
assert child_env["SERVICE_TOKEN"] == "token-for-routed-profile"
|
|
|
|
def test_background_terminal_omits_missing_scoped_passthrough(self, monkeypatch):
|
|
"""A missing routed secret must not leak into background terminal work."""
|
|
from tools.environments.local import _sanitize_subprocess_env
|
|
|
|
register_env_passthrough(["SERVICE_TOKEN"])
|
|
monkeypatch.setenv("SERVICE_TOKEN", "token-for-default")
|
|
ss.set_multiplex_active(True)
|
|
token = ss.set_secret_scope({})
|
|
try:
|
|
child_env = _sanitize_subprocess_env({"SERVICE_TOKEN": "token-for-default"})
|
|
finally:
|
|
ss.reset_secret_scope(token)
|
|
ss.set_multiplex_active(False)
|
|
|
|
assert "SERVICE_TOKEN" not in child_env
|
|
|
|
def test_shared_local_snapshot_re_resolves_current_profile(self, monkeypatch, tmp_path):
|
|
"""A persistent shell snapshot must not retain the previous profile's value."""
|
|
from tools.environments.local import LocalEnvironment
|
|
|
|
register_env_passthrough(["SERVICE_TOKEN"])
|
|
monkeypatch.setenv("SERVICE_TOKEN", "token-for-default")
|
|
ss.set_multiplex_active(True)
|
|
env = None
|
|
token_b = None
|
|
token_c = None
|
|
try:
|
|
token_a = ss.set_secret_scope({"SERVICE_TOKEN": "token-for-profile-a"})
|
|
try:
|
|
env = LocalEnvironment(cwd=str(tmp_path))
|
|
assert env.execute("printf '%s' \"$SERVICE_TOKEN\"")["output"] == "token-for-profile-a"
|
|
finally:
|
|
ss.reset_secret_scope(token_a)
|
|
|
|
token_b = ss.set_secret_scope({"SERVICE_TOKEN": "token-for-profile-b"})
|
|
result = env.execute("printf '%s' \"$SERVICE_TOKEN\"")
|
|
ss.reset_secret_scope(token_b)
|
|
token_b = None
|
|
|
|
token_c = ss.set_secret_scope({})
|
|
missing = env.execute("printf '%s' \"${SERVICE_TOKEN-unset}\"")
|
|
finally:
|
|
if token_b is not None:
|
|
ss.reset_secret_scope(token_b)
|
|
if token_c is not None:
|
|
ss.reset_secret_scope(token_c)
|
|
ss.set_multiplex_active(False)
|
|
if env is not None:
|
|
env.cleanup()
|
|
|
|
assert result["output"] == "token-for-profile-b"
|
|
assert missing["output"] == "unset"
|
|
|
|
def test_blocklisted_var_blocked_by_default(self):
|
|
from tools.environments.local import _sanitize_subprocess_env
|
|
from tools.environments.local_env_policy import _HERMES_PROVIDER_ENV_BLOCKLIST
|
|
|
|
# Pick a var we know is in the blocklist
|
|
blocked_var = next(iter(_HERMES_PROVIDER_ENV_BLOCKLIST))
|
|
env = {blocked_var: "secret_value", "PATH": "/usr/bin"}
|
|
result = _sanitize_subprocess_env(env)
|
|
assert blocked_var not in result
|
|
assert "PATH" in result
|
|
|
|
def test_passthrough_cannot_override_provider_blocklist(self):
|
|
"""GHSA-rhgp-j443-p4rf: register_env_passthrough must NOT accept
|
|
Hermes provider credentials — that was the bypass where a skill
|
|
could declare ANTHROPIC_TOKEN / OPENAI_API_KEY as passthrough and
|
|
defeat the execute_code sandbox scrubbing."""
|
|
from tools.environments.local import _sanitize_subprocess_env
|
|
from tools.environments.local_env_policy import _HERMES_PROVIDER_ENV_BLOCKLIST
|
|
|
|
blocked_var = next(iter(_HERMES_PROVIDER_ENV_BLOCKLIST))
|
|
# Attempt to register — must be silently refused (logged warning).
|
|
register_env_passthrough([blocked_var])
|
|
|
|
# is_env_passthrough must NOT report it as allowed
|
|
assert not is_env_passthrough(blocked_var)
|
|
|
|
# Sanitizer still strips the var from subprocess env
|
|
env = {blocked_var: "secret_value", "PATH": "/usr/bin"}
|
|
result = _sanitize_subprocess_env(env)
|
|
assert blocked_var not in result
|
|
assert "PATH" in result
|
|
|
|
def test_passthrough_cannot_override_internal_dynamic_secret(self):
|
|
"""A skill must NOT be able to register dynamically-named Hermes
|
|
secrets (AUXILIARY_*_API_KEY / _BASE_URL, GATEWAY_RELAY_* auth) as
|
|
passthrough — they aren't in the static blocklist, so this is the
|
|
defense-in-depth layer that keeps env_passthrough consistent with the
|
|
unconditional strip in the sanitizers."""
|
|
from tools.environments.local import _sanitize_subprocess_env
|
|
|
|
for var in (
|
|
"AUXILIARY_VISION_API_KEY",
|
|
"AUXILIARY_VISION_BASE_URL",
|
|
"GATEWAY_RELAY_SECRET",
|
|
"GATEWAY_RELAY_DELIVERY_KEY",
|
|
):
|
|
register_env_passthrough([var])
|
|
assert not is_env_passthrough(var), (
|
|
f"{var} should be refused passthrough registration"
|
|
)
|
|
result = _sanitize_subprocess_env({var: "secret", "PATH": "/usr/bin"})
|
|
assert var not in result
|
|
assert "PATH" in result
|
|
|
|
def test_passthrough_cannot_register_buzz_vars(self, monkeypatch):
|
|
"""GHSA-rhgp-j443-p4rf seal stays intact for the BUZZ_* first-party
|
|
platform credentials: even though they pass through to terminal
|
|
children in a Buzz agent context (issue #78026), env_passthrough
|
|
registration must still refuse them — the carve-out opens NO
|
|
registration path, so a skill cannot expand BUZZ_* exposure to
|
|
execute_code."""
|
|
from tools.environments.local import _sanitize_subprocess_env
|
|
|
|
monkeypatch.setenv("BUZZ_MANAGED_AGENT", "1")
|
|
for var in (
|
|
"BUZZ_PRIVATE_KEY",
|
|
"BUZZ_AUTH_TAG",
|
|
"BUZZ_RELAY_URL",
|
|
):
|
|
register_env_passthrough([var])
|
|
assert not is_env_passthrough(var), (
|
|
f"{var} should be refused passthrough registration"
|
|
)
|
|
# Terminal sanitizer still passes BUZZ_* through to terminal
|
|
# children by the first-party carve-out...
|
|
result = _sanitize_subprocess_env({var: "value", "PATH": "/usr/bin"})
|
|
assert result.get(var) == "value"
|
|
# ...but the execute_code child never sees them.
|
|
from tools.code_execution_env import _scrub_child_env
|
|
|
|
child_env = _scrub_child_env({var: "value", "PATH": "/usr/bin"})
|
|
assert var not in child_env
|
|
|
|
def test_passthrough_allows_auxiliary_non_secret_routing(self):
|
|
"""AUXILIARY_*_PROVIDER / _MODEL and GATEWAY_RELAY routing hints are not
|
|
secrets, so a skill may still register them (they're not protected)."""
|
|
register_env_passthrough([
|
|
"AUXILIARY_VISION_PROVIDER",
|
|
"AUXILIARY_VISION_MODEL",
|
|
"GATEWAY_RELAY_URL",
|
|
])
|
|
assert is_env_passthrough("AUXILIARY_VISION_PROVIDER")
|
|
assert is_env_passthrough("AUXILIARY_VISION_MODEL")
|
|
assert is_env_passthrough("GATEWAY_RELAY_URL")
|
|
|
|
def test_make_run_env_blocklist_override_rejected(self):
|
|
"""_make_run_env must NOT expose a blocklisted var to subprocess env
|
|
even after a skill attempts to register it via passthrough."""
|
|
from tools.environments.local import _make_run_env
|
|
from tools.environments.local_env_policy import _HERMES_PROVIDER_ENV_BLOCKLIST
|
|
|
|
blocked_var = next(iter(_HERMES_PROVIDER_ENV_BLOCKLIST))
|
|
os.environ[blocked_var] = "secret_value"
|
|
try:
|
|
# Without passthrough — blocked
|
|
result_before = _make_run_env({})
|
|
assert blocked_var not in result_before
|
|
|
|
# Skill tries to register it — must be refused, so still blocked
|
|
register_env_passthrough([blocked_var])
|
|
result_after = _make_run_env({})
|
|
assert blocked_var not in result_after
|
|
finally:
|
|
os.environ.pop(blocked_var, None)
|
|
|
|
def test_non_hermes_api_key_still_registerable(self):
|
|
"""Third-party API keys (TENOR_API_KEY, NOTION_TOKEN, etc.) are NOT
|
|
Hermes provider credentials and must still pass through — skills
|
|
that legitimately wrap third-party APIs must keep working."""
|
|
# TENOR_API_KEY is a real example — used by the gif-search skill
|
|
register_env_passthrough(["TENOR_API_KEY"])
|
|
assert is_env_passthrough("TENOR_API_KEY")
|
|
|
|
# Arbitrary skill-specific var
|
|
register_env_passthrough(["MY_SKILL_CUSTOM_CONFIG"])
|
|
assert is_env_passthrough("MY_SKILL_CUSTOM_CONFIG")
|
|
|
|
def test_provider_blocklist_import_failure_fails_closed(self, monkeypatch):
|
|
"""If the dynamic provider blocklist can't be imported, provider
|
|
credentials must be treated as protected and refused passthrough —
|
|
otherwise a skill could tunnel a Hermes credential into the
|
|
execute_code child (regression for #37950 / GHSA-rhgp-j443-p4rf).
|
|
|
|
Verifies the full path: _is_hermes_provider_credential returns True,
|
|
register_env_passthrough refuses the var, and _scrub_child_env keeps
|
|
it out of the child env. A non-Hermes key is also rejected here (the
|
|
fallback is conservative: when we can't tell, we fail closed), which
|
|
is the safe direction.
|
|
"""
|
|
import builtins
|
|
|
|
from tools.code_execution_env import _scrub_child_env
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def fail_local_import(name, *args, **kwargs):
|
|
if name == "tools.environments.local":
|
|
raise ImportError("synthetic blocklist import failure")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fail_local_import)
|
|
|
|
# Every name is now treated as a protected provider credential.
|
|
assert _ep_mod._is_hermes_provider_credential("OPENAI_API_KEY")
|
|
assert _ep_mod._is_hermes_provider_credential("ANTHROPIC_API_KEY")
|
|
assert _ep_mod._is_hermes_provider_credential("GH_TOKEN")
|
|
|
|
# Registration is refused while the blocklist is unavailable.
|
|
register_env_passthrough(["OPENAI_API_KEY", "ANTHROPIC_API_KEY"])
|
|
assert not is_env_passthrough("OPENAI_API_KEY")
|
|
assert not is_env_passthrough("ANTHROPIC_API_KEY")
|
|
|
|
# And the credential never reaches the execute_code child.
|
|
child_env = _scrub_child_env(
|
|
{
|
|
"OPENAI_API_KEY": "synthetic-secret",
|
|
"ANTHROPIC_API_KEY": "synthetic-secret",
|
|
"PATH": "/usr/bin",
|
|
},
|
|
is_passthrough=is_env_passthrough,
|
|
is_windows=False,
|
|
)
|
|
assert "OPENAI_API_KEY" not in child_env
|
|
assert "ANTHROPIC_API_KEY" not in child_env
|
|
assert child_env["PATH"] == "/usr/bin"
|