diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 54bb29c5d..16a086fdb 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -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)) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index eb786e9f4..d80b69fbe 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -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.""" diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index b011d765f..2d31c8bf9 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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).""" diff --git a/tests/tools/test_exec_env.py b/tests/tools/test_exec_env.py index a05510af4..47b2c313d 100644 --- a/tests/tools/test_exec_env.py +++ b/tests/tools/test_exec_env.py @@ -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