Skip to content

Commit 787c0a7

Browse files
committed
fix(mcp-proxy): minimize approval center child env
Start managed Approval Center processes with a bounded environment instead of inheriting the full parent shell. Cover shared, launcher, and legacy Claude lifecycle spawn paths.\n\nImplemented with assistance from Codex.
1 parent 2cd8cc9 commit 787c0a7

5 files changed

Lines changed: 222 additions & 24 deletions

File tree

packages/agentveil-mcp-proxy/agentveil_mcp_proxy/agent_launcher.py

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -623,24 +623,12 @@ def _proxy_cli_argv(proxy_command: str, subcommand: list[str]) -> list[str]:
623623
return [proxy_command, *subcommand]
624624

625625

626-
def _proxy_cli_child_env() -> dict[str, str]:
626+
def _proxy_cli_child_env(*, passphrase_file: Path | None = None) -> dict[str, str]:
627627
"""Return an env that lets child interpreters import this package reliably."""
628628

629-
env = dict(os.environ)
630-
package_root_path = Path(__file__).resolve().parents[1]
631-
source_roots = [str(package_root_path)]
632-
repo_root = package_root_path.parents[1]
633-
if (repo_root / "agentveil").is_dir():
634-
source_roots.append(str(repo_root))
635-
existing = env.get("PYTHONPATH")
636-
if existing:
637-
paths = existing.split(os.pathsep)
638-
prefix = [path for path in source_roots if path not in paths]
639-
if prefix:
640-
env["PYTHONPATH"] = os.pathsep.join([*prefix, existing])
641-
else:
642-
env["PYTHONPATH"] = os.pathsep.join(source_roots)
643-
return env
629+
from agentveil_mcp_proxy.approval.server import _proxy_cli_child_env as _center_child_env
630+
631+
return _center_child_env(passphrase_file=passphrase_file)
644632

645633

646634
def _proxy_command_display(proxy_command: str) -> str:
@@ -738,7 +726,7 @@ def _spawn_approval_center(
738726
"stdout": log_handle,
739727
"stderr": log_handle,
740728
"close_fds": True,
741-
"env": _proxy_cli_child_env(),
729+
"env": _proxy_cli_child_env(passphrase_file=passphrase_file),
742730
}
743731
if os.name == "posix":
744732
kwargs["start_new_session"] = True

packages/agentveil-mcp-proxy/agentveil_mcp_proxy/approval/server.py

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1612,21 +1612,85 @@ def _proxy_cli_argv(proxy_command: str, subcommand: list[str]) -> list[str]:
16121612
return [proxy_command, *subcommand]
16131613

16141614

1615-
def _proxy_cli_child_env() -> dict[str, str]:
1616-
env = dict(os.environ)
1615+
_MANAGED_CENTER_ENV_KEYS = (
1616+
"PATH",
1617+
"USER",
1618+
"LOGNAME",
1619+
"SHELL",
1620+
"HOME",
1621+
"LANG",
1622+
"LC_ALL",
1623+
"LC_CTYPE",
1624+
"LC_MESSAGES",
1625+
"LC_NUMERIC",
1626+
"LC_TIME",
1627+
"TMPDIR",
1628+
"TEMP",
1629+
"TMP",
1630+
"TZ",
1631+
"TERM",
1632+
"SYSTEMROOT",
1633+
"WINDIR",
1634+
"COMSPEC",
1635+
"PATHEXT",
1636+
"PYTHONHOME",
1637+
"VIRTUAL_ENV",
1638+
)
1639+
1640+
_BLOCKED_MANAGED_CENTER_ENV_KEYS = frozenset({
1641+
"OPENAI_API_KEY",
1642+
"DEEPSEEK_API_KEY",
1643+
"AWS_SECRET_ACCESS_KEY",
1644+
})
1645+
1646+
_BLOCKED_MANAGED_CENTER_ENV_SUFFIXES = ("_TOKEN", "_SECRET", "_PASSWORD")
1647+
1648+
1649+
def _managed_center_env_key_is_blocked(key: str) -> bool:
1650+
upper = key.upper()
1651+
if upper in _BLOCKED_MANAGED_CENTER_ENV_KEYS:
1652+
return True
1653+
return any(upper.endswith(suffix) for suffix in _BLOCKED_MANAGED_CENTER_ENV_SUFFIXES)
1654+
1655+
1656+
def _managed_center_pythonpath(*, parent_env: Mapping[str, str]) -> str:
16171657
package_root_path = Path(__file__).resolve().parents[2]
16181658
source_roots = [str(package_root_path)]
16191659
repo_root = package_root_path.parents[1]
16201660
if (repo_root / "agentveil").is_dir():
16211661
source_roots.append(str(repo_root))
1622-
existing = env.get("PYTHONPATH")
1662+
existing = parent_env.get("PYTHONPATH")
16231663
if existing:
16241664
paths = existing.split(os.pathsep)
16251665
prefix = [path for path in source_roots if path not in paths]
16261666
if prefix:
1627-
env["PYTHONPATH"] = os.pathsep.join([*prefix, existing])
1628-
else:
1629-
env["PYTHONPATH"] = os.pathsep.join(source_roots)
1667+
return os.pathsep.join([*prefix, existing])
1668+
return existing
1669+
return os.pathsep.join(source_roots)
1670+
1671+
1672+
def _proxy_cli_child_env(
1673+
*,
1674+
passphrase_file: Path | None = None,
1675+
parent_env: Mapping[str, str] | None = None,
1676+
) -> dict[str, str]:
1677+
"""Return a bounded env for managed Approval Center child processes."""
1678+
1679+
from agentveil_mcp_proxy.identity import PASSPHRASE_ENV
1680+
1681+
source = dict(os.environ if parent_env is None else parent_env)
1682+
env: dict[str, str] = {}
1683+
for key in _MANAGED_CENTER_ENV_KEYS:
1684+
if _managed_center_env_key_is_blocked(key):
1685+
continue
1686+
value = source.get(key)
1687+
if isinstance(value, str) and value:
1688+
env[key] = value
1689+
env["PYTHONPATH"] = _managed_center_pythonpath(parent_env=source)
1690+
if passphrase_file is None:
1691+
passphrase = source.get(PASSPHRASE_ENV)
1692+
if isinstance(passphrase, str) and passphrase:
1693+
env[PASSPHRASE_ENV] = passphrase
16301694
return env
16311695

