"""Tests for subprocess env sanitization in LocalEnvironment.

Verifies that Hermes-managed provider, tool, and gateway env vars are
stripped from subprocess environments so external CLIs are not silently
misrouted or handed Hermes secrets.

See: https://github.com/NousResearch/hermes-agent/issues/1002
See: https://github.com/NousResearch/hermes-agent/issues/1264
"""

import os
import subprocess
import sys
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from tools.environments.local import (
    LocalEnvironment,
    _HERMES_PROVIDER_ENV_BLOCKLIST,
    _HERMES_PROVIDER_ENV_FORCE_PREFIX,
)


def _running_venv_site_packages() -> Path:
    """Independently construct the host-native venv site-packages path."""
    if sys.platform == "win32":
        return Path(sys.prefix) / "Lib" / "site-packages"
    pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
    return Path(sys.prefix) / "lib" / pyver / "site-packages"


def _make_fake_popen(captured: dict):
    """Return a fake Popen constructor that records the env kwarg."""
    def fake_popen(cmd, **kwargs):
        captured["env"] = kwargs.get("env", {})
        proc = MagicMock()
        proc.poll.return_value = 0
        proc.returncode = 0
        proc.stdout = MagicMock(__iter__=lambda s: iter([]), __next__=lambda s: (_ for _ in ()).throw(StopIteration))
        proc.stdin = MagicMock()
        return proc
    return fake_popen


def _run_with_env(extra_os_env=None, self_env=None):
    """Execute a command via LocalEnvironment with mocked Popen
    and return the env dict passed to the subprocess."""
    captured = {}
    fake_interrupt = threading.Event()
    test_environ = {
        "PATH": "/usr/bin:/bin",
        "HOME": "/home/user",
        "USER": "testuser",
    }
    if extra_os_env:
        test_environ.update(extra_os_env)

    env = LocalEnvironment(cwd="/tmp", timeout=10, env=self_env)

    with patch("tools.environments.local._find_bash", return_value="/bin/bash"), \
         patch("subprocess.Popen", side_effect=_make_fake_popen(captured)), \
         patch("tools.terminal_tool._interrupt_event", fake_interrupt), \
         patch.dict(os.environ, test_environ, clear=True):
        env.execute("echo hello")

    return captured.get("env", {})


class TestProviderEnvBlocklist:
    """Provider env vars loaded from ~/.hermes/.env must not leak."""

    def test_blocked_vars_are_stripped(self):
        """OPENAI_BASE_URL and other provider vars must not appear in subprocess env."""
        leaked_vars = {
            "OPENAI_BASE_URL": "http://localhost:8000/v1",
            "OPENAI_API_KEY": "sk-fake-key",
            "OPENROUTER_API_KEY": "or-fake-key",
            "ANTHROPIC_API_KEY": "ant-fake-key",
            "LLM_MODEL": "anthropic/claude-opus-4-6",
        }
        result_env = _run_with_env(extra_os_env=leaked_vars)

        for var in leaked_vars:
            assert var not in result_env, f"{var} leaked into subprocess env"

    def test_registry_derived_vars_are_stripped(self):
        """Vars from the provider registry (ANTHROPIC_TOKEN, ZAI_API_KEY, etc.)
        must also be blocked — not just the hand-written extras."""
        registry_vars = {
            "ANTHROPIC_TOKEN": "ant-tok",
            "ZAI_API_KEY": "zai-key",
            "Z_AI_API_KEY": "z-ai-key",
            "GLM_API_KEY": "glm-key",
            "KIMI_API_KEY": "kimi-key",
            "MINIMAX_API_KEY": "mm-key",
            "MINIMAX_CN_API_KEY": "mmcn-key",
            "DEEPSEEK_API_KEY": "deepseek-key",
            "NVIDIA_API_KEY": "nvidia-key",
        }
        result_env = _run_with_env(extra_os_env=registry_vars)

        for var in registry_vars:
            assert var not in result_env, f"{var} leaked into subprocess env"

    def test_bedrock_bearer_token_is_stripped(self):
        """The Bedrock-specific bearer token is a Hermes inference secret
        (analogous to OPENAI_API_KEY) and must not leak into subprocesses.

        Regression for #32314: AWS_BEARER_TOKEN_BEDROCK leaked into terminal /
        execute_code children because the ``bedrock`` ProviderConfig declares
        ``api_key_env_vars=()`` (auth_type="aws_sdk") and the blocklist builder
        only consulted that field. The reporter caught it when ``opencode
        models`` run inside a Hermes terminal enumerated the entire Bedrock
        catalog off the leaked bearer token.
        """
        result_env = _run_with_env(extra_os_env={
            "AWS_BEARER_TOKEN_BEDROCK": "bedrock-bearer-secret",
        })

        assert "AWS_BEARER_TOKEN_BEDROCK" not in result_env, (
            "AWS_BEARER_TOKEN_BEDROCK leaked into subprocess env (see #32314)"
        )

    def test_vertex_credentials_path_is_stripped(self):
        """The Vertex AI service-account JSON path must not leak into
        subprocesses, even though it is filesystem path metadata rather
        than a bare API key.

        Regression: ``vertex`` authenticates via OAuth2 (service-account
        JSON / ADC), not PROVIDER_REGISTRY, and OPTIONAL_ENV_VARS marks
        VERTEX_CREDENTIALS_PATH as ``password=False`` (it's a path, not a
        secret string) with ``category="provider"`` — a category the
        registry-derived loop above never checks — so it fell through both
        blocklist sources. GOOGLE_APPLICATION_CREDENTIALS (the ADC fallback
        the adapter also reads) had the same gap. A leaked path discloses
        the on-disk location of a GCP service-account key to every spawned
        subprocess (terminal, codex/copilot app-server, browser workers).
        """
        result_env = _run_with_env(extra_os_env={
            "VERTEX_CREDENTIALS_PATH": "/home/user/.config/gcloud/sa-key.json",
            "GOOGLE_APPLICATION_CREDENTIALS": "/home/user/.config/gcloud/adc.json",
        })

        assert "VERTEX_CREDENTIALS_PATH" not in result_env
        assert "GOOGLE_APPLICATION_CREDENTIALS" not in result_env

    def test_general_aws_credential_chain_is_preserved(self):
        """The GENERAL AWS credential chain must STILL pass through to
        subprocesses — this is the no-regression guard for #32314.

        Per SECURITY.md §3.2 the local terminal is the user's trusted operator
        shell. A user running ``aws``/``terraform``/``cdk``/``boto3`` in the
        agent terminal must keep the same AWS access their own shell has.
        Stripping these would (a) break every user who does AWS work in the
        agent terminal — not just Bedrock users, since the registry is iterated
        unconditionally — and (b) be unrecoverable, because env_passthrough.py
        refuses to re-allow anything in _HERMES_PROVIDER_ENV_BLOCKLIST
        (GHSA-rhgp-j443-p4rf). Only the Bedrock inference bearer token is
        Hermes-managed; the rest belongs to the user.
        """
        general_chain = {
            "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE",
            "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
            "AWS_SESSION_TOKEN": "session-token",
            "AWS_PROFILE": "production",
            "AWS_DEFAULT_REGION": "us-east-1",
            "AWS_REGION": "us-east-1",
            "AWS_SHARED_CREDENTIALS_FILE": "/home/user/.aws/credentials",
            "AWS_CONFIG_FILE": "/home/user/.aws/config",
            "AWS_WEB_IDENTITY_TOKEN_FILE": "/var/run/secrets/token",
            "AWS_ROLE_ARN": "arn:aws:iam::123456789012:role/example",
        }
        result_env = _run_with_env(extra_os_env=general_chain)

        for var, value in general_chain.items():
            assert result_env.get(var) == value, (
                f"{var} was stripped from subprocess env — this is a "
                f"capability regression (see #32314 discussion)"
            )

    def test_non_registry_provider_vars_are_stripped(self):
        """Extra provider vars not in PROVIDER_REGISTRY must also be blocked."""
        extra_provider_vars = {
            "GOOGLE_API_KEY": "google-key",
            "MISTRAL_API_KEY": "mistral-key",
            "GROQ_API_KEY": "groq-key",
            "TOGETHER_API_KEY": "together-key",
            "PERPLEXITY_API_KEY": "perplexity-key",
            "COHERE_API_KEY": "cohere-key",
            "FIREWORKS_API_KEY": "fireworks-key",
            "XAI_API_KEY": "xai-key",
            "HELICONE_API_KEY": "helicone-key",
        }
        result_env = _run_with_env(extra_os_env=extra_provider_vars)

        for var in extra_provider_vars:
            assert var not in result_env, f"{var} leaked into subprocess env"

    def test_tool_and_gateway_vars_are_stripped(self):
        """Tool and gateway secrets/config must not leak into subprocess env."""
        leaked_vars = {
            "TELEGRAM_BOT_TOKEN": "bot-token",
            "TELEGRAM_HOME_CHANNEL": "12345",
            "DISCORD_HOME_CHANNEL": "67890",
            "SLACK_APP_TOKEN": "xapp-secret",
            "WHATSAPP_ALLOWED_USERS": "+15555550123",
            "SIGNAL_ACCOUNT": "+15555550124",
            "HASS_TOKEN": "ha-secret",
            "EMAIL_PASSWORD": "email-secret",
            "FIRECRAWL_API_KEY": "fc-secret",
            "HERMES_DASHBOARD_SESSION_TOKEN": "dashboard-session-secret",
            "BROWSERBASE_PROJECT_ID": "bb-project",
            "ELEVENLABS_API_KEY": "el-secret",
            "GITHUB_TOKEN": "ghp_secret",
            "GH_TOKEN": "gh_alias_secret",
            "GATEWAY_ALLOW_ALL_USERS": "true",
            "GATEWAY_ALLOWED_USERS": "alice,bob",
            "MODAL_TOKEN_ID": "modal-id",
            "MODAL_TOKEN_SECRET": "modal-secret",
            "DAYTONA_API_KEY": "daytona-key",
            "VERCEL_OIDC_TOKEN": "vercel-oidc-token",
            "VERCEL_TOKEN": "vercel-token",
            "VERCEL_PROJECT_ID": "vercel-project",
            "VERCEL_TEAM_ID": "vercel-team",
        }
        result_env = _run_with_env(extra_os_env=leaked_vars)

        for var in leaked_vars:
            assert var not in result_env, f"{var} leaked into subprocess env"

    def test_safe_vars_are_preserved(self):
        """Standard env vars (PATH, HOME, USER) must still be passed through."""
        result_env = _run_with_env()

        assert "HOME" in result_env
        assert result_env["HOME"] == "/home/user"
        assert "USER" in result_env
        assert "PATH" in result_env

    def test_self_env_blocked_vars_also_stripped(self):
        """Blocked vars in self.env are stripped; non-blocked vars pass through."""
        result_env = _run_with_env(self_env={
            "OPENAI_BASE_URL": "http://custom:9999/v1",
            "MY_CUSTOM_VAR": "keep-this",
        })

        assert "OPENAI_BASE_URL" not in result_env
        assert "MY_CUSTOM_VAR" in result_env
        assert result_env["MY_CUSTOM_VAR"] == "keep-this"


