feat(exec): support allowed_env_keys to pass specified env vars to subprocess

Add allowed_env_keys config field to selectively forward host environment variables (e.g. GOPATH, JAVA_HOME) into the sandboxed subprocess environment, while keeping the default allow-list unchanged.
This commit is contained in:
chenyahui 2026-04-09 15:32:57 +08:00 committed by Xubin Ren
parent c625c0c2a7
commit 0e6331b66d
4 changed files with 47 additions and 2 deletions

View File

@ -242,6 +242,7 @@ class AgentLoop:
restrict_to_workspace=self.restrict_to_workspace,
sandbox=self.exec_config.sandbox,
path_append=self.exec_config.path_append,
allowed_env_keys=self.exec_config.allowed_env_keys,
))
if self.web_config.enable:
self.tools.register(WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy))

View File

@ -46,6 +46,7 @@ class ExecTool(Tool):
restrict_to_workspace: bool = False,
sandbox: str = "",
path_append: str = "",
allowed_env_keys: list[str] | None = None,
):
self.timeout = timeout
self.working_dir = working_dir
@ -64,6 +65,7 @@ class ExecTool(Tool):
self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace
self.path_append = path_append
self.allowed_env_keys = allowed_env_keys or []
@property
def name(self) -> str:
@ -208,7 +210,7 @@ class ExecTool(Tool):
"""
if _IS_WINDOWS:
sr = os.environ.get("SYSTEMROOT", r"C:\Windows")
return {
env = {
"SYSTEMROOT": sr,
"COMSPEC": os.environ.get("COMSPEC", f"{sr}\\system32\\cmd.exe"),
"USERPROFILE": os.environ.get("USERPROFILE", ""),
@ -225,12 +227,22 @@ class ExecTool(Tool):
"ProgramFiles(x86)": os.environ.get("ProgramFiles(x86)", ""),
"ProgramW6432": os.environ.get("ProgramW6432", ""),
}
for key in self.allowed_env_keys:
val = os.environ.get(key)
if val is not None:
env[key] = val
return env
home = os.environ.get("HOME", "/tmp")
return {
env = {
"HOME": home,
"LANG": os.environ.get("LANG", "C.UTF-8"),
"TERM": os.environ.get("TERM", "dumb"),
}
for key in self.allowed_env_keys:
val = os.environ.get(key)
if val is not None:
env[key] = val
return env
def _guard_command(self, command: str, cwd: str) -> str | None:
"""Best-effort safety guard for potentially destructive commands."""

View File

@ -177,6 +177,7 @@ class ExecToolConfig(Base):
timeout: int = 60
path_append: str = ""
sandbox: str = "" # sandbox backend: "" (none) or "bwrap"
allowed_env_keys: list[str] = Field(default_factory=list) # Env var names to pass through to subprocess (e.g. ["GOPATH", "JAVA_HOME"])
class MCPServerConfig(Base):
"""MCP server connection configuration (stdio or HTTP)."""

View File

@ -43,3 +43,34 @@ async def test_exec_path_append_preserves_system_path():
tool = ExecTool(path_append="/opt/custom/bin")
result = await tool.execute(command="ls /")
assert "Exit code: 0" in result
@_UNIX_ONLY
@pytest.mark.asyncio
async def test_exec_allowed_env_keys_passthrough(monkeypatch):
"""Env vars listed in allowed_env_keys should be visible to commands."""
monkeypatch.setenv("MY_CUSTOM_VAR", "hello-from-config")
tool = ExecTool(allowed_env_keys=["MY_CUSTOM_VAR"])
result = await tool.execute(command="printenv MY_CUSTOM_VAR")
assert "hello-from-config" in result
@_UNIX_ONLY
@pytest.mark.asyncio
async def test_exec_allowed_env_keys_does_not_leak_others(monkeypatch):
"""Env vars NOT in allowed_env_keys should still be blocked."""
monkeypatch.setenv("MY_CUSTOM_VAR", "hello-from-config")
monkeypatch.setenv("MY_SECRET_VAR", "secret-value")
tool = ExecTool(allowed_env_keys=["MY_CUSTOM_VAR"])
result = await tool.execute(command="printenv MY_SECRET_VAR")
assert "secret-value" not in result
@_UNIX_ONLY
@pytest.mark.asyncio
async def test_exec_allowed_env_keys_missing_var_ignored(monkeypatch):
"""If an allowed key is not set in the parent process, it should be silently skipped."""
monkeypatch.delenv("NONEXISTENT_VAR_12345", raising=False)
tool = ExecTool(allowed_env_keys=["NONEXISTENT_VAR_12345"])
result = await tool.execute(command="printenv NONEXISTENT_VAR_12345")
assert "Exit code: 1" in result