mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-22 18:54:10 +00:00
feat: sandbox exec calls with bwrap and run container as non-root
This commit is contained in:
parent
49fc50b1e6
commit
7913e7150a
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 && \
|
apt-get install -y --no-install-recommends curl ca-certificates gnupg git bubblewrap && \
|
||||||
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 && \
|
||||||
@ -30,8 +30,13 @@ WORKDIR /app/bridge
|
|||||||
RUN npm install && npm run build
|
RUN 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
|
||||||
|
|||||||
@ -3,7 +3,10 @@ x-common-config: &common-config
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
volumes:
|
volumes:
|
||||||
- ~/.nanobot:/root/.nanobot
|
- ~/.nanobot:/home/nanobot/.nanobot
|
||||||
|
security_opt:
|
||||||
|
- apparmor=unconfined
|
||||||
|
- seccomp=./podman-seccomp.json
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nanobot-gateway:
|
nanobot-gateway:
|
||||||
|
|||||||
@ -115,7 +115,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):
|
||||||
@ -124,6 +124,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,
|
||||||
))
|
))
|
||||||
self.tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))
|
self.tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))
|
||||||
|
|||||||
@ -92,7 +92,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))
|
||||||
@ -102,6 +102,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,
|
||||||
))
|
))
|
||||||
tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))
|
tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))
|
||||||
|
|||||||
49
nanobot/agent/tools/sandbox.py
Normal file
49
nanobot/agent/tools/sandbox.py
Normal 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)}")
|
||||||
@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
|
from nanobot.agent.tools.sandbox import wrap_command
|
||||||
|
|
||||||
|
|
||||||
class ExecTool(Tool):
|
class ExecTool(Tool):
|
||||||
@ -19,10 +20,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
|
||||||
@ -84,6 +87,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()
|
||||||
|
|||||||
@ -128,6 +128,7 @@ class ExecToolConfig(Base):
|
|||||||
|
|
||||||
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):
|
||||||
@ -147,7 +148,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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1129
podman-seccomp.json
Normal file
1129
podman-seccomp.json
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user