class TestForceEnvOptIn:
    """Callers can opt in to passing a blocked var via _HERMES_FORCE_ prefix."""

    def test_force_prefix_passes_blocked_var(self):
        """_HERMES_FORCE_OPENAI_API_KEY in self.env should inject OPENAI_API_KEY."""
        result_env = _run_with_env(self_env={
            f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}OPENAI_API_KEY": "sk-explicit",
        })

        assert "OPENAI_API_KEY" in result_env
        assert result_env["OPENAI_API_KEY"] == "sk-explicit"
        # The force-prefixed key itself must not appear
        assert f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}OPENAI_API_KEY" not in result_env

    def test_force_prefix_overrides_os_environ_block(self):
        """Force-prefix in self.env wins even when os.environ has the blocked var."""
        result_env = _run_with_env(
            extra_os_env={"OPENAI_BASE_URL": "http://leaked/v1"},
            self_env={f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}OPENAI_BASE_URL": "http://intended/v1"},
        )

        assert result_env["OPENAI_BASE_URL"] == "http://intended/v1"


class TestActiveVenvMarkerStripping:
    """Active-virtualenv markers must not leak into terminal subprocesses (#23473).

    The gateway runs inside its own venv, so its process environment carries
    VIRTUAL_ENV (and possibly CONDA_PREFIX). If those leak into commands the
    agent runs against ANOTHER Python project, ``uv``/``poetry`` treat the
    inherited value as the active environment and build that project's deps
    into the Hermes venv path instead of the project's own ``.venv`` —
    silently clobbering the Hermes environment (and, when the other project
    pins a different Python, breaking the gateway outright). The Hermes venv
    stays reachable via PATH, so stripping the markers is safe.
    """

    def test_virtualenv_marker_stripped_end_to_end(self):
        result_env = _run_with_env(extra_os_env={
            "VIRTUAL_ENV": "/home/user/.hermes/hermes-agent/venv",
        })
        assert "VIRTUAL_ENV" not in result_env

    def test_conda_prefix_marker_stripped_end_to_end(self):
        result_env = _run_with_env(extra_os_env={
            "CONDA_PREFIX": "/opt/conda/envs/hermes",
        })
        assert "CONDA_PREFIX" not in result_env

    def test_make_run_env_strips_markers(self):
        from tools.environments.local import _make_run_env
        poison = {"VIRTUAL_ENV": "/venv", "CONDA_PREFIX": "/conda", "PATH": "/usr/bin"}
        with patch.dict(os.environ, poison, clear=True):
            result = _make_run_env({})
        assert "VIRTUAL_ENV" not in result
        assert "CONDA_PREFIX" not in result

    def test_sanitize_subprocess_env_strips_markers(self):
        from tools.environments.local import _sanitize_subprocess_env
        base = {"VIRTUAL_ENV": "/venv", "CONDA_PREFIX": "/conda", "HOME": "/home/user"}
        # Even an explicitly-passed extra marker is stripped.
        result = _sanitize_subprocess_env(base, {"VIRTUAL_ENV": "/also/venv"})
        assert "VIRTUAL_ENV" not in result
        assert "CONDA_PREFIX" not in result
        assert result.get("HOME") == "/home/user"

    def test_markers_constant_contents(self):
        from tools.environments.local import _ACTIVE_VENV_MARKER_VARS
        assert "VIRTUAL_ENV" in _ACTIVE_VENV_MARKER_VARS
        assert "CONDA_PREFIX" in _ACTIVE_VENV_MARKER_VARS


def _make_directory_link(link: Path, target: Path) -> None:
    """Create a directory link without requiring symlink privileges.

    POSIX: Path.symlink_to.  Windows: try symlink_to first (works with
    Developer Mode enabled), then fall back to an unprivileged directory
    junction via `cmd /c mklink /J` -- junctions do not require the
    SeCreateSymbolicLinkPrivilege.  Raises the original error when no
    mechanism is available so callers can skip with a clear reason.
    """
    try:
        link.symlink_to(target, target_is_directory=True)
        return
    except OSError:
        if sys.platform != "win32":
            raise
    # Binary capture: on a localized Windows the junction message is in the
    # console code page (e.g. GBK), which would raise UnicodeDecodeError in
    # the reader thread under UTF-8 mode.  Only the exit code matters.
    result = subprocess.run(
        ["cmd", "/c", "mklink", "/J", str(link), str(target)],
        capture_output=True,
    )
    if result.returncode != 0:
        detail = result.stderr.decode("utf-8", errors="replace").strip()
        raise OSError(detail or f"mklink /J failed: {result.returncode}")


def _physical_repo_root(tmp_path: Path) -> Path:
    """Create the physical repo checkout directory for junction tests."""
    physical_root = tmp_path / "physical-home" / "hermes-agent"
    physical_root.mkdir(parents=True)
    return physical_root


