mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-30 23:05:51 +00:00
Merge PR #1940: feat: sandbox exec calls with bwrap and run container as non-root
feat: sandbox exec calls with bwrap and run container as non-root (minimally fixes #1873)
This commit is contained in:
commit
5e01a910bf
11
Dockerfile
11
Dockerfile
@ -2,7 +2,7 @@ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
|
|||||||
|
|
||||||
# Install Node.js 20 for the WhatsApp bridge
|
# Install Node.js 20 for the WhatsApp bridge
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends curl ca-certificates gnupg git openssh-client && \
|
apt-get install -y --no-install-recommends curl ca-certificates gnupg git bubblewrap openssh-client && \
|
||||||
mkdir -p /etc/apt/keyrings && \
|
mkdir -p /etc/apt/keyrings && \
|
||||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \
|
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \
|
||||||
@ -32,8 +32,13 @@ RUN git config --global --add url."https://github.com/".insteadOf ssh://git@gith
|
|||||||
npm install && npm run build
|
npm install && npm run build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Create config directory
|
# Create non-root user and config directory
|
||||||
RUN mkdir -p /root/.nanobot
|
RUN useradd -m -u 1000 -s /bin/bash nanobot && \
|
||||||
|
mkdir -p /home/nanobot/.nanobot && \
|
||||||
|
chown -R nanobot:nanobot /home/nanobot /app
|
||||||
|
|
||||||
|
USER nanobot
|
||||||
|
ENV HOME=/home/nanobot
|
||||||
|
|
||||||
# Gateway default port
|
# Gateway default port
|
||||||
EXPOSE 18790
|
EXPOSE 18790
|
||||||
|
|||||||
@ -1434,16 +1434,19 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
|||||||
### Security
|
### Security
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
|
> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent.
|
||||||
> In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all senders. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default. To allow all senders, set `"allowFrom": ["*"]`.
|
> In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all senders. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default. To allow all senders, set `"allowFrom": ["*"]`.
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
|
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
|
||||||
|
| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
|
||||||
| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
|
| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
|
||||||
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
|
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
|
||||||
| `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. |
|
| `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. |
|
||||||
|
|
||||||
|
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation).
|
||||||
|
|
||||||
|
|
||||||
### Timezone
|
### Timezone
|
||||||
|
|
||||||
|
|||||||
20
SECURITY.md
20
SECURITY.md
@ -64,6 +64,7 @@ chmod 600 ~/.nanobot/config.json
|
|||||||
|
|
||||||
The `exec` tool can execute shell commands. While dangerous command patterns are blocked, you should:
|
The `exec` tool can execute shell commands. While dangerous command patterns are blocked, you should:
|
||||||
|
|
||||||
|
- ✅ **Enable the bwrap sandbox** (`"tools.exec.sandbox": "bwrap"`) for kernel-level isolation (Linux only)
|
||||||
- ✅ Review all tool usage in agent logs
|
- ✅ Review all tool usage in agent logs
|
||||||
- ✅ Understand what commands the agent is running
|
- ✅ Understand what commands the agent is running
|
||||||
- ✅ Use a dedicated user account with limited privileges
|
- ✅ Use a dedicated user account with limited privileges
|
||||||
@ -71,6 +72,19 @@ The `exec` tool can execute shell commands. While dangerous command patterns are
|
|||||||
- ❌ Don't disable security checks
|
- ❌ Don't disable security checks
|
||||||
- ❌ Don't run on systems with sensitive data without careful review
|
- ❌ Don't run on systems with sensitive data without careful review
|
||||||
|
|
||||||
|
**Exec sandbox (bwrap):**
|
||||||
|
|
||||||
|
On Linux, set `"tools.exec.sandbox": "bwrap"` to wrap every shell command in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox. This uses Linux kernel namespaces to restrict what the process can see:
|
||||||
|
|
||||||
|
- Workspace directory → **read-write** (agent works normally)
|
||||||
|
- Media directory → **read-only** (can read uploaded attachments)
|
||||||
|
- System directories (`/usr`, `/bin`, `/lib`) → **read-only** (commands still work)
|
||||||
|
- Config files and API keys (`~/.nanobot/config.json`) → **hidden** (masked by tmpfs)
|
||||||
|
|
||||||
|
Requires `bwrap` installed (`apt install bubblewrap`). Pre-installed in the official Docker image. **Not available on macOS or Windows** — bubblewrap depends on Linux kernel namespaces.
|
||||||
|
|
||||||
|
Enabling the sandbox also automatically activates `restrictToWorkspace` for file tools.
|
||||||
|
|
||||||
**Blocked patterns:**
|
**Blocked patterns:**
|
||||||
- `rm -rf /` - Root filesystem deletion
|
- `rm -rf /` - Root filesystem deletion
|
||||||
- Fork bombs
|
- Fork bombs
|
||||||
@ -82,6 +96,7 @@ The `exec` tool can execute shell commands. While dangerous command patterns are
|
|||||||
|
|
||||||
File operations have path traversal protection, but:
|
File operations have path traversal protection, but:
|
||||||
|
|
||||||
|
- ✅ Enable `restrictToWorkspace` or the bwrap sandbox to confine file access
|
||||||
- ✅ Run nanobot with a dedicated user account
|
- ✅ Run nanobot with a dedicated user account
|
||||||
- ✅ Use filesystem permissions to protect sensitive directories
|
- ✅ Use filesystem permissions to protect sensitive directories
|
||||||
- ✅ Regularly audit file operations in logs
|
- ✅ Regularly audit file operations in logs
|
||||||
@ -232,7 +247,7 @@ If you suspect a security breach:
|
|||||||
1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed)
|
1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed)
|
||||||
2. **Plain Text Config** - API keys stored in plain text (use keyring for production)
|
2. **Plain Text Config** - API keys stored in plain text (use keyring for production)
|
||||||
3. **No Session Management** - No automatic session expiry
|
3. **No Session Management** - No automatic session expiry
|
||||||
4. **Limited Command Filtering** - Only blocks obvious dangerous patterns
|
4. **Limited Command Filtering** - Only blocks obvious dangerous patterns (enable the bwrap sandbox for kernel-level isolation on Linux)
|
||||||
5. **No Audit Trail** - Limited security event logging (enhance as needed)
|
5. **No Audit Trail** - Limited security event logging (enhance as needed)
|
||||||
|
|
||||||
## Security Checklist
|
## Security Checklist
|
||||||
@ -243,6 +258,7 @@ Before deploying nanobot:
|
|||||||
- [ ] Config file permissions set to 0600
|
- [ ] Config file permissions set to 0600
|
||||||
- [ ] `allowFrom` lists configured for all channels
|
- [ ] `allowFrom` lists configured for all channels
|
||||||
- [ ] Running as non-root user
|
- [ ] Running as non-root user
|
||||||
|
- [ ] Exec sandbox enabled (`"tools.exec.sandbox": "bwrap"`) on Linux deployments
|
||||||
- [ ] File system permissions properly restricted
|
- [ ] File system permissions properly restricted
|
||||||
- [ ] Dependencies updated to latest secure versions
|
- [ ] Dependencies updated to latest secure versions
|
||||||
- [ ] Logs monitored for security events
|
- [ ] Logs monitored for security events
|
||||||
@ -252,7 +268,7 @@ Before deploying nanobot:
|
|||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
**Last Updated**: 2026-02-03
|
**Last Updated**: 2026-04-05
|
||||||
|
|
||||||
For the latest security updates and announcements, check:
|
For the latest security updates and announcements, check:
|
||||||
- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories
|
- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories
|
||||||
|
|||||||
@ -3,7 +3,14 @@ x-common-config: &common-config
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
volumes:
|
volumes:
|
||||||
- ~/.nanobot:/root/.nanobot
|
- ~/.nanobot:/home/nanobot/.nanobot
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- SYS_ADMIN
|
||||||
|
security_opt:
|
||||||
|
- apparmor=unconfined
|
||||||
|
- seccomp=unconfined
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nanobot-gateway:
|
nanobot-gateway:
|
||||||
|
|||||||
@ -262,7 +262,7 @@ class AgentLoop:
|
|||||||
|
|
||||||
def _register_default_tools(self) -> None:
|
def _register_default_tools(self) -> None:
|
||||||
"""Register the default set of tools."""
|
"""Register the default set of tools."""
|
||||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
allowed_dir = self.workspace if (self.restrict_to_workspace or self.exec_config.sandbox) else None
|
||||||
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
|
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
|
||||||
self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read))
|
self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read))
|
||||||
for cls in (WriteFileTool, EditFileTool, ListDirTool):
|
for cls in (WriteFileTool, EditFileTool, ListDirTool):
|
||||||
@ -274,6 +274,7 @@ class AgentLoop:
|
|||||||
working_dir=str(self.workspace),
|
working_dir=str(self.workspace),
|
||||||
timeout=self.exec_config.timeout,
|
timeout=self.exec_config.timeout,
|
||||||
restrict_to_workspace=self.restrict_to_workspace,
|
restrict_to_workspace=self.restrict_to_workspace,
|
||||||
|
sandbox=self.exec_config.sandbox,
|
||||||
path_append=self.exec_config.path_append,
|
path_append=self.exec_config.path_append,
|
||||||
))
|
))
|
||||||
if self.web_config.enable:
|
if self.web_config.enable:
|
||||||
|
|||||||
@ -111,7 +111,7 @@ class SubagentManager:
|
|||||||
try:
|
try:
|
||||||
# Build subagent tools (no message tool, no spawn tool)
|
# Build subagent tools (no message tool, no spawn tool)
|
||||||
tools = ToolRegistry()
|
tools = ToolRegistry()
|
||||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
allowed_dir = self.workspace if (self.restrict_to_workspace or self.exec_config.sandbox) else None
|
||||||
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
|
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
|
||||||
tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read))
|
tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read))
|
||||||
tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||||
@ -124,6 +124,7 @@ class SubagentManager:
|
|||||||
working_dir=str(self.workspace),
|
working_dir=str(self.workspace),
|
||||||
timeout=self.exec_config.timeout,
|
timeout=self.exec_config.timeout,
|
||||||
restrict_to_workspace=self.restrict_to_workspace,
|
restrict_to_workspace=self.restrict_to_workspace,
|
||||||
|
sandbox=self.exec_config.sandbox,
|
||||||
path_append=self.exec_config.path_append,
|
path_append=self.exec_config.path_append,
|
||||||
))
|
))
|
||||||
if self.web_config.enable:
|
if self.web_config.enable:
|
||||||
|
|||||||
55
nanobot/agent/tools/sandbox.py
Normal file
55
nanobot/agent/tools/sandbox.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"""Sandbox backends for shell command execution.
|
||||||
|
|
||||||
|
To add a new backend, implement a function with the signature:
|
||||||
|
_wrap_<name>(command: str, workspace: str, cwd: str) -> str
|
||||||
|
and register it in _BACKENDS below.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nanobot.config.paths import get_media_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _bwrap(command: str, workspace: str, cwd: str) -> str:
|
||||||
|
"""Wrap command in a bubblewrap sandbox (requires bwrap in container).
|
||||||
|
|
||||||
|
Only the workspace is bind-mounted read-write; its parent dir (which holds
|
||||||
|
config.json) is hidden behind a fresh tmpfs. The media directory is
|
||||||
|
bind-mounted read-only so exec commands can read uploaded attachments.
|
||||||
|
"""
|
||||||
|
ws = Path(workspace).resolve()
|
||||||
|
media = get_media_dir().resolve()
|
||||||
|
|
||||||
|
try:
|
||||||
|
sandbox_cwd = str(ws / Path(cwd).resolve().relative_to(ws))
|
||||||
|
except ValueError:
|
||||||
|
sandbox_cwd = str(ws)
|
||||||
|
|
||||||
|
required = ["/usr"]
|
||||||
|
optional = ["/bin", "/lib", "/lib64", "/etc/alternatives",
|
||||||
|
"/etc/ssl/certs", "/etc/resolv.conf", "/etc/ld.so.cache"]
|
||||||
|
|
||||||
|
args = ["bwrap", "--new-session", "--die-with-parent"]
|
||||||
|
for p in required: args += ["--ro-bind", p, p]
|
||||||
|
for p in optional: args += ["--ro-bind-try", p, p]
|
||||||
|
args += [
|
||||||
|
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp",
|
||||||
|
"--tmpfs", str(ws.parent), # mask config dir
|
||||||
|
"--dir", str(ws), # recreate workspace mount point
|
||||||
|
"--bind", str(ws), str(ws),
|
||||||
|
"--ro-bind-try", str(media), str(media), # read-only access to media
|
||||||
|
"--chdir", sandbox_cwd,
|
||||||
|
"--", "sh", "-c", command,
|
||||||
|
]
|
||||||
|
return shlex.join(args)
|
||||||
|
|
||||||
|
|
||||||
|
_BACKENDS = {"bwrap": _bwrap}
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_command(sandbox: str, command: str, workspace: str, cwd: str) -> str:
|
||||||
|
"""Wrap *command* using the named sandbox backend."""
|
||||||
|
if backend := _BACKENDS.get(sandbox):
|
||||||
|
return backend(command, workspace, cwd)
|
||||||
|
raise ValueError(f"Unknown sandbox backend {sandbox!r}. Available: {list(_BACKENDS)}")
|
||||||
@ -10,6 +10,7 @@ from typing import Any
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
|
from nanobot.agent.tools.sandbox import wrap_command
|
||||||
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
|
|
||||||
@ -40,10 +41,12 @@ class ExecTool(Tool):
|
|||||||
deny_patterns: list[str] | None = None,
|
deny_patterns: list[str] | None = None,
|
||||||
allow_patterns: list[str] | None = None,
|
allow_patterns: list[str] | None = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
|
sandbox: str = "",
|
||||||
path_append: str = "",
|
path_append: str = "",
|
||||||
):
|
):
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.working_dir = working_dir
|
self.working_dir = working_dir
|
||||||
|
self.sandbox = sandbox
|
||||||
self.deny_patterns = deny_patterns or [
|
self.deny_patterns = deny_patterns or [
|
||||||
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
||||||
r"\bdel\s+/[fq]\b", # del /f, del /q
|
r"\bdel\s+/[fq]\b", # del /f, del /q
|
||||||
@ -83,6 +86,11 @@ class ExecTool(Tool):
|
|||||||
if guard_error:
|
if guard_error:
|
||||||
return guard_error
|
return guard_error
|
||||||
|
|
||||||
|
if self.sandbox:
|
||||||
|
workspace = self.working_dir or cwd
|
||||||
|
command = wrap_command(self.sandbox, command, workspace, cwd)
|
||||||
|
cwd = str(Path(workspace).resolve())
|
||||||
|
|
||||||
effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
|
effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT)
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
|||||||
@ -174,6 +174,7 @@ class ExecToolConfig(Base):
|
|||||||
enable: bool = True
|
enable: bool = True
|
||||||
timeout: int = 60
|
timeout: int = 60
|
||||||
path_append: str = ""
|
path_append: str = ""
|
||||||
|
sandbox: str = "" # sandbox backend: "" (none) or "bwrap"
|
||||||
|
|
||||||
class MCPServerConfig(Base):
|
class MCPServerConfig(Base):
|
||||||
"""MCP server connection configuration (stdio or HTTP)."""
|
"""MCP server connection configuration (stdio or HTTP)."""
|
||||||
@ -192,7 +193,7 @@ class ToolsConfig(Base):
|
|||||||
|
|
||||||
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
|
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
|
||||||
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
|
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
|
||||||
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
|
restrict_to_workspace: bool = False # restrict all tool access to workspace directory
|
||||||
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
||||||
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
||||||
|
|
||||||
|
|||||||
121
tests/tools/test_sandbox.py
Normal file
121
tests/tools/test_sandbox.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
"""Tests for nanobot.agent.tools.sandbox."""
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.tools.sandbox import wrap_command
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(cmd: str) -> list[str]:
|
||||||
|
"""Split a wrapped command back into tokens for assertion."""
|
||||||
|
return shlex.split(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBwrapBackend:
|
||||||
|
def test_basic_structure(self, tmp_path):
|
||||||
|
ws = str(tmp_path / "project")
|
||||||
|
result = wrap_command("bwrap", "echo hi", ws, ws)
|
||||||
|
tokens = _parse(result)
|
||||||
|
|
||||||
|
assert tokens[0] == "bwrap"
|
||||||
|
assert "--new-session" in tokens
|
||||||
|
assert "--die-with-parent" in tokens
|
||||||
|
assert "--ro-bind" in tokens
|
||||||
|
assert "--proc" in tokens
|
||||||
|
assert "--dev" in tokens
|
||||||
|
assert "--tmpfs" in tokens
|
||||||
|
|
||||||
|
sep = tokens.index("--")
|
||||||
|
assert tokens[sep + 1:] == ["sh", "-c", "echo hi"]
|
||||||
|
|
||||||
|
def test_workspace_bind_mounted_rw(self, tmp_path):
|
||||||
|
ws = str(tmp_path / "project")
|
||||||
|
result = wrap_command("bwrap", "ls", ws, ws)
|
||||||
|
tokens = _parse(result)
|
||||||
|
|
||||||
|
bind_idx = [i for i, t in enumerate(tokens) if t == "--bind"]
|
||||||
|
assert any(tokens[i + 1] == ws and tokens[i + 2] == ws for i in bind_idx)
|
||||||
|
|
||||||
|
def test_parent_dir_masked_with_tmpfs(self, tmp_path):
|
||||||
|
ws = tmp_path / "project"
|
||||||
|
result = wrap_command("bwrap", "ls", str(ws), str(ws))
|
||||||
|
tokens = _parse(result)
|
||||||
|
|
||||||
|
tmpfs_indices = [i for i, t in enumerate(tokens) if t == "--tmpfs"]
|
||||||
|
tmpfs_targets = {tokens[i + 1] for i in tmpfs_indices}
|
||||||
|
assert str(ws.parent) in tmpfs_targets
|
||||||
|
|
||||||
|
def test_cwd_inside_workspace(self, tmp_path):
|
||||||
|
ws = tmp_path / "project"
|
||||||
|
sub = ws / "src" / "lib"
|
||||||
|
result = wrap_command("bwrap", "pwd", str(ws), str(sub))
|
||||||
|
tokens = _parse(result)
|
||||||
|
|
||||||
|
chdir_idx = tokens.index("--chdir")
|
||||||
|
assert tokens[chdir_idx + 1] == str(sub)
|
||||||
|
|
||||||
|
def test_cwd_outside_workspace_falls_back(self, tmp_path):
|
||||||
|
ws = tmp_path / "project"
|
||||||
|
outside = tmp_path / "other"
|
||||||
|
result = wrap_command("bwrap", "pwd", str(ws), str(outside))
|
||||||
|
tokens = _parse(result)
|
||||||
|
|
||||||
|
chdir_idx = tokens.index("--chdir")
|
||||||
|
assert tokens[chdir_idx + 1] == str(ws.resolve())
|
||||||
|
|
||||||
|
def test_command_with_special_characters(self, tmp_path):
|
||||||
|
ws = str(tmp_path / "project")
|
||||||
|
cmd = "echo 'hello world' && cat \"file with spaces.txt\""
|
||||||
|
result = wrap_command("bwrap", cmd, ws, ws)
|
||||||
|
tokens = _parse(result)
|
||||||
|
|
||||||
|
sep = tokens.index("--")
|
||||||
|
assert tokens[sep + 1:] == ["sh", "-c", cmd]
|
||||||
|
|
||||||
|
def test_system_dirs_ro_bound(self, tmp_path):
|
||||||
|
ws = str(tmp_path / "project")
|
||||||
|
result = wrap_command("bwrap", "ls", ws, ws)
|
||||||
|
tokens = _parse(result)
|
||||||
|
|
||||||
|
ro_bind_indices = [i for i, t in enumerate(tokens) if t == "--ro-bind"]
|
||||||
|
ro_targets = {tokens[i + 1] for i in ro_bind_indices}
|
||||||
|
assert "/usr" in ro_targets
|
||||||
|
|
||||||
|
def test_optional_dirs_use_ro_bind_try(self, tmp_path):
|
||||||
|
ws = str(tmp_path / "project")
|
||||||
|
result = wrap_command("bwrap", "ls", ws, ws)
|
||||||
|
tokens = _parse(result)
|
||||||
|
|
||||||
|
try_indices = [i for i, t in enumerate(tokens) if t == "--ro-bind-try"]
|
||||||
|
try_targets = {tokens[i + 1] for i in try_indices}
|
||||||
|
assert "/bin" in try_targets
|
||||||
|
assert "/etc/ssl/certs" in try_targets
|
||||||
|
|
||||||
|
def test_media_dir_ro_bind(self, tmp_path, monkeypatch):
|
||||||
|
"""Media directory should be read-only mounted inside the sandbox."""
|
||||||
|
fake_media = tmp_path / "media"
|
||||||
|
fake_media.mkdir()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.agent.tools.sandbox.get_media_dir",
|
||||||
|
lambda: fake_media,
|
||||||
|
)
|
||||||
|
ws = str(tmp_path / "project")
|
||||||
|
result = wrap_command("bwrap", "ls", ws, ws)
|
||||||
|
tokens = _parse(result)
|
||||||
|
|
||||||
|
try_indices = [i for i, t in enumerate(tokens) if t == "--ro-bind-try"]
|
||||||
|
try_pairs = {(tokens[i + 1], tokens[i + 2]) for i in try_indices}
|
||||||
|
assert (str(fake_media), str(fake_media)) in try_pairs
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnknownBackend:
|
||||||
|
def test_raises_value_error(self, tmp_path):
|
||||||
|
ws = str(tmp_path / "project")
|
||||||
|
with pytest.raises(ValueError, match="Unknown sandbox backend"):
|
||||||
|
wrap_command("nonexistent", "ls", ws, ws)
|
||||||
|
|
||||||
|
def test_empty_string_raises(self, tmp_path):
|
||||||
|
ws = str(tmp_path / "project")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
wrap_command("", "ls", ws, ws)
|
||||||
Loading…
x
Reference in New Issue
Block a user