feat: sandbox exec calls with bwrap and run container as non-root

This commit is contained in:
kinchahoy 2026-03-16 23:55:19 -07:00
parent 49fc50b1e6
commit 7913e7150a
8 changed files with 1204 additions and 7 deletions

View File

@ -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 && \
apt-get install -y --no-install-recommends curl ca-certificates gnupg git bubblewrap && \
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 && \
@ -30,8 +30,13 @@ WORKDIR /app/bridge
RUN 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

View File

@ -3,7 +3,10 @@ x-common-config: &common-config
context: .
dockerfile: Dockerfile
volumes:
- ~/.nanobot:/root/.nanobot
- ~/.nanobot:/home/nanobot/.nanobot
security_opt:
- apparmor=unconfined
- seccomp=./podman-seccomp.json
services:
nanobot-gateway:

View File

@ -115,7 +115,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):
@ -124,6 +124,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,
))
self.tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))

View File

@ -92,7 +92,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))
@ -102,6 +102,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,
))
tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))

View File

@ -0,0 +1,49 @@
"""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
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.
"""
ws = Path(workspace).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"]
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),
"--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)}")

View File

@ -7,6 +7,7 @@ from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.sandbox import wrap_command
class ExecTool(Tool):
@ -19,10 +20,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
@ -84,6 +87,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()

View File

@ -128,6 +128,7 @@ class ExecToolConfig(Base):
timeout: int = 60
path_append: str = ""
sandbox: str = "" # sandbox backend: "" (none) or "bwrap"
class MCPServerConfig(Base):
@ -147,7 +148,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)

1129
podman-seccomp.json Normal file

File diff suppressed because it is too large Load Diff