class TestPythonpathSelectiveStrip:
    """PYTHONPATH Hermes-owned entry stripping (#74817).

    The Desktop Electron app injects the Hermes repo root and the Hermes
    venv's site-packages (Python 3.11) into PYTHONPATH.  When this leaks
    into subprocesses running a different Python (e.g. 3.13), 3.11 C
    extensions appear on sys.path and crash with ImportError.
    ``_strip_hermes_owned_pythonpath`` surgically removes only the
    entries Hermes itself owns (repo root, own venv site-packages),
    preserving user paths — including user paths whose names merely
    contain another Python version.
    """

    def test_owned_entries_stripped_matrix(self):
        """Exact Hermes-owned entries are removed; everything else survives
        verbatim (ordering, duplicates, empty components).

        Covers: the running venv's site-packages, the repo root (computed
        independently via parents[2] so an off-by-one in _hermes_repo_root
        cannot silently pass), duplicate Hermes entries, all-owned input
        (PYTHONPATH key removed), and mixed user/Hermes ordering with an
        empty component preserved.
        """
        from tools.environments.local import _strip_hermes_owned_pythonpath

        venv_sp = str(_running_venv_site_packages())
        local_file = Path(__import__("tools.environments.local", fromlist=["__file__"]).__file__).resolve()
        repo_root = str(local_file.parents[2])
        cases = [
            ([venv_sp, "/home/user/my-lib"], ["/home/user/my-lib"]),
            ([repo_root, "/home/user/my-lib"], ["/home/user/my-lib"]),
            ([venv_sp, "/user/lib", venv_sp, "/user/lib"], ["/user/lib", "/user/lib"]),
            ([venv_sp], None),  # all owned -> PYTHONPATH key removed
            (["/first/user/lib", repo_root, "", venv_sp, "/second/user/lib"],
             ["/first/user/lib", "", "/second/user/lib"]),
        ]
        for input_entries, expected in cases:
            env = {"PYTHONPATH": os.pathsep.join(input_entries)}
            _strip_hermes_owned_pythonpath(env)
            if expected is None:
                assert "PYTHONPATH" not in env
            else:
                assert env["PYTHONPATH"].split(os.pathsep) == expected

    @pytest.mark.parametrize("user_pp", [
        os.pathsep.join(["/opt/my-lib", "/another/path"]),
        "/nix/store/abc123-user-plugin/lib/python3.12/site-packages",
        os.pathsep.join(["/old/lib/python2.7/site-packages", "/home/user/lib"]),
        os.pathsep.join(["/opt/tools/python3.13/bin", "/opt/downloads/python3.13", "/custom/python3.13"]),
        os.pathsep.join([" /opt/user-lib ", "relative/../lib", "", "/opt/user-lib", "/opt/user-lib"]),
        os.pathsep.join(["/foo", "", "/bar"]),
        "",
    ])
    def test_non_owned_entries_preserved(self, user_pp):
        """Anything not proven Hermes-owned is preserved byte-for-byte.

        One invariant, one matrix: ordinary user paths, Nix store paths,
        other-major/minor-version site-packages, paths merely containing a
        pythonX.Y component, raw spellings (whitespace, relative segments,
        duplicates), empty components, and an empty PYTHONPATH all reduce to
        the same contract -- ownership is decided by provenance, never by
        path shape or version (P1/P2, #74817 follow-ups).
        """
        from tools.environments.local import _strip_hermes_owned_pythonpath
        env = {"PYTHONPATH": user_pp}
        _strip_hermes_owned_pythonpath(env)
        assert env.get("PYTHONPATH") == user_pp

    def test_non_owned_runtime_shaped_entries_preserved(self):
        """Runtime-derived user spellings are preserved: site-packages for a
        different interpreter version, a descendant of the Hermes venv
        site-packages, and direct/deeper children of the repo root.  The
        repo root is computed independently (parents[2] of this file) so an
        off-by-one in _hermes_repo_root cannot silently pass; no launcher
        injects a direct child as a standalone entry, so such paths are user
        paths by contract.
        """
        from tools.environments.local import _strip_hermes_owned_pythonpath
        import sys

        running_minor = sys.version_info[1]
        other_minor = running_minor + 1 if running_minor < 20 else running_minor - 1
        local_file = Path(__import__("tools.environments.local", fromlist=["__file__"]).__file__).resolve()
        real_repo_root = local_file.parents[2]
        inputs = [
            os.pathsep.join([
                f"/opt/other-venv/lib/python{sys.version_info[0]}.{other_minor}/site-packages",
                "/home/user/my-lib",
            ]),
            os.pathsep.join([str(_running_venv_site_packages() / "some-user-path"), "/home/user/my-lib"]),
            os.pathsep.join([str(real_repo_root / "tools"), "/home/user/my-lib"]),
            os.pathsep.join([str(real_repo_root / "tools" / "environments"), "/home/user/my-lib"]),
        ]
        for user_pp in inputs:
            env = {"PYTHONPATH": user_pp}
            _strip_hermes_owned_pythonpath(env)
            assert env["PYTHONPATH"] == user_pp

    def test_windows_backslash_paths(self):
        """Windows-style backslash paths are handled for Hermes-owned entries.

        On Windows, os.pathsep is ';'.  We mock it so the test runs
        correctly on POSIX CI.  On a POSIX host a backslash path is a
        single path component, so ``Path`` cannot identify it as
        Hermes-owned — the critical invariant is that user Windows paths
        (including site-packages paths for another Python version) are
        never destroyed.  On a real Windows host, Path splits on
        backslashes and Hermes venv site-packages entries are stripped
        by the same Hermes-owned check (covered by the Windows-only test
        below).
        """
        from tools.environments.local import _strip_hermes_owned_pythonpath
        import sys

        pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
        hermes_win = f"C:\\\\Users\\\\u\\\\.hermes\\\\hermes-agent\\\\venv\\\\lib\\\\{pyver}\\\\site-packages"
        user_win = "D:\\\\user\\\\lib"
        env = {
            "PYTHONPATH": ";".join([hermes_win, user_win]),
        }
        # Mock os.pathsep to ';' (Windows) just for the strip call.
        with patch("os.pathsep", ";"):
            _strip_hermes_owned_pythonpath(env)
        assert "PYTHONPATH" in env
        entries = env["PYTHONPATH"].split(";")
        # Both survive on POSIX: user paths must always be preserved, and
        # the Hermes-owned check cannot match a backslash path here.
        assert hermes_win in entries
        assert user_win in entries

    @pytest.mark.windows_only
    def test_windows_hermes_owned_paths_stripped(self):
        """On Windows, a Hermes venv site-packages entry written with
        backslashes is stripped by the same Hermes-owned check, while a
        user Windows path is preserved.  Windows-only: POSIX ``Path`` does
        not split on backslashes, so this cannot be meaningfully simulated
        on a POSIX host."""
        from tools.environments.local import _strip_hermes_owned_pythonpath

        venv_sp = str(_running_venv_site_packages())
        # Windows form: C:\...\venv\Lib\site-packages (backslashes)
        hermes_win = venv_sp
        user_win = "D:\\\\user\\\\lib"
        env = {
            "PYTHONPATH": ";".join([hermes_win, user_win]),
        }
        _strip_hermes_owned_pythonpath(env)
        entries = env["PYTHONPATH"].split(";")
        assert hermes_win not in entries
        assert user_win in entries

    def test_empty_pythonpath_unchanged(self):
        """An empty PYTHONPATH is a no-op (falsy -> early return)."""
        from tools.environments.local import _strip_hermes_owned_pythonpath
        env = {"PYTHONPATH": ""}
        _strip_hermes_owned_pythonpath(env)
        # Empty string is falsy, so the function returns early without
        # modifying the dict.  The key stays as-is (empty string).
        assert env.get("PYTHONPATH") == ""

    def test_empty_component_preserved(self):
        """An empty component means cwd and must survive unchanged."""
        from tools.environments.local import _strip_hermes_owned_pythonpath

        user_pp = os.pathsep.join(["/foo", "", "/bar"])
        env = {"PYTHONPATH": user_pp}

        _strip_hermes_owned_pythonpath(env)

        assert env["PYTHONPATH"] == user_pp

    def test_raw_user_spelling_preserved(self):
        """The sanitizer does not trim, normalize, or deduplicate user entries."""
        from tools.environments.local import _strip_hermes_owned_pythonpath

        user_pp = os.pathsep.join([
            " /opt/user-lib ",
            "relative/../lib",
            "",
            "/opt/user-lib",
            "/opt/user-lib",
        ])
        env = {"PYTHONPATH": user_pp}

        _strip_hermes_owned_pythonpath(env)

        assert env["PYTHONPATH"] == user_pp


    def test_base_python_sanitizer_uses_validated_separate_runtime_venv(self, tmp_path, monkeypatch):
        """A base interpreter strips the exact Windows runtime site-packages.

        This deliberately uses a synthetic Hermes venv separate from the test
        runner: sys.prefix represents base Python, while validated VIRTUAL_ENV
        identifies ``<repo>/venv`` as the Hermes runtime producer contract.
        """
        import tools.environments.local as local

        repo_root = tmp_path / "hermes-agent"
        runtime_venv = repo_root / "venv"
        runtime_sp = runtime_venv / "Lib" / "site-packages"
        runtime_sp.mkdir(parents=True)
        (runtime_venv / "pyvenv.cfg").write_text("version = 3.11\n", encoding="utf-8")
        base_prefix = tmp_path / "base-python"
        unrelated = "/custom/lib/python3.13/site-packages"

        monkeypatch.setattr(local, "_hermes_repo_root_aliases", (repo_root,))
        monkeypatch.setattr(local, "_in_venv", False)
        monkeypatch.setattr(local, "_hermes_site_packages", None)
        monkeypatch.setattr(local.sys, "prefix", str(base_prefix))
        monkeypatch.setattr(local.sys, "base_prefix", str(base_prefix))

        env = {
            "VIRTUAL_ENV": str(runtime_venv),
            "PYTHONPATH": os.pathsep.join([str(runtime_sp), unrelated]),
        }
        result = local._sanitize_subprocess_env(env)

        assert Path(local.sys.prefix) == base_prefix
        assert runtime_venv != Path(local.sys.prefix)
        assert result["PYTHONPATH"] == unrelated
        assert "VIRTUAL_ENV" not in result

    def test_unrelated_virtual_env_is_not_runtime_provenance(self, tmp_path, monkeypatch):
        """An arbitrary inherited VIRTUAL_ENV cannot claim PYTHONPATH ownership."""
        import tools.environments.local as local

        repo_root = tmp_path / "hermes-agent"
        repo_root.mkdir()
        unrelated_venv = tmp_path / "user-venv"
        unrelated_sp = unrelated_venv / "Lib" / "site-packages"
        unrelated_sp.mkdir(parents=True)
        (unrelated_venv / "pyvenv.cfg").write_text("version = 3.13\n", encoding="utf-8")

        monkeypatch.setattr(local, "_hermes_repo_root_aliases", (repo_root,))
        monkeypatch.setattr(local, "_in_venv", False)
        monkeypatch.setattr(local, "_hermes_site_packages", None)

        env = {
            "VIRTUAL_ENV": str(unrelated_venv),
            "PYTHONPATH": str(unrelated_sp),
        }
        local._strip_hermes_owned_pythonpath(env)

        assert env["PYTHONPATH"] == str(unrelated_sp)


    def test_no_pythonpath_key(self):
        """Missing PYTHONPATH key is a no-op."""
        from tools.environments.local import _strip_hermes_owned_pythonpath
        env = {"PATH": "/usr/bin"}
        _strip_hermes_owned_pythonpath(env)
        assert "PYTHONPATH" not in env


    @pytest.mark.parametrize("builder", [
        "_make_run_env",
        "_sanitize_subprocess_env",
        "hermes_subprocess_env",
    ])
    def test_builders_strip_hermes_venv_pythonpath(self, builder):
        """Every subprocess env builder applies the same sanitation contract:
        Hermes venv site-packages is stripped, user entries survive.
        """
        from tools.environments import local as local_mod

        venv_sp = str(_running_venv_site_packages())
        seed = {
            "PATH": "/usr/bin:/bin",
            "HOME": "/home/user",
            "PYTHONPATH": os.pathsep.join([venv_sp, "/home/user/my-lib"]),
        }
        with patch.dict(os.environ, seed, clear=True):
            if builder == "_make_run_env":
                result = local_mod._make_run_env({})
            elif builder == "_sanitize_subprocess_env":
                result = local_mod._sanitize_subprocess_env(dict(os.environ))
            else:
                result = local_mod.hermes_subprocess_env()
        pp = result.get("PYTHONPATH", "")
        entries = pp.split(os.pathsep) if pp else []
        assert venv_sp not in entries
        assert "/home/user/my-lib" in entries

    def test_scrub_child_env_strips_hermes_venv_pythonpath(self):
        """execute_code's _scrub_child_env path: after scrubbing, Hermes venv
        site-packages entries should be stripped when
        _strip_hermes_owned_pythonpath is applied (as the spawn path does),
        while user entries (even for another Python version) are preserved.
        """
        from tools.code_execution_tool import _scrub_child_env
        from tools.environments.local import _strip_hermes_owned_pythonpath

        venv_sp = str(_running_venv_site_packages())
        other_sp = "/opt/other-venv/lib/python3.99/site-packages"
        source = {
            "PATH": "/usr/bin",
            "HOME": "/home/user",
            "PYTHONPATH": os.pathsep.join([venv_sp, other_sp, "/home/user/my-lib"]),
        }
        scrubbed = _scrub_child_env(source)
        # The scrubber passes PYTHONPATH through (it's in _SAFE_ENV_PREFIXES).
        assert "PYTHONPATH" in scrubbed
        # Now apply the selective strip (as the spawn path does).
        _strip_hermes_owned_pythonpath(scrubbed)
        pp = scrubbed.get("PYTHONPATH", "")
        entries = pp.split(os.pathsep) if pp else []
        assert venv_sp not in entries
        assert other_sp in entries
        assert "/home/user/my-lib" in entries

    @pytest.mark.parametrize("same_env", [True, False])
    def test_execute_code_composition_strips_inherited_hermes_entries(self, same_env):
        """Integration: execute_code's real spawn path composes a clean PYTHONPATH.

        Seeds a contaminated inherited PYTHONPATH (Hermes repo root + Hermes
        venv site-packages + user entries) through os.environ and drives
        execute_code all the way to Popen.  Proves the #84500 conditional
        composition and the #82581 selective strip compose correctly:

        * inherited Hermes venv site-packages never survive into the sandbox;
        * the staging tmpdir stays the first entry;
        * the repo root is deliberately re-added exactly once for a same-env
          child (the single occurrence proves the inherited copy was stripped
          first) and stays absent for an external-environment child;
        * user entries survive after the controlled entries.
        """
        import tools.code_execution_tool as cet
        from tools.code_execution_tool import execute_code

        def _mock_handle_function_call(function_name, function_args, task_id=None, user_task=None):
            return '{"output": "mock", "exit_code": 0}'

        hermes_root = str(Path(cet.__file__).resolve().parents[1])
        venv_sp = str(_running_venv_site_packages())
        user_a = "/home/user/my-lib"
        user_b = "/opt/project/lib"
        captured = {}

        def _fake_popen(cmd, **kwargs):
            captured["env"] = kwargs.get("env", {})
            captured["staging"] = os.path.dirname(cmd[1])
            proc = MagicMock()
            proc.stdout.read.return_value = b""
            proc.stderr.read.return_value = b""
            proc.wait.return_value = 0
            proc.returncode = 0
            proc.poll.return_value = 0
            return proc

        with patch("tools.code_execution_tool._load_config",
                   return_value={"mode": "strict"}), \
             patch("model_tools.handle_function_call",
                   side_effect=_mock_handle_function_call), \
             patch("tools.code_execution_tool._uses_hermes_python_environment",
                   return_value=same_env), \
             patch("subprocess.Popen", side_effect=_fake_popen), \
             patch.dict(os.environ, {
                 "PYTHONPATH": os.pathsep.join(
                     [hermes_root, venv_sp, user_a, user_b]),
             }):
            execute_code(code="pass", task_id="test-int", enabled_tools=[])

        assert "PYTHONPATH" in captured["env"], \
            "execute_code never reached Popen"
        parts = captured["env"]["PYTHONPATH"].split(os.pathsep)
        # Windows path comparison is case-insensitive: the inherited entries
        # and the re-added repo root can carry a different case than the
        # resolve()/abspath()-derived spellings used in this test (e.g. a
        # launcher-written lowercase PYTHONPATH).  Normalize with
        # os.path.normcase so a case-only difference never fails the
        # composition contract (identity on POSIX).
        norm_parts = [os.path.normcase(p) for p in parts]
        norm_staging = os.path.normcase(captured["staging"])
        norm_root = os.path.normcase(hermes_root)
        norm_venv = os.path.normcase(venv_sp)
        norm_user_a = os.path.normcase(user_a)
        norm_user_b = os.path.normcase(user_b)
        assert norm_parts[0] == norm_staging, \
            "staging tmpdir must be the first PYTHONPATH entry"
        assert norm_venv not in norm_parts, \
            "inherited Hermes venv site-packages must be stripped"
        assert norm_user_a in norm_parts and norm_user_b in norm_parts, \
            "user PYTHONPATH entries must survive"
        assert norm_parts.index(norm_user_a) > norm_parts.index(norm_staging), \
            "user entries must come after the staging tmpdir"
        if same_env:
            assert norm_parts.count(norm_root) == 1, \
                "repo root must be re-added exactly once for a same-env child"
            assert norm_parts.index(norm_user_a) > norm_parts.index(norm_root), \
                "user entries must come after the re-added repo root"
        else:
            assert norm_root not in norm_parts, \
                "repo root must stay absent for an external-env child"


    def test_repo_root_direct_child_preserved(self):
        """A direct child of the repo root (depth=1) is PRESERVED.

        Independent audit of every real launcher producer (Electron
        ``apps/desktop/electron/main.ts``,
        ``gateway/run.py::_ensure_windows_gateway_venv_imports``,
        ``cron/scheduler.py::_windows_cron_python_invocation``,
        ``tui_gateway/host_supervisor.py``) shows they all inject the exact
        repo root and/or the venv site-packages — none injects
        ``<repo>/tools`` or another direct child as an independent
        PYTHONPATH entry.  A user path that merely happens to live under
        the repo directory must therefore be preserved.
        """
        from tools.environments.local import _strip_hermes_owned_pythonpath

        local_file = Path(__import__("tools.environments.local", fromlist=["__file__"]).__file__).resolve()
        real_repo_root = local_file.parents[2]
        direct_child = str(real_repo_root / "tools")

        env = {
            "PYTHONPATH": os.pathsep.join([direct_child, "/home/user/my-lib"]),
        }
        _strip_hermes_owned_pythonpath(env)
        pp = env.get("PYTHONPATH", "")
        entries = pp.split(os.pathsep) if pp else []
        assert direct_child in entries
        assert "/home/user/my-lib" in entries

    def test_configured_home_alias_matches_launcher_output(self, tmp_path, monkeypatch):
        """The real producer spelling is derived and consumed end to end."""
        import tools.environments.local as local
        from hermes_cli.gateway_windows import _preserve_hermes_home_path

        physical_home = tmp_path / "physical-home"
        physical_root = _physical_repo_root(tmp_path)
        configured_home = tmp_path / "configured-home"
        try:
            _make_directory_link(configured_home, physical_home)
        except OSError as exc:
            pytest.skip(f"directory link unavailable on this host: {exc}")
        monkeypatch.setenv("HERMES_HOME", str(configured_home))

        launcher_entry = Path(_preserve_hermes_home_path(physical_root))
        aliases = local._build_hermes_repo_root_aliases(
            physical_root.resolve(),
            physical_root,
            configured_home,
        )

        assert launcher_entry == configured_home / "hermes-agent"
        assert launcher_entry in aliases

        monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
        nested_user_path = launcher_entry / "user-data"
        env = {
            "PYTHONPATH": os.pathsep.join([
                str(launcher_entry),
                str(nested_user_path),
                "/home/user/my-lib",
            ])
        }
        local._strip_hermes_owned_pythonpath(env)

        assert env["PYTHONPATH"].split(os.pathsep) == [
            str(nested_user_path),
            "/home/user/my-lib",
        ]

    def test_profile_rehome_keeps_junction_lexical_alias(self, tmp_path, monkeypatch):
        """Profile re-home must not lose the launcher's lexical repo-root spelling.

        The desktop/CLI spawn children with HERMES_HOME and PYTHONPATH in the
        configured (junction) spelling, but --profile / sticky active_profile
        re-home HERMES_HOME through resolve_profile_env() before the
        sanitizer loads.  Regression (junction + profile re-home): the alias
        builder must still recover the lexical root so the inherited lexical
        repo-root entry is stripped.
        """
        import tools.environments.local as local
        from hermes_cli.profiles import resolve_profile_env

        physical_home = tmp_path / "physical-home"
        physical_root = physical_home / "hermes-agent"
        physical_root.mkdir(parents=True)
        (physical_home / "profiles" / "coder").mkdir(parents=True)
        configured_home = tmp_path / "configured-home"
        try:
            _make_directory_link(configured_home, physical_home)
        except OSError as exc:
            pytest.skip(f"directory link unavailable on this host: {exc}")

        # Launcher contract: the configured spelling is the env and the root.
        monkeypatch.setenv("HERMES_HOME", str(configured_home))
        lexical_root = configured_home / "hermes-agent"

        # Profile re-home keeps the configured spelling (physically identical
        # through the link; lexically the launcher spelling is preserved).
        assert Path(resolve_profile_env("default")) == configured_home
        assert Path(resolve_profile_env("coder")) == configured_home / "profiles" / "coder"

        # The sanitizer now runs under the re-homed (profile) HERMES_HOME.
        aliases = local._build_hermes_repo_root_aliases(
            physical_root.resolve(),
            physical_root,
            configured_home / "profiles" / "coder",
        )
        assert any(local._same_path(a, lexical_root) for a in aliases)

        monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
        env = {"PYTHONPATH": os.pathsep.join([str(lexical_root), "/home/user/my-lib"])}
        local._strip_hermes_owned_pythonpath(env)
        assert env["PYTHONPATH"].split(os.pathsep) == ["/home/user/my-lib"]


    def test_repo_level_junction_recovers_lexical_alias(self, tmp_path, monkeypatch):
        """The repo itself may be a junction under the configured root
        (e.g. D:\\hermes\\hermes-agent -> C:\\...\\hermes-agent) while the
        editable import spelling resolves to the physical location.  The
        alias builder must recover the lexical spelling via exact-identity
        proof (strict resolve), not a name-based guess.
        """
        import tools.environments.local as local

        physical_root = _physical_repo_root(tmp_path)
        configured_home = tmp_path / "configured-home"
        configured_home.mkdir()
        # repo-level link: <configured-home>/hermes-agent -> physical repo
        try:
            _make_directory_link(configured_home / "hermes-agent", physical_root)
        except OSError as exc:
            pytest.skip(f"directory link unavailable on this host: {exc}")

        lexical_root = configured_home / "hermes-agent"
        aliases = local._build_hermes_repo_root_aliases(
            physical_root.resolve(),
            physical_root,
            configured_home,
        )
        assert any(local._same_path(a, lexical_root) for a in aliases)

        monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
        env = {"PYTHONPATH": os.pathsep.join([str(lexical_root), "/home/user/my-lib"])}
        local._strip_hermes_owned_pythonpath(env)
        assert env["PYTHONPATH"].split(os.pathsep) == ["/home/user/my-lib"]

    def test_same_named_non_owned_directories_preserved(self, tmp_path, monkeypatch):
        """Negative controls: a directory that merely shares the repo's name
        -- whether under the configured root or in an unrelated location --
        is never aliased or stripped.  Exact filesystem identity decides,
        not the name; no ownership provenance means no strip.
        """
        import tools.environments.local as local

        physical_root = _physical_repo_root(tmp_path)
        configured_home = tmp_path / "configured-home"
        (configured_home / "hermes-agent").mkdir(parents=True)
        unrelated = tmp_path / "user-tools" / "hermes-agent"
        unrelated.mkdir(parents=True)

        aliases = local._build_hermes_repo_root_aliases(
            physical_root.resolve(),
            physical_root,
            configured_home,
        )
        for lookalike in (configured_home / "hermes-agent", unrelated):
            assert not any(local._same_path(a, lookalike) for a in aliases)

        monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
        for lookalike in (configured_home / "hermes-agent", unrelated):
            env = {"PYTHONPATH": os.pathsep.join([str(lookalike), "/home/user/my-lib"])}
            local._strip_hermes_owned_pythonpath(env)
            assert env["PYTHONPATH"].split(os.pathsep) == [str(lookalike), "/home/user/my-lib"]

    def test_profile_home_with_repo_level_junction(self, tmp_path, monkeypatch):
        """Profile re-home + repo-level junction together: the configured home
        is <root>/profiles/<name> while the repo is a link at <root>/hermes-agent.
        The root spelling must be derived (profiles -> grandparent) and then
        the lexical repo alias recovered from it.
        """
        import tools.environments.local as local

        physical_root = _physical_repo_root(tmp_path)
        configured_root = tmp_path / "configured-root"
        (configured_root / "profiles" / "coder").mkdir(parents=True)
        try:
            _make_directory_link(configured_root / "hermes-agent", physical_root)
        except OSError as exc:
            pytest.skip(f"directory link unavailable on this host: {exc}")

        configured_home = configured_root / "profiles" / "coder"
        lexical_root = configured_root / "hermes-agent"
        aliases = local._build_hermes_repo_root_aliases(
            physical_root.resolve(),
            physical_root,
            configured_home,
        )
        assert any(local._same_path(a, lexical_root) for a in aliases)
        assert not any(local._same_path(a, configured_home / "hermes-agent") for a in aliases)

        monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
        env = {"PYTHONPATH": os.pathsep.join([str(lexical_root), "/home/user/my-lib"])}
        local._strip_hermes_owned_pythonpath(env)
        assert env["PYTHONPATH"].split(os.pathsep) == ["/home/user/my-lib"]

    def test_validated_runtime_venv_lexical_after_repo_recovery(self, tmp_path, monkeypatch):
        """uv-base gateway: once the lexical repo alias is recovered, a lexical
        VIRTUAL_ENV (<lexical repo>/venv) validates and its site-packages is
        stripped together with the repo root, while user entries survive.
        """
        import tools.environments.local as local

        physical_root = _physical_repo_root(tmp_path)
        venv_dir = physical_root / "venv"
        venv_dir.mkdir(parents=True)
        (venv_dir / "pyvenv.cfg").write_text("home = x\n", encoding="utf-8")
        configured_home = tmp_path / "configured-home"
        configured_home.mkdir()
        try:
            _make_directory_link(configured_home / "hermes-agent", physical_root)
        except OSError as exc:
            pytest.skip(f"directory link unavailable on this host: {exc}")

        lexical_root = configured_home / "hermes-agent"
        aliases = local._build_hermes_repo_root_aliases(
            physical_root.resolve(),
            physical_root,
            configured_home,
        )
        assert any(local._same_path(a, lexical_root) for a in aliases)
        monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)

        lexical_venv = lexical_root / "venv"
        validated = local._validated_runtime_venv({"VIRTUAL_ENV": str(lexical_venv)})
        assert validated is not None
        assert local._same_path(validated, lexical_venv)

        local._hermes_site_packages = None
        env = {"PYTHONPATH": os.pathsep.join([
            str(lexical_root),
            str(lexical_venv / "Lib" / "site-packages"),
            "/home/user/my-lib",
        ]), "VIRTUAL_ENV": str(lexical_venv)}
        local._strip_hermes_owned_pythonpath(env)
        assert env["PYTHONPATH"].split(os.pathsep) == ["/home/user/my-lib"]





