mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-06 19:23:39 +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
|
||||
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 && \
|
||||
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 && \
|
||||
@ -32,8 +32,13 @@ RUN git config --global --add url."https://github.com/".insteadOf ssh://git@gith
|
||||
npm install && npm run build
|
||||
WORKDIR /app
|
||||
|
||||
# Create config directory
|
||||
RUN mkdir -p /root/.nanobot
|
||||
# Create non-root user and config directory
|
||||
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
|
||||
EXPOSE 18790
|
||||
|
||||
@ -1434,16 +1434,19 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
||||
### Security
|
||||
|
||||
> [!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": ["*"]`.
|
||||
|
||||
| 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.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.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. |
|
||||
|
||||
**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
|
||||
|
||||
|
||||
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:
|
||||
|
||||
- ✅ **Enable the bwrap sandbox** (`"tools.exec.sandbox": "bwrap"`) for kernel-level isolation (Linux only)
|
||||
- ✅ Review all tool usage in agent logs
|
||||
- ✅ Understand what commands the agent is running
|
||||
- ✅ 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 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:**
|
||||
- `rm -rf /` - Root filesystem deletion
|
||||
- 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:
|
||||
|
||||
- ✅ Enable `restrictToWorkspace` or the bwrap sandbox to confine file access
|
||||
- ✅ Run nanobot with a dedicated user account
|
||||
- ✅ Use filesystem permissions to protect sensitive directories
|
||||
- ✅ 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)
|
||||
2. **Plain Text Config** - API keys stored in plain text (use keyring for production)
|
||||
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)
|
||||
|
||||
## Security Checklist
|
||||
@ -243,6 +258,7 @@ Before deploying nanobot:
|
||||
- [ ] Config file permissions set to 0600
|
||||
- [ ] `allowFrom` lists configured for all channels
|
||||
- [ ] Running as non-root user
|
||||
- [ ] Exec sandbox enabled (`"tools.exec.sandbox": "bwrap"`) on Linux deployments
|
||||
- [ ] File system permissions properly restricted
|
||||
- [ ] Dependencies updated to latest secure versions
|
||||
- [ ] Logs monitored for security events
|
||||
@ -252,7 +268,7 @@ Before deploying nanobot:
|
||||
|
||||
## Updates
|
||||
|
||||
**Last Updated**: 2026-02-03
|
||||
**Last Updated**: 2026-04-05
|
||||
|
||||
For the latest security updates and announcements, check:
|
||||
- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories
|
||||
|
||||
@ -3,7 +3,14 @@ x-common-config: &common-config
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ~/.nanobot:/root/.nanobot
|
||||
- ~/.nanobot:/home/nanobot/.nanobot
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
- seccomp=unconfined
|
||||
|
||||
services:
|
||||
nanobot-gateway:
|
||||
|
||||
@ -262,7 +262,7 @@ class AgentLoop:
|
||||
|
||||
def _register_default_tools(self) -> None:
|
||||
"""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
|
||||
self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read))
|
||||
for cls in (WriteFileTool, EditFileTool, ListDirTool):
|
||||
@ -274,6 +274,7 @@ class AgentLoop:
|
||||
working_dir=str(self.workspace),
|
||||
timeout=self.exec_config.timeout,
|
||||
restrict_to_workspace=self.restrict_to_workspace,
|
||||
sandbox=self.exec_config.sandbox,
|
||||
path_append=self.exec_config.path_append,
|
||||
))
|
||||
if self.web_config.enable:
|
||||
|
||||
@ -111,7 +111,7 @@ class SubagentManager:
|
||||
try:
|
||||
# Build subagent tools (no message tool, no spawn tool)
|
||||
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
|
||||
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))
|
||||
@ -124,6 +124,7 @@ class SubagentManager:
|
||||
working_dir=str(self.workspace),
|
||||
timeout=self.exec_config.timeout,
|
||||
restrict_to_workspace=self.restrict_to_workspace,
|
||||
sandbox=self.exec_config.sandbox,
|
||||
path_append=self.exec_config.path_append,
|
||||
))
|
||||
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 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.config.paths import get_media_dir
|
||||
|
||||
@ -40,10 +41,12 @@ class ExecTool(Tool):
|
||||
deny_patterns: list[str] | None = None,
|
||||
allow_patterns: list[str] | None = None,
|
||||
restrict_to_workspace: bool = False,
|
||||
sandbox: str = "",
|
||||
path_append: str = "",
|
||||
):
|
||||
self.timeout = timeout
|
||||
self.working_dir = working_dir
|
||||
self.sandbox = sandbox
|
||||
self.deny_patterns = deny_patterns or [
|
||||
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
||||
r"\bdel\s+/[fq]\b", # del /f, del /q
|
||||
@ -83,6 +86,11 @@ class ExecTool(Tool):
|
||||
if 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)
|
||||
|
||||
env = os.environ.copy()
|
||||
|
||||
@ -174,6 +174,7 @@ class ExecToolConfig(Base):
|
||||
enable: bool = True
|
||||
timeout: int = 60
|
||||
path_append: str = ""
|
||||
sandbox: str = "" # sandbox backend: "" (none) or "bwrap"
|
||||
|
||||
class MCPServerConfig(Base):
|
||||
"""MCP server connection configuration (stdio or HTTP)."""
|
||||
@ -192,7 +193,7 @@ class ToolsConfig(Base):
|
||||
|
||||
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
|
||||
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)
|
||||
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