16321696

@@ -1957,7 +2021,7 @@ def spawn_managed_approval_center_process(
19572021
"stdout": log_handle,
19582022
"stderr": log_handle,
19592023
"close_fds": True,
1960-
"env": _proxy_cli_child_env(),
2024+
"env": _proxy_cli_child_env(passphrase_file=passphrase_file),
19612025
}
19622026
if os.name == "posix":
19632027
kwargs["start_new_session"] = True

packages/agentveil-mcp-proxy/agentveil_mcp_proxy/claude_center_lifecycle.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ def _spawn_center(
143143
]
144144
if passphrase_file is not None:
145145
args.extend(["--passphrase-file", str(passphrase_file)])
146+
from agentveil_mcp_proxy.approval.server import _proxy_cli_child_env
147+
148+
kwargs["env"] = _proxy_cli_child_env(passphrase_file=passphrase_file)
146149
return subprocess.Popen( # noqa: S603 - args constructed from validated paths
147150
args,
148151
**kwargs,

packages/agentveil-mcp-proxy/tests/test_mcp_proxy_agent_launcher.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,42 @@ def fake_popen(_command, **kwargs):
218218
assert Path(child_home).name == "home"
219219

220220

221+
def test_launcher_approval_center_spawn_uses_bounded_env(tmp_path, monkeypatch):
222+
from agentveil_mcp_proxy import agent_launcher
223+
from agentveil_mcp_proxy.identity import PASSPHRASE_ENV
224+
225+
captured: dict[str, dict[str, str]] = {}
226+
227+
def fake_popen(_args, **kwargs):
228+
captured["env"] = kwargs["env"]
229+
return SimpleNamespace(pid=7171)
230+
231+
monkeypatch.setenv("OPENAI_API_KEY", PROVIDER_API_KEY)
232+
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "aws-secret")
233+
monkeypatch.setenv("SECRET_TOKEN", "token-secret")
234+
monkeypatch.setenv(PASSPHRASE_ENV, "passphrase-secret")
235+
monkeypatch.setattr(agent_launcher.subprocess, "Popen", fake_popen)
236+
237+
home = tmp_path / "project" / ".avp"
238+
(home / "mcp-proxy").mkdir(parents=True)
239+
(home / "mcp-proxy" / "config.json").write_text("{}", encoding="utf-8")
240+
passphrase_file = tmp_path / "passphrase.txt"
241+
passphrase_file.write_text("passphrase-secret\n", encoding="utf-8")
242+
243+
agent_launcher._spawn_approval_center(
244+
proxy_command="agentveil-mcp-proxy",
245+
home=home,
246+
passphrase_file=passphrase_file,
247+
)
248+
249+
env = captured["env"]
250+
assert "OPENAI_API_KEY" not in env
251+
assert "AWS_SECRET_ACCESS_KEY" not in env
252+
assert "SECRET_TOKEN" not in env
253+
assert PASSPHRASE_ENV not in env
254+
assert "PYTHONPATH" in env
255+
256+
221257
SECRET_COMMAND_TOKEN = "SECRET_TOKEN_DO_NOT_PERSIST_IN_MANIFEST"
222258
PROVIDER_API_KEY = "sk-FAKE_PROVIDER_KEY_DO_NOT_PERSIST"
223259

packages/agentveil-mcp-proxy/tests/test_mcp_proxy_approval_center_lifecycle.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import signal
77
import sys
88
from pathlib import Path
9+
from types import SimpleNamespace
910

1011
import pytest
1112

@@ -248,6 +249,112 @@ def test_managed_center_child_env_includes_package_root(monkeypatch):
248249
assert (Path(package_root) / "agentveil_mcp_proxy").is_dir()
249250