class TestPythonhomeSanitized:
    """PYTHONHOME must not leak from the Hermes runtime into subprocesses.

    The gateway inherits/sets PYTHONHOME in its process environment; a child
    interpreter (system Python, another venv, cron no_agent scripts) that
    inherits it redirects its stdlib search to the Hermes venv and crashes
    with version-mismatch errors before importing anything (#75018).
    """

    @pytest.mark.parametrize("builder", [
        "_make_run_env",
        "_sanitize_subprocess_env",
        "hermes_subprocess_env",
        "build_subprocess_env",
    ])
    def test_builders_strip_pythonhome(self, builder):
        """The gateway's inherited PYTHONHOME must not reach any subprocess
        builder -- terminal, background/PTY, cron no_agent scripts, and
        execute_code children (#75018).
        """
        from tools.environments import local as local_mod

        seed = {
            "PATH": "/usr/bin:/bin",
            "HOME": "/home/user",
            "PYTHONHOME": "/opt/hermes-venv",
        }
        with patch.dict(os.environ, seed, clear=True):
            if builder == "_make_run_env":
                result = local_mod._make_run_env({})
            elif builder == "_sanitize_subprocess_env":
                result = local_mod._sanitize_subprocess_env(dict(os.environ))
            elif builder == "hermes_subprocess_env":
                result = local_mod.hermes_subprocess_env()
            else:
                result = local_mod.build_subprocess_env()
        assert "PYTHONHOME" not in result

    def test_pythonhome_removed_from_active_venv_markers(self):
        """PYTHONHOME is part of _ACTIVE_VENV_MARKER_VARS so all builders
        that iterate it drop the variable."""
        from tools.environments.local import _ACTIVE_VENV_MARKER_VARS
        assert "PYTHONHOME" in _ACTIVE_VENV_MARKER_VARS

    def test_build_subprocess_env_no_scrub_preserves_pythonhome(self):
        """``build_subprocess_env(scrub_secrets=False)`` is the documented
        byte-for-byte escape hatch: no key is removed, so PYTHONHOME (and
        everything else) survives there by contract, not by omission.

        Callers that explicitly opt out of scrubbing (git credential flows,
        secret CLIs) must not have their environment silently altered — this
        test pins that exception as intentional.
        """
        from tools.environments.local import build_subprocess_env
        base = {
            "PATH": "/usr/bin:/bin",
            "HOME": "/home/user",
            "PYTHONHOME": "/opt/hermes-venv",
            "VIRTUAL_ENV": "/opt/hermes-venv",
            "SERVICE_TOKEN": "s3cr3t",
        }
        result = build_subprocess_env(base, scrub_secrets=False)
        assert result.get("PYTHONHOME") == "/opt/hermes-venv"
        assert result.get("VIRTUAL_ENV") == "/opt/hermes-venv"
        assert result.get("SERVICE_TOKEN") == "s3cr3t"


