{"repo":"NousResearch/hermes-agent","public_id":"29157","display_label":"Scrubbing credentials from subprocess environments","summary":"These issues and pull requests address a security concern where messaging gateway credentials were being leaked into subprocess environments. The work involves centralizing and applying credential scrubbing logic to prevent environment variable exposure in subprocesses.","subsystem":"environments","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53937,"merged_at":"2026-06-28T03:45:32+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-28T03:45:32+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":6032,"pr_number":53937,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-28T03:45:32+00:00"},{"issue_number":29157,"pr_number":53937,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-28T03:45:32+00:00"},{"issue_number":37258,"pr_number":53937,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-28T03:45:32+00:00"}],"members":[{"kind":"issue","number":29157,"title":"[Security tracking] Information Leak — Messaging gateway credentials leaked to subprocesses (GHSA-m4m8-xjp4-5rmm)","url":null,"state":"closed"},{"kind":"pr","number":35769,"title":"fix(gateway): scrub subprocess credential env","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9c6229ce2","number":0},{"kind":"pr","value":"#53937","number":53937},{"kind":"pr","value":"#29157","number":29157}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":39003,"title":"fix(gateway): scrub credential env vars from subprocess environments (#29157)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9c6229ce2","number":0},{"kind":"pr","value":"#53937","number":53937},{"kind":"pr","value":"#29157","number":29157},{"kind":"pr","value":"#31959","number":31959}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":53937,"title":"fix(security): centralize credential-safe subprocess env (#29157)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/copilot_acp_client.py","hermes_cli/dep_ensure.py","tests/tools/test_hermes_subprocess_env.py","tools/browser_tool.py","tools/environments/local.py","tools/lazy_deps.py","tui_gateway/server.py"],"fix_hunks":[{"file":"agent/copilot_acp_client.py","hunks":["@@ -23,6 +23,7 @@\n \n from agent.file_safety import get_read_block_error, is_write_denied\n from agent.redact import redact_sensitive_text\n+from tools.environments.local import hermes_subprocess_env\n \n ACP_MARKER_BASE_URL = \"acp://copilot\"\n _DEFAULT_TIMEOUT_SECONDS = 900.0","@@ -94,7 +95,10 @@ def _resolve_home_dir() -> str:\n \n \n def _build_subprocess_env() -> dict[str, str]:\n-    env = os.environ.copy()\n+    # Copilot ACP is a model-driving CLI executor: it legitimately needs LLM\n+    # provider credentials. Route through the central helper so Tier-1 secrets\n+    # (gateway bot tokens, GitHub auth, infra) are still stripped (#29157).\n+    env = hermes_subprocess_env(inherit_credentials=True)\n     home = _resolve_home_dir()\n     env[\"HOME\"] = home\n     from hermes_constants import apply_subprocess_home_env"]},{"file":"hermes_cli/dep_ensure.py","hunks":["@@ -23,6 +23,7 @@\n from pathlib import Path\n \n from hermes_constants import agent_browser_runnable\n+from tools.environments.local import hermes_subprocess_env\n \n _IS_WINDOWS = platform.system() == \"Windows\"\n ","@@ -148,7 +149,8 @@ def ensure_dependency(\n     else:\n         cmd = [\"bash\", str(script), \"--ensure\", dep]\n \n-    run_env = {**os.environ, \"IS_INTERACTIVE\": \"false\"}\n+    run_env = hermes_subprocess_env(inherit_credentials=[redacted:secret-assignment]\n+    run_env[\"IS_INTERACTIVE\"] = \"false\"\n     result = subprocess.run(\n         cmd,\n         env=run_env,"]},{"file":"tests/tools/test_hermes_subprocess_env.py","hunks":["@@ -0,0 +1,151 @@\n+\"\"\"Tests for hermes_subprocess_env() — the centralized credential-safe env\n+builder for the non-terminal subprocess spawn surface.\n+\n+Covers GHSA-m4m8-xjp4-5rmm / issue #29157: subprocesses spawned by the\n+gateway/browser/ACP/installer paths must not blindly inherit the operator's\n+full credential environment. Two tiers:\n+\n+  * Tier 1 (_ALWAYS_STRIP_KEYS): gateway bot tokens, GitHub auth, infra\n+    secrets — stripped even when inherit_credentials=True.\n+  * Tier 2 (_HERMES_PROVIDER_ENV_BLOCKLIST): LLM provider/tool keys — stripped\n+    unless the caller opts into inherit_credentials=True.\n+\"\"\"\n+\n+import os\n+from unittest.mock import patch\n+\n+from tools.environments.local import (\n+    hermes_subprocess_env,\n+    _ALWAYS_STRIP_KEYS,\n+    _HERMES_PROVIDER_ENV_FORCE_PREFIX,\n+)\n+\n+\n+_TIER1_SAMPLE = {\n+    \"GH_TOKEN\": \"[redacted:secret-assignment]\",\n+    \"TELEGRAM_BOT_TOKEN\": \"[redacted:secret-assignment]\",\n+    \"SLACK_APP_TOKEN\": \"[redacted:secret-assignment]\",\n+    \"MODAL_TOKEN_SECRET\": \"[redacted:secret-assignment]\",\n+    \"HERMES_DASHBOARD_SESSION_TOKEN\": \"[redacted:secret-assignment]\",\n+}\n+\n+_PROVIDER_SAMPLE = {\n+    \"OPENAI_API_KEY\": \"[redacted:secret-assignment]\",\n+    \"ANTHROPIC_API_KEY\": \"[redacted:secret-assignment]\",\n+    \"OPENROUTER_API_KEY\": \"[redacted:secret-assignment]\",\n+}\n+\n+_SAFE_SAMPLE = {\n+    \"PATH\": \"/usr/bin:/bin\",\n+    \"HOME\": \"/home/user\",\n+    \"USER\": \"testuser\",\n+    \"MY_APP_VAR\": \"keep-me\",\n+}\n+\n+\n+def _build(extra=None, *, inherit_credentials=[redacted:secret-assignment]\n+    env = dict(_SAFE_SAMPLE)\n+    if extra:\n+        env.update(extra)\n+    with patch.dict(os.environ, env, clear=True):\n+        return hermes_subprocess_env(inherit_credentials=[redacted:secret-assignment]\n+\n+\n+class TestStripByDefault:\n+    def test_provider_keys_stripped_by_default(self):\n+        result = _build(_PROVIDER_SAMPLE)\n+        for var in _PROVIDER_SAMPLE:\n+            assert var not in result, f\"{var} leaked with inherit_credentials=False\"\n+\n+    def test_tier1_secrets_stripped_by_default(self):\n+        result = _build(_TIER1_SAMPLE)\n+        for var in _TIER1_SAMPLE:\n+            assert var not in result, f\"{var} leaked (Tier-1) with inherit_credentials=False\"\n+\n+    def test_safe_vars_preserved(self):\n+        result = _build()\n+        assert result[\"HOME\"] == \"/home/user\"\n+        assert result[\"USER\"] == \"testuser\"\n+        assert \"PATH\" in result\n+        assert result[\"MY_APP_VAR\"] == \"keep-me\"\n+\n+    def test_force_prefix_hints_stripped(self):\n+        result = _build({f\"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}OPENAI_API_KEY\": \"sk-x\"})\n+        assert f\"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}OPENAI_API_KEY\" not in result\n+        assert \"OPENAI_API_KEY\" not in result\n+\n+    def test_pythonutf8_set(self):\n+        result = _build()\n+        assert result.get(\"PYTHONUTF8\") == \"1\"\n+\n+\n+class TestInheritCredentials:\n+    def test_provider_keys_preserved_when_inheriting(self):\n+        result = _build(_PROVIDER_SAMPLE, inherit_credentials=True)\n+        for var, val in _PROVIDER_SAMPLE.items():\n+            assert result.get(var) == val, f\"{var} should survive inherit_credentials=True\"\n+\n+    def test_tier1_secrets_stripped_even_when_inheriting(self):\n+        \"\"\"The whole point of Tier 1: gateway/GitHub/infra secrets never reach\n+        a child, even a model-driving CLI that legitimately needs provider keys.\"\"\"\n+        result = _build({**_PROVIDER_SAMPLE, **_TIER1_SAMPLE}, inherit_credentials=True)\n+        for var in _TIER1_SAMPLE:\n+            assert var not in result, (\n+                f\"{var} (Tier-1) must be stripped even with inherit_credentials=True\"\n+            )\n+        # ...while provider keys survive.\n+        for var in _PROVIDER_SAMPLE:\n+            assert var in result\n+\n+    def test_pythonutf8_set_when_inheriting(self):\n+        assert _build(inherit_credentials=[redacted:secret-assignment]\"PYTHONUTF8\") == \"1\"\n+\n+\n+class TestTierInvariants:\n+    def test_tier1_always_stripped_both_paths(self):\n+        \"\"\"Behavioral invariant: every Tier-1 key is stripped on BOTH the\n+        default path and the inherit_credentials=True path. This is what\n+        guarantees no gap, regardless of whether the key also happens to be\n+        in the provider blocklist.\"\"\"\n+        sample = {k: f\"secret-{k}\" for k in _ALWAYS_STRIP_KEYS}\n+        for inherit in (False, True):\n+            result = _build(sample, inherit_credentials=[redacted:secret-assignment]\n+            leaked = {k for k in _ALWAYS_STRIP_KEYS if k in result}\n+            assert not leaked, (\n+                f\"Tier-1 keys leaked with inherit_credentials={inherit}: {sorted(leaked)}\"\n+            )\n+\n+    def test_tier1_covers_gateway_bot_token(self):\n+        assert \"TELEGRAM_BOT_TOKEN\" in _ALWAYS_STRIP_KEYS\n+\n+    def test_tier1_covers_github_auth(self):\n+        assert {\"GH_TOKEN\", \"GITHUB_TOKEN\"} <= _ALWAYS_STRIP_KEYS\n+\n+    def test_tier1_covers_infra_secrets(self):\n+        assert {\"MODAL_TOKEN_ID\", \"MODAL_TOKEN_SECRET\", \"DAYTONA_API_KEY\"} <= _ALWAYS_STRIP_KEYS\n+\n+\n+class TestBrowserPassthroughPattern:\n+    def test_browser_keys_recoverable_after_strip(self):\n+        \"\"\"Browser tool pattern: strip everything, then re-add the browser\n+        backend keys agent-browser actually needs.\"\"\"\n+        from tools.browser_tool import _BROWSER_PASSTHROUGH_KEYS\n+\n+        leaked = {\n+            \"BROWSERBASE_API_KEY\": \"[redacted:secret-assignment]\",\n+            \"BROWSERBASE_PROJECT_ID\": \"bb-proj\",\n+            \"FIRECRAWL_API_KEY\": \"[redacted:secret-assignment]\",\n+            \"ANTHROPIC_API_KEY\": \"[redacted:secret-assignment]\",\n+            \"TELEGRAM_BOT_TOKEN\": \"[redacted:secret-assignment]\",\n+        }\n+        with patch.dict(os.environ, {**_SAFE_SAMPLE, **leaked}, clear=True):\n+            env = hermes_subprocess_env(inherit_credentials=[redacted:secret-assignment]\n+            for key in _BROWSER_PASSTHROUGH_KEYS:\n+                if key in os.environ:\n+                    env[key] = os.environ[key]\n+\n+        assert env[\"BROWSERBASE_API_KEY\"] == \"bb-key\"\n+        assert env[\"FIRECRAWL_API_KEY\"] == \"fc-key\"\n+        # Provider + gateway secrets must NOT come back.\n+        assert \"ANTHROPIC_API_KEY\" not in env\n+        assert \"TELEGRAM_BOT_TOKEN\" not in env"]},{"file":"tools/browser_tool.py","hunks":["@@ -70,6 +70,39 @@\n from hermes_cli.config import DEFAULT_CONFIG, cfg_get\n from hermes_cli._subprocess_compat import windows_hide_flags\n \n+# Browser-specific tool keys passed through to the agent-browser subprocess\n+# AFTER credential stripping.  agent-browser is a Node process loading npm\n+# deps; handing it the full operator keyring (#29157 / GHSA-m4m8-xjp4-5rmm)\n+# means a compromised transitive dependency could read every Hermes secret\n+# straight out of process.env.  Strip by default, then re-add only the\n+# browser-backend keys the worker legitimately needs.\n+_BROWSER_PASSTHROUGH_KEYS: tuple[str, ...] = (\n+    \"BROWSERBASE_API_KEY\",\n+    \"BROWSERBASE_PROJECT_ID\",\n+    \"BROWSER_USE_API_KEY\",\n+    \"FIRECRAWL_API_KEY\",\n+    \"FIRECRAWL_API_URL\",\n+    \"FIRECRAWL_BROWSER_TTL\",\n+)\n+\n+\n+def _build_browser_env() -> dict:\n+    \"\"\"Credential-scrubbed env for an agent-browser subprocess.\n+\n+    Strips Hermes-managed secrets (provider keys, gateway tokens, GitHub auth,\n+    infra secrets) then re-adds only the browser-backend keys the worker needs.\n+    The ``hermes_subprocess_env`` import is deferred to keep ``browser_tool``\n+    importable under test harnesses that load it against a stubbed ``tools``\n+    package (tests/tools/test_managed_browserbase_and_modal.py).\n+    \"\"\"\n+    from tools.environments.local import hermes_subprocess_env\n+\n+    env = hermes_subprocess_env(inherit_credentials=[redacted:secret-assignment]\n+    for _key in _BROWSER_PASSTHROUGH_KEYS:\n+        if _key in os.environ:\n+            env[_key] = os.environ[_key]\n+    return env\n+\n try:\n     from tools.website_policy import check_website_access\n except Exception:","@@ -872,7 +905,8 @@ def _run_chrome_fallback_command(\n \n     task_socket_dir = os.path.join(_socket_safe_tmpdir(), f\"agent-browser-{tmp_session}\")\n     os.makedirs(task_socket_dir, mode=0o700, exist_ok=True)\n-    browser_env = {**os.environ, \"AGENT_BROWSER_SOCKET_DIR\": task_socket_dir}\n+    browser_env = _build_browser_env()\n+    browser_env[\"AGENT_BROWSER_SOCKET_DIR\"] = task_socket_dir\n     browser_env[\"PATH\"] = _merge_browser_path(browser_env.get(\"PATH\", \"\"))\n \n     if \"AGENT_BROWSER_IDLE_TIMEOUT_MS\" not in browser_env:","@@ -2125,7 +2159,7 @@ def _run_browser_command(\n         logger.debug(\"browser cmd=%s task=%s socket_dir=%s (%d chars)\",\n                      command, task_id, task_socket_dir, len(task_socket_dir))\n \n-        browser_env = {**os.environ}\n+        browser_env = _build_browser_env()\n \n         # Ensure subprocesses inherit the same browser-specific PATH fallbacks\n         # used during CLI discovery."]},{"file":"tools/environments/local.py","hunks":["@@ -250,6 +250,100 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non\n     return sanitized\n \n \n+# Tier-1 secrets: stripped from EVERY spawned subprocess unconditionally —\n+# even when the caller opts into credential inheritance for a model-driving\n+# CLI (claude / codex / gemini).  These are not LLM provider credentials; no\n+# legitimate child Hermes spawns needs them, and they are the highest-value\n+# secrets to keep out of a compromised dependency's reach (gateway bot tokens,\n+# GitHub auth, remote-compute tokens, dashboard session secret).  The set is a\n+# narrow subset of _HERMES_PROVIDER_ENV_BLOCKLIST; provider keys are handled by\n+# the conditional Tier-2 strip in hermes_subprocess_env().\n+_ALWAYS_STRIP_KEYS: frozenset[str] = frozenset({\n+    # GitHub auth\n+    \"GH_TOKEN\",\n+    \"GITHUB_TOKEN\",\n+    \"GITHUB_APP_ID\",\n+    \"GITHUB_APP_PRIVATE_KEY_PATH\",\n+    \"GITHUB_APP_INSTALLATION_ID\",\n+    # Gateway / messaging bot tokens and access control\n+    \"TELEGRAM_BOT_TOKEN\",\n+    \"DISCORD_BOT_TOKEN\",\n+    \"SLACK_BOT_TOKEN\",\n+    \"SLACK_APP_TOKEN\",\n+    \"SLACK_SIGNING_SECRET\",\n+    \"GATEWAY_ALLOWED_USERS\",\n+    \"GATEWAY_ALLOW_ALL_USERS\",\n+    \"HASS_TOKEN\",\n+    \"EMAIL_PASSWORD\",\n+    \"HERMES_DASHBOARD_SESSION_TOKEN\",\n+    # Remote-compute / infrastructure secrets\n+    \"MODAL_TOKEN_ID\",\n+    \"MODAL_TOKEN_SECRET\",\n+    \"DAYTONA_API_KEY\",\n+})\n+\n+\n+def hermes_subprocess_env(*, inherit_credentials: bool = False) -> dict[str, str]:\n+    \"\"\"Build a sanitized environment dict for a spawned subprocess.\n+\n+    Centralized helper for the **non-terminal** spawn surface (browser,\n+    ACP/CLI executors, computer-use driver, dep-ensure, TUI Node host,\n+    detached gateway).  Use this instead of copying ``os.environ`` directly\n+    so strip-by-default is the uniform policy across every spawn site, with a\n+    single source of truth (``_HERMES_PROVIDER_ENV_BLOCKLIST``).  The terminal\n+    / execute_code path keeps using :func:`_sanitize_subprocess_env`, which is\n+    skill-aware (``env_passthrough``); this helper is for spawns that have no\n+    skill-passthrough concept.\n+\n+    Two-tier stripping:\n+\n+    * **Tier 1 (always):** ``_ALWAYS_STRIP_KEYS`` — gateway bot tokens, GitHub\n+      auth, and remote-compute secrets are removed regardless of\n+      ``inherit_credentials``.  No child Hermes spawns legitimately needs them.\n+    * **Tier 2 (conditional):** the rest of ``_HERMES_PROVIDER_ENV_BLOCKLIST``\n+      (LLM provider API keys, tool secrets) is removed unless the caller passes\n+      ``inherit_credentials=[redacted:secret-assignment]\n+\n+    Pass ``inherit_credentials=[redacted:secret-assignment] **only** when the child legitimately\n+    needs LLM provider credentials — a user-blessed ``claude`` / ``codex`` /\n+    ``gemini`` CLI executor, or the TUI Node host that makes model calls.  The\n+    flag is grep-able for audit: ``grep -rn 'inherit_credentials=True'`` lists\n+    every spawn site that still receives provider credentials.\n+\n+    Callers that need a *specific* non-provider secret (e.g. the browser worker\n+    needs ``BROWSERBASE_API_KEY`` / ``FIRECRAWL_API_KEY``) should call with\n+    ``inherit_credentials=[redacted:secret-assignment] and copy just those keys back from\n+    ``os.environ`` into the returned dict.\n+    \"\"\"\n+    env = os.environ.copy()\n+\n+    # Tier 1 — always strip.\n+    for key in _ALWAYS_STRIP_KEYS:\n+        env.pop(key, None)\n+    # Internal routing hints must never reach a child.\n+    for key in list(env):\n+        if key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX):\n+            env.pop(key, None)\n+\n+    if not inherit_credentials:\n+        # Tier 2 — strip provider/tool credentials unless explicitly inherited.\n+        for key in _HERMES_PROVIDER_ENV_BLOCKLIST:\n+            env.pop(key, None)\n+\n+    # Windows UTF-8 safety for spawned processes (#31420).\n+    env.setdefault(\"PYTHONUTF8\", \"1\")\n+\n+    _inject_context_hermes_home(env)\n+    from hermes_constants import apply_subprocess_home_env\n+    apply_subprocess_home_env(env)\n+\n+    # Active-venv markers must not clobber another project's environment.\n+    for _marker in _ACTIVE_VENV_MARKER_VARS:\n+        env.pop(_marker, None)\n+\n+    return env\n+\n+\n def _find_bash() -> str:\n     \"\"\"Find bash for command execution.\"\"\"\n     if not _IS_WINDOWS:"]},{"file":"tools/lazy_deps.py","hunks":["@@ -613,7 +613,9 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install\n \n     try:\n         venv_root = Path(sys.executable).parent.parent\n-        uv_env = {**os.environ, \"VIRTUAL_ENV\": str(venv_root)}\n+        from tools.environments.local import hermes_subprocess_env\n+        uv_env = hermes_subprocess_env(inherit_credentials=[redacted:secret-assignment]\n+        uv_env[\"VIRTUAL_ENV\"] = str(venv_root)\n \n         # Tier 1: uv (preferred — fast, doesn't need pip in the venv)\n         uv_bin = shutil.which(\"uv\")"]},{"file":"tui_gateway/server.py","hunks":["@@ -25,6 +25,7 @@\n )\n from hermes_cli.env_loader import load_hermes_dotenv\n from utils import is_truthy_value\n+from tools.environments.local import hermes_subprocess_env\n from tui_gateway import git_probe\n from tui_gateway.transport import (\n     StdioTransport,","@@ -284,7 +285,9 @@ def __init__(self, session_key: str, model: str):\n             text=True,\n             bufsize=1,\n             cwd=os.getcwd(),\n-            env=os.environ.copy(),\n+            # slash_worker runs the Hermes agent → needs provider credentials.\n+            # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157).\n+            env=hermes_subprocess_env(inherit_credentials=[redacted:secret-assignment]\n         )\n         threading.Thread(target=self._drain_stdout, daemon=True).start()\n         threading.Thread(target=self._drain_stderr, daemon=True).start()","@@ -11131,7 +11134,9 @@ def _(rid, params: dict) -> dict:\n             text=True,\n             timeout=min(int(params.get(\"timeout\", 240)), 600),\n             cwd=os.getcwd(),\n-            env=os.environ.copy(),\n+            # cli.exec runs `python -m hermes_cli.main` (can drive the agent) →\n+            # needs provider credentials. Tier-1 secrets still stripped (#29157).\n+            env=hermes_subprocess_env(inherit_credentials=[redacted:secret-assignment]\n             stdin=subprocess.DEVNULL,\n         )\n         parts = [r.stdout or \"\", r.stderr or \"\"]\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}