250251

252+
def test_managed_center_child_env_excludes_provider_and_generic_secrets():
253+
from agentveil_mcp_proxy.approval import server
254+
from agentveil_mcp_proxy.identity import PASSPHRASE_ENV
255+
256+
parent_env = {
257+
"PATH": "/usr/bin",
258+
"LANG": "C",
259+
"OPENAI_API_KEY": "sk-test",
260+
"DEEPSEEK_API_KEY": "sk-test",
261+
"AWS_SECRET_ACCESS_KEY": "secret",
262+
"SECRET_TOKEN": "secret",
263+
PASSPHRASE_ENV: "pass",
264+
}
265+
env = server._proxy_cli_child_env(parent_env=parent_env)
266+
267+
assert env["PATH"] == "/usr/bin"
268+
assert env["LANG"] == "C"
269+
assert "OPENAI_API_KEY" not in env
270+
assert "DEEPSEEK_API_KEY" not in env
271+
assert "AWS_SECRET_ACCESS_KEY" not in env
272+
assert "SECRET_TOKEN" not in env
273+
assert env[PASSPHRASE_ENV] == "pass"
274+
package_root = Path(server.__file__).resolve().parents[2]
275+
assert env["PYTHONPATH"].split(os.pathsep) == _expected_child_pythonpath_roots(
276+
package_root
277+
)
278+
279+
280+
def test_managed_center_child_env_omits_passphrase_when_passphrase_file_used():
281+
from agentveil_mcp_proxy.approval import server
282+
from agentveil_mcp_proxy.identity import PASSPHRASE_ENV
283+
284+
parent_env = {
285+
"PATH": "/usr/bin",
286+
PASSPHRASE_ENV: "pass",
287+
"OPENAI_API_KEY": "sk-test",
288+
}
289+
env = server._proxy_cli_child_env(
290+
parent_env=parent_env,
291+
passphrase_file=Path("/tmp/demo.passphrase"),
292+
)
293+
294+
assert PASSPHRASE_ENV not in env
295+
assert "OPENAI_API_KEY" not in env
296+
297+
298+
def test_managed_center_child_env_preserves_existing_pythonpath(monkeypatch):
299+
from agentveil_mcp_proxy.approval import server
300+
301+
env = server._proxy_cli_child_env(
302+
parent_env={"PATH": "/usr/bin", "PYTHONPATH": "existing-path"},
303+
)
304+
package_root = Path(server.__file__).resolve().parents[2]
305+
expected_prefix = _expected_child_pythonpath_roots(package_root)
306+
307+
assert env["PYTHONPATH"].split(os.pathsep) == [*expected_prefix, "existing-path"]
308+
309+
310+
def test_managed_center_child_env_empty_parent_does_not_read_os_environ(monkeypatch):
311+
from agentveil_mcp_proxy.approval import server
312+
313+
monkeypatch.setenv("PATH", "/must-not-leak")
314+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
315+
316+
env = server._proxy_cli_child_env(parent_env={})
317+
318+
assert "PATH" not in env
319+
assert "OPENAI_API_KEY" not in env
320+
assert env["PYTHONPATH"].split(os.pathsep) == _expected_child_pythonpath_roots(
321+
Path(server.__file__).resolve().parents[2]
322+
)
323+
324+
325+
def test_claude_center_spawn_uses_bounded_env(tmp_path, monkeypatch):
326+
from agentveil_mcp_proxy import claude_center_lifecycle
327+
from agentveil_mcp_proxy.identity import PASSPHRASE_ENV
328+
329+
captured: dict[str, dict[str, str]] = {}
330+
331+
def fake_popen(argv, **kwargs):
332+
captured["env"] = kwargs["env"]
333+
return SimpleNamespace(pid=999999, poll=lambda: 0)
334+
335+
monkeypatch.setattr(claude_center_lifecycle.subprocess, "Popen", fake_popen)
336+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
337+
monkeypatch.setenv(PASSPHRASE_ENV, "pass")
338+
339+
home = tmp_path / "project" / ".avp"
340+
home.mkdir(parents=True)
341+
(home / "mcp-proxy").mkdir()
342+
(home / "mcp-proxy" / "config.json").write_text("{}", encoding="utf-8")
343+
passphrase_file = tmp_path / "demo.passphrase"
344+
passphrase_file.write_text("secret\n", encoding="utf-8")
345+
346+
claude_center_lifecycle._spawn_center(
347+
proxy_command=sys.executable,
348+
home=home,
349+
passphrase_file=passphrase_file,
350+
)
351+
352+
env = captured["env"]
353+
assert "OPENAI_API_KEY" not in env
354+
assert PASSPHRASE_ENV not in env
355+
assert "PATH" in env
356+
357+
251358
def test_stale_manifest_is_not_running(managed_project_home):
252359
_project, home, _tracker = managed_project_home
253360
status = inspect_managed_approval_center(home)

0 commit comments

Comments
 (0)