class TestProfileScopedPassthrough:
    def test_make_run_env_uses_active_profile_for_passthrough(self, monkeypatch):
        """Allowlisted values must come from the routed profile, not os.environ."""
        from agent import secret_scope as ss
        from tools.env_passthrough import clear_env_passthrough, register_env_passthrough
        from tools.environments.local import _make_run_env

        clear_env_passthrough()
        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:
            result = _make_run_env({})
        finally:
            ss.reset_secret_scope(token)
            ss.set_multiplex_active(False)
            clear_env_passthrough()

        assert result["SERVICE_TOKEN"] == "token-for-routed-profile"

    def test_make_run_env_omits_missing_scoped_passthrough(self, monkeypatch):
        """A missing routed secret must not fall back to the default profile."""
        from agent import secret_scope as ss
        from tools.env_passthrough import clear_env_passthrough, register_env_passthrough
        from tools.environments.local import _make_run_env

        clear_env_passthrough()
        register_env_passthrough(["SERVICE_TOKEN"])
        monkeypatch.setenv("SERVICE_TOKEN", "token-for-default")
        ss.set_multiplex_active(True)
        token = ss.set_secret_scope({})
        try:
            result = _make_run_env({})
        finally:
            ss.reset_secret_scope(token)
            ss.set_multiplex_active(False)
            clear_env_passthrough()

        assert "SERVICE_TOKEN" not in result


