Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion agent/lsp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,15 @@ def _win_wrap_cmd(cmd: List[str]) -> List[str]:
return cmd

async def _spawn(self) -> None:
env = dict(os.environ)
# Sanitized env, not a raw os.environ copy: LSP servers are
# third-party processes (pyright, gopls, ...) that never need
# Hermes' gateway tokens or provider keys, and a model can trigger
# a spawn just by writing a file in the workspace (#77463).
# hermes_subprocess_env strips Tier-1 secrets unconditionally;
# the language server's own env additions are layered on top.
from tools.environments.local import hermes_subprocess_env

env = hermes_subprocess_env(inherit_credentials=False)
if self._env:
env.update(self._env)

Expand Down
52 changes: 52 additions & 0 deletions tests/agent/lsp/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import atexit
import sys
from unittest.mock import MagicMock

import pytest
Expand Down Expand Up @@ -88,6 +89,57 @@ def test_shutdown_service_idempotent(monkeypatch):
assert fake_svc.shutdown.call_count == 1


def test_lsp_spawn_env_excludes_tier1_and_provider_secrets(monkeypatch, tmp_path):
"""#77463: LSP servers (third-party pyright/gopls/...) must not receive
Hermes' Tier-1 secrets (gateway tokens) OR provider API keys.

E2E with a REAL child: seed both a Tier-1 key and a provider key in the
parent, build the env exactly as the fixed LSPClient._spawn does
(hermes_subprocess_env(inherit_credentials=False) + self._env), spawn a
real Python child that reports which keys it sees in ITS OWN environment,
and assert the secrets are absent while the LSP's own env additions
survive.
"""
import json as _json
import subprocess as _sp

from agent.lsp.client import LSPClient

monkeypatch.setenv("GATEWAY_RELAY_SECRET", "«redacted:tier1-secret»")
monkeypatch.setenv("ANTHROPIC_API_KEY", "«redacted:provider-key»")

client = LSPClient.__new__(LSPClient)
client._command = [sys.executable, "-c", "pass"]
client._env = {"LSP_CUSTOM_OPT": "kept-value"}

# The fixed _spawn builds the env via hermes_subprocess_env then layers
# self._env; replicate that construction and verify it in a real child.
# The construction is the contract under test.
from tools.environments.local import hermes_subprocess_env

env = hermes_subprocess_env(inherit_credentials=False)
env.update(client._env)

probe = (
"import json, os; print(json.dumps({"
"'relay': 'GATEWAY_RELAY_SECRET' in os.environ, "
"'provider': 'ANTHROPIC_API_KEY' in os.environ, "
"'custom': os.environ.get('LSP_CUSTOM_OPT', '')}))"
)
out = _sp.run(
[sys.executable, "-c", probe],
env=env,
capture_output=True,
text=True,
timeout=60,
check=True,
)
result = _json.loads(out.stdout.strip().splitlines()[-1])
assert result["relay"] is False, "Tier-1 relay secret leaked to LSP server"
assert result["provider"] is False, "provider API key leaked to LSP server"
assert result["custom"] == "kept-value", "LSP's own env addition must survive"





Expand Down
47 changes: 47 additions & 0 deletions tests/tui_gateway/test_compute_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,50 @@ def __getattr__(self, name):

assert calls == ["hard" if kind == "hard-only" else "legacy"]
assert emitted[-1]["applied"] is True


def test_compute_host_spawn_env_excludes_tier1_secrets(monkeypatch, tmp_path):
"""#77463: the compute-host child env must come from the sanitized
hermes_subprocess_env, NOT a post-scrub env.update(os.environ) which
re-added every Tier-1 secret (gateway tokens, remote-compute auth).

E2E with a REAL child: seed Tier-1 secrets in the parent, build the env
exactly as the fixed _spawn_locked does (hermes_subprocess_env +
heartbeat/PYTHONPATH additions), spawn a real Python child that reports
which keys it can see in ITS OWN environment, and assert the secrets are
absent while the legitimate additions survive.
"""
import json as _json
import subprocess as _sp

from tools.environments.local import hermes_subprocess_env

monkeypatch.setenv("GATEWAY_RELAY_SECRET", "«redacted:tier1-secret»")
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "«redacted:session»")

# Build the env exactly as the fixed _spawn_locked does.
env = hermes_subprocess_env(inherit_credentials=True)
env["HERMES_COMPUTE_HOST_HEARTBEAT_SECS"] = "5"
env.setdefault("PYTHONPATH", str(tmp_path))

probe = (
"import json, os; print(json.dumps({"
"'relay': 'GATEWAY_RELAY_SECRET' in os.environ, "
"'session': 'HERMES_DASHBOARD_SESSION_TOKEN' in os.environ, "
"'heartbeat': os.environ.get('HERMES_COMPUTE_HOST_HEARTBEAT_SECS', ''), "
"'pythonpath_present': bool(os.environ.get('PYTHONPATH', ''))}))"
)

out = _sp.run(
[sys.executable, "-c", probe],
env=env,
capture_output=True,
text=True,
timeout=60,
check=True,
)
result = _json.loads(out.stdout.strip().splitlines()[-1])
assert result["relay"] is False, "Tier-1 relay secret leaked to compute host"
assert result["session"] is False, "session token leaked to compute host"
assert result["heartbeat"] == "5", "heartbeat must survive"
assert result["pythonpath_present"] is True, "PYTHONPATH must survive"
7 changes: 6 additions & 1 deletion tui_gateway/host_supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,13 @@ def _spawn_locked(self, *, reason: str) -> None:
raise RuntimeError("compute host respawn disabled after crash loop")
self._hello_event.clear()
self._hello = {}
# Use the sanitized non-terminal env — never re-add the full
# os.environ after the scrub. The compute host legitimately needs
# the heartbeat + PYTHONPATH additions below, but inheriting every
# Tier-1 secret (gateway tokens, remote-compute auth) via a
# post-scrub env.update(os.environ) re-opened the leak the scrub
# exists to close (#77463).
env = hermes_subprocess_env(inherit_credentials=True)
env.update(os.environ)
if self.env:
env.update(self.env)
env["HERMES_COMPUTE_HOST_HEARTBEAT_SECS"] = str(self.heartbeat_secs)
Expand Down
Loading