class TestBlocklistCoverage:
    """Sanity checks that the blocklist covers all known providers."""

    def test_issue_1002_offenders(self):
        """Blocklist includes the main offenders from issue #1002."""
        must_block = {
            "OPENAI_BASE_URL",
            "OPENAI_API_KEY",
            "OPENROUTER_API_KEY",
            "ANTHROPIC_API_KEY",
            "LLM_MODEL",
        }
        assert must_block.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST)

    def test_registry_vars_are_in_blocklist(self):
        """Every api_key_env_var and base_url_env_var from PROVIDER_REGISTRY
        must appear in the blocklist — ensures no drift.

        CLAUDE_CODE_OAUTH_TOKEN is the one deliberate exemption: it is owned
        by the user's Claude Code install, not Hermes (#55878).
        """
        from hermes_cli.auth import PROVIDER_REGISTRY

        exempt = {"CLAUDE_CODE_OAUTH_TOKEN"}
        for pconfig in PROVIDER_REGISTRY.values():
            for var in pconfig.api_key_env_vars:
                if var in exempt:
                    continue
                assert var in _HERMES_PROVIDER_ENV_BLOCKLIST, (
                    f"Registry var {var} (provider={pconfig.id}) missing from blocklist"
                )
            if pconfig.base_url_env_var:
                assert pconfig.base_url_env_var in _HERMES_PROVIDER_ENV_BLOCKLIST, (
                    f"Registry base_url_env_var {pconfig.base_url_env_var} "
                    f"(provider={pconfig.id}) missing from blocklist"
                )

    def test_bedrock_bearer_token_is_in_blocklist(self):
        """auth_type='aws_sdk' providers contribute their Hermes-managed
        inference token (the Bedrock bearer) to the blocklist, keyed off
        auth_type so any future SDK-cred provider is covered automatically."""
        assert "AWS_BEARER_TOKEN_BEDROCK" in _HERMES_PROVIDER_ENV_BLOCKLIST

    def test_general_aws_chain_not_in_blocklist(self):
        """The general AWS credential chain must NOT be in the blocklist —
        no-regression guard for #32314. These belong to the user's trusted
        operator shell (SECURITY.md §3.2), not to Hermes, and blocklisting
        them would be unrecoverable via env_passthrough (GHSA-rhgp-j443-p4rf).
        """
        general_chain = {
            "AWS_ACCESS_KEY_ID",
            "AWS_SECRET_ACCESS_KEY",
            "AWS_SESSION_TOKEN",
            "AWS_PROFILE",
            "AWS_DEFAULT_REGION",
            "AWS_REGION",
            "AWS_SHARED_CREDENTIALS_FILE",
            "AWS_CONFIG_FILE",
            "AWS_WEB_IDENTITY_TOKEN_FILE",
            "AWS_ROLE_ARN",
        }
        leaked_block = general_chain & _HERMES_PROVIDER_ENV_BLOCKLIST
        assert not leaked_block, (
            f"General AWS chain vars must stay inheritable, but these are "
            f"blocklisted: {sorted(leaked_block)} (capability regression, #32314)"
        )

    def test_extra_auth_vars_covered(self):
        """Non-registry auth vars (ANTHROPIC_TOKEN) must also be in the
        blocklist."""
        extras = {"ANTHROPIC_TOKEN"}
        assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST)

    def test_claude_code_oauth_token_is_inheritable(self):
        """CLAUDE_CODE_OAUTH_TOKEN is owned by the user's Claude Code install
        (subscription OAuth), not a Hermes inference credential. Stripping it
        made agent-spawned ``claude`` fall through to the shared Keychain /
        ~/.claude credential store and clobber the user's interactive login
        on auth failure (#55878). It must stay inheritable."""
        assert "CLAUDE_CODE_OAUTH_TOKEN" not in _HERMES_PROVIDER_ENV_BLOCKLIST

    def test_non_registry_provider_vars_are_in_blocklist(self):
        extras = {
            "GOOGLE_API_KEY",
            "DEEPSEEK_API_KEY",
            "MISTRAL_API_KEY",
            "GROQ_API_KEY",
            "TOGETHER_API_KEY",
            "PERPLEXITY_API_KEY",
            "COHERE_API_KEY",
            "FIREWORKS_API_KEY",
            "XAI_API_KEY",
            "HELICONE_API_KEY",
        }
        assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST)

    def test_optional_tool_and_messaging_vars_are_in_blocklist(self):
        """Tool/messaging vars from OPTIONAL_ENV_VARS should stay covered."""
        from hermes_cli.config import OPTIONAL_ENV_VARS

        for name, metadata in OPTIONAL_ENV_VARS.items():
            category = metadata.get("category")
            if category in {"tool", "messaging"}:
                assert name in _HERMES_PROVIDER_ENV_BLOCKLIST, (
                    f"Optional env var {name} (category={category}) missing from blocklist"
                )
            elif category == "setting" and metadata.get("password"):
                assert name in _HERMES_PROVIDER_ENV_BLOCKLIST, (
                    f"Secret setting env var {name} missing from blocklist"
                )

    def test_gateway_runtime_vars_are_in_blocklist(self):
        extras = {
            "TELEGRAM_HOME_CHANNEL",
            "TELEGRAM_HOME_CHANNEL_NAME",
            "DISCORD_HOME_CHANNEL",
            "DISCORD_HOME_CHANNEL_NAME",
            "DISCORD_REQUIRE_MENTION",
            "DISCORD_FREE_RESPONSE_CHANNELS",
            "DISCORD_AUTO_THREAD",
            "SLACK_HOME_CHANNEL",
            "SLACK_HOME_CHANNEL_NAME",
            "SLACK_ALLOWED_USERS",
            "WHATSAPP_ENABLED",
            "WHATSAPP_MODE",
            "WHATSAPP_ALLOWED_USERS",
            "SIGNAL_HTTP_URL",
            "SIGNAL_ACCOUNT",
            "SIGNAL_ALLOWED_USERS",
            "SIGNAL_GROUP_ALLOWED_USERS",
            "SIGNAL_HOME_CHANNEL",
            "SIGNAL_HOME_CHANNEL_NAME",
            "SIGNAL_IGNORE_STORIES",
            "HASS_TOKEN",
            "HASS_URL",
            "EMAIL_ADDRESS",
            "EMAIL_PASSWORD",
            "EMAIL_IMAP_HOST",
            "EMAIL_SMTP_HOST",
            "EMAIL_HOME_ADDRESS",
            "EMAIL_HOME_ADDRESS_NAME",
            "HERMES_DASHBOARD_SESSION_TOKEN",
            "GATEWAY_ALLOWED_USERS",
            "GH_TOKEN",
            "GITHUB_APP_ID",
            "GITHUB_APP_PRIVATE_KEY_PATH",
            "GITHUB_APP_INSTALLATION_ID",
            "MODAL_TOKEN_ID",
            "MODAL_TOKEN_SECRET",
            "DAYTONA_API_KEY",
            "VERCEL_OIDC_TOKEN",
            "VERCEL_TOKEN",
            "VERCEL_PROJECT_ID",
            "VERCEL_TEAM_ID",
        }
        assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST)


class TestSanePathIncludesHomebrew:
    """Verify _SANE_PATH includes macOS Homebrew directories."""

    @pytest.fixture(autouse=True)
    def _disable_hermes_bin_injection(self):
        """These tests assert the sane-path merge in isolation. Disable the
        hermes-install-dir prepend (a separate concern, covered by
        TestHermesBinDirOnPath) so a real ``hermes`` on the test runner's PATH
        doesn't shift the asserted PATH layout."""
        from tools.environments import local as local_mod
        saved = local_mod._HERMES_BIN_DIR
        local_mod._HERMES_BIN_DIR = None  # resolved -> no dir to inject
        yield
        local_mod._HERMES_BIN_DIR = saved

    def test_sane_path_includes_homebrew_bin(self):
        from tools.environments.local import _SANE_PATH
        assert "/opt/homebrew/bin" in _SANE_PATH


    def test_make_run_env_appends_homebrew_on_minimal_path(self, monkeypatch):
        """When PATH is minimal, _make_run_env appends missing sane entries.

        POSIX: the sane-path merge appends the Homebrew dirs.  Windows:
        _append_missing_sane_path_entries is a documented passthrough (the
        native PATH must not be touched), so the assertion is the unchanged
        input.  Git Bash dir prepending is neutralised so the merged PATH
        layout is deterministic on every host.
        """
        from tools.environments import local as local_mod
        from tools.environments.local import _SANE_PATH, _make_run_env
        monkeypatch.setattr(local_mod, "_git_bash_bin_dirs", lambda: [])
        minimal_env = {"PATH": "/some/custom/bin"}
        with patch.dict(os.environ, minimal_env, clear=True):
            result = _make_run_env({})
        path_entries = result["PATH"].split(os.pathsep)
        assert path_entries[0] == "/some/custom/bin"
        if sys.platform == "win32":
            assert result["PATH"] == "/some/custom/bin"
        else:
            for entry in _SANE_PATH.split(os.pathsep):
                assert entry in path_entries


    @pytest.mark.macos_only
    def test_make_run_env_real_launchd_path_gains_homebrew(self):
        """The literal macOS launchd PATH is the production trigger for #35613.

        macOS-only: the regression is the launchd environment on macOS, and
        the sane-path merge is a documented passthrough on Windows.
        """
        from tools.environments.local import _make_run_env
        launchd_env = {"PATH": os.pathsep.join(["/usr/bin", "/bin", "/usr/sbin", "/sbin"])}
        with patch.dict(os.environ, launchd_env, clear=True):
            result = _make_run_env({})
        path_entries = result["PATH"].split(os.pathsep)
        assert "/opt/homebrew/bin" in path_entries
        assert "/opt/homebrew/sbin" in path_entries
        # Original entries keep their leading precedence.
        assert path_entries[:4] == ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]


    @pytest.mark.windows_only
    def test_make_run_env_preserves_windows_mixed_case_path_key(self, monkeypatch):
        """Windows-only: ``_path_env_key`` looks for a case-insensitive PATH
        key only on Windows, so the mixed-case ``Path`` preservation this
        asserts is a genuinely Windows-native behaviour.

        The Git Bash dir prepend is neutralised so the assertion is about the
        key casing alone (a real Windows box has those dirs).
        """
        from tools.environments import local as local_mod
        from tools.environments.local import _make_run_env
        windows_env = {"Path": r"C:\Windows\System32;C:\Program Files\Git\bin"}
        monkeypatch.setattr(local_mod, "_git_bash_bin_dirs", lambda: [])
        with patch.object(local_mod.os, "environ", windows_env):
            result = _make_run_env({})
        assert result["Path"] == windows_env["Path"]
        assert "PATH" not in result


class TestHermesBinDirOnPath:
    """The hermes install dir is reachable in the terminal subshell PATH.

    Plugins shelling out to bare ``hermes`` via the terminal tool must work
    even when the gateway was launched without the hermes install dir on
    PATH (systemd, service managers, cron). See the discussion that motivated
    _resolve_hermes_bin_dir / _prepend_hermes_bin_dir.
    """

    def _reset_cache(self):
        from tools.environments import local as local_mod
        local_mod._HERMES_BIN_DIR = local_mod._SENTINEL

    def test_resolves_via_which(self, monkeypatch):
        from tools.environments import local as local_mod
        self._reset_cache()
        monkeypatch.setattr(local_mod.shutil, "which",
                            lambda name: "/opt/hermes/bin/hermes" if name == "hermes" else None)
        monkeypatch.setattr(local_mod.os.path, "isdir", lambda p: p == "/opt/hermes/bin")
        assert local_mod._resolve_hermes_bin_dir() == "/opt/hermes/bin"


    def test_prepend_noop_when_unresolved(self, monkeypatch):
        from tools.environments import local as local_mod
        self._reset_cache()
        local_mod._HERMES_BIN_DIR = None
        assert local_mod._prepend_hermes_bin_dir("/usr/bin:/bin") == "/usr/bin:/bin"

    def test_make_run_env_injects_hermes_bin_dir(self):
        """A gateway env missing the hermes dir gets it back in the subshell PATH.

        Platform-agnostic: ``_prepend_hermes_bin_dir`` uses ``os.pathsep`` on
        every host, so no platform flag is faked here."""
        from tools.environments import local as local_mod
        from tools.environments.local import _make_run_env
        self._reset_cache()
        local_mod._HERMES_BIN_DIR = "/opt/hermes/bin"
        with patch.dict(
            os.environ,
            {"PATH": os.pathsep.join(["/usr/bin", "/bin"])},
            clear=True,
        ):
            result = _make_run_env({})
        entries = result["PATH"].split(os.pathsep)
        assert entries[0] == "/opt/hermes/bin"
        assert "/usr/bin" in entries


class TestHermesInternalDynamicSecrets:
    """Dynamically-named Hermes secrets injected at gateway/CLI startup must
    not leak into terminal subprocesses.

    The static ``_HERMES_PROVIDER_ENV_BLOCKLIST`` is name-based and derived
    from provider/tool registries, so it cannot enumerate:

    - ``AUXILIARY_<TASK>_API_KEY`` / ``AUXILIARY_<TASK>_BASE_URL`` — per-task
      side-LLM credentials bridged from ``config.yaml[auxiliary]`` by
      ``gateway/run.py`` and ``cli.py``.
    - ``GATEWAY_RELAY_*_SECRET`` / ``_KEY`` / ``_TOKEN`` — relay-auth material
      provisioned by ``gateway/relay``.

    ``_is_hermes_internal_secret`` is the single source of truth; every spawn
    path (``_sanitize_subprocess_env``, ``_make_run_env``,
    ``hermes_subprocess_env``, Docker forward filter, ``env_passthrough``)
    consults it. These tests exercise the terminal execute path + predicate.
    """

    def test_predicate_matches_auxiliary_api_key(self):
        from tools.environments.local import _is_hermes_internal_secret
        assert _is_hermes_internal_secret("AUXILIARY_VISION_API_KEY")
        assert _is_hermes_internal_secret("AUXILIARY_WEB_EXTRACT_API_KEY")
        assert _is_hermes_internal_secret("AUXILIARY_APPROVAL_API_KEY")
        # plugin-registered task names are covered by the pattern
        assert _is_hermes_internal_secret("AUXILIARY_MY_PLUGIN_TASK_API_KEY")

    def test_predicate_matches_auxiliary_base_url(self):
        from tools.environments.local import _is_hermes_internal_secret
        assert _is_hermes_internal_secret("AUXILIARY_VISION_BASE_URL")
        assert _is_hermes_internal_secret("AUXILIARY_COMPRESSION_BASE_URL")

    def test_predicate_matches_gateway_relay_auth(self):
        from tools.environments.local import _is_hermes_internal_secret
        assert _is_hermes_internal_secret("GATEWAY_RELAY_SECRET")
        assert _is_hermes_internal_secret("GATEWAY_RELAY_DELIVERY_KEY")
        assert _is_hermes_internal_secret("GATEWAY_RELAY_SESSION_TOKEN")

    def test_predicate_allows_auxiliary_non_secrets(self):
        """AUXILIARY_*_PROVIDER / _MODEL and GATEWAY_RELAY_* routing hints are
        NOT secrets and must remain visible so tooling that reads them works."""
        from tools.environments.local import _is_hermes_internal_secret
        assert not _is_hermes_internal_secret("AUXILIARY_VISION_PROVIDER")
        assert not _is_hermes_internal_secret("AUXILIARY_VISION_MODEL")
        assert not _is_hermes_internal_secret("GATEWAY_RELAY_URL")
        assert not _is_hermes_internal_secret("GATEWAY_RELAY_PLATFORMS")
        assert not _is_hermes_internal_secret("GATEWAY_RELAY_ID")  # not a secret suffix
        # unrelated vars pass through
        assert not _is_hermes_internal_secret("PATH")
        assert not _is_hermes_internal_secret("MY_APP_KEY")

    def test_auxiliary_secrets_stripped_from_subprocess(self):
        """AUXILIARY_*_API_KEY / _BASE_URL injected into os.environ must not
        reach the terminal subprocess, while _PROVIDER / _MODEL survive."""
        result_env = _run_with_env(extra_os_env={
            "AUXILIARY_VISION_API_KEY": "sk-vision-secret",
            "AUXILIARY_VISION_BASE_URL": "http://internal:1234/v1",
            "AUXILIARY_WEB_EXTRACT_API_KEY": "sk-webx-secret",
            "AUXILIARY_VISION_PROVIDER": "openai",
            "AUXILIARY_VISION_MODEL": "gpt-4o",
        })
        assert "AUXILIARY_VISION_API_KEY" not in result_env
        assert "AUXILIARY_VISION_BASE_URL" not in result_env
        assert "AUXILIARY_WEB_EXTRACT_API_KEY" not in result_env
        # Non-secret routing config is preserved.
        assert result_env.get("AUXILIARY_VISION_PROVIDER") == "openai"
        assert result_env.get("AUXILIARY_VISION_MODEL") == "gpt-4o"

    def test_gateway_relay_secret_stripped_from_subprocess(self):
        result_env = _run_with_env(extra_os_env={
            "GATEWAY_RELAY_SECRET": "relay-signing-secret",
            "GATEWAY_RELAY_DELIVERY_KEY": "relay-delivery-key",
            "GATEWAY_RELAY_URL": "https://relay.example.com",
        })
        assert "GATEWAY_RELAY_SECRET" not in result_env
        assert "GATEWAY_RELAY_DELIVERY_KEY" not in result_env
        # Non-secret routing hint stays visible.
        assert result_env.get("GATEWAY_RELAY_URL") == "https://relay.example.com"

    def test_auxiliary_secret_stripped_even_when_passthrough_registered(self):
        """A skill registering AUXILIARY_*_API_KEY as env_passthrough must NOT
        be able to tunnel it into a subprocess — the strip is unconditional."""
        with patch(
            "tools.env_passthrough.is_env_passthrough",
            side_effect=lambda name: name == "AUXILIARY_VISION_API_KEY",
        ):
            result_env = _run_with_env(extra_os_env={
                "AUXILIARY_VISION_API_KEY": "sk-vision-secret",
            })
        assert "AUXILIARY_VISION_API_KEY" not in result_env

    def test_make_run_env_strips_internal_secrets(self):
        """The foreground _make_run_env path strips the same dynamic secrets."""
        from tools.environments.local import _make_run_env
        with patch.dict(os.environ, {
            "PATH": "/usr/bin:/bin",
            "AUXILIARY_VISION_API_KEY": "sk-secret",
            "GATEWAY_RELAY_SECRET": "relay-secret",
            "AUXILIARY_VISION_PROVIDER": "openai",
        }, clear=True):
            run_env = _make_run_env({})
        assert "AUXILIARY_VISION_API_KEY" not in run_env
        assert "GATEWAY_RELAY_SECRET" not in run_env
        assert run_env.get("AUXILIARY_VISION_PROVIDER") == "openai"

    def test_gateway_relay_static_names_in_blocklist(self):
        """The static relay names are also added to the name-based blocklist so
        the exact-match path catches them independently of the predicate."""
        assert "GATEWAY_RELAY_SECRET" in _HERMES_PROVIDER_ENV_BLOCKLIST
        assert "GATEWAY_RELAY_DELIVERY_KEY" in _HERMES_PROVIDER_ENV_BLOCKLIST
        assert "GATEWAY_RELAY_ID" in _HERMES_PROVIDER_ENV_BLOCKLIST
