From 7913e7150a5a93ac9c3847f60b213b20c27e3ded Mon Sep 17 00:00:00 2001 From: kinchahoy Date: Mon, 16 Mar 2026 23:55:19 -0700 Subject: [PATCH 1/4] feat: sandbox exec calls with bwrap and run container as non-root --- Dockerfile | 11 +- docker-compose.yml | 5 +- nanobot/agent/loop.py | 3 +- nanobot/agent/subagent.py | 3 +- nanobot/agent/tools/sandbox.py | 49 ++ nanobot/agent/tools/shell.py | 8 + nanobot/config/schema.py | 3 +- podman-seccomp.json | 1129 ++++++++++++++++++++++++++++++++ 8 files changed, 1204 insertions(+), 7 deletions(-) create mode 100644 nanobot/agent/tools/sandbox.py create mode 100644 podman-seccomp.json diff --git a/Dockerfile b/Dockerfile index 81327475c..594a9e7a7 100644 --- a/Dockerfile +++ b/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 && \ + 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 diff --git a/docker-compose.yml b/docker-compose.yml index 5c27f81a0..88b9f4d07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 34f5baa12..1333a89e1 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -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)) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 30e7913cf..1960bd82c 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -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)) diff --git a/nanobot/agent/tools/sandbox.py b/nanobot/agent/tools/sandbox.py new file mode 100644 index 000000000..67818ec00 --- /dev/null +++ b/nanobot/agent/tools/sandbox.py @@ -0,0 +1,49 @@ +"""Sandbox backends for shell command execution. + +To add a new backend, implement a function with the signature: + _wrap_(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)}") diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 4b10c83a3..4bdeda6ec 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -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() diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 033fb633a..dee8c5f34 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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) diff --git a/podman-seccomp.json b/podman-seccomp.json new file mode 100644 index 000000000..92d882b5c --- /dev/null +++ b/podman-seccomp.json @@ -0,0 +1,1129 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 38, + "defaultErrno": "ENOSYS", + "archMap": [ + { + "architecture": "SCMP_ARCH_X86_64", + "subArchitectures": [ + "SCMP_ARCH_X86", + "SCMP_ARCH_X32" + ] + }, + { + "architecture": "SCMP_ARCH_AARCH64", + "subArchitectures": [ + "SCMP_ARCH_ARM" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64" + ] + }, + { + "architecture": "SCMP_ARCH_S390X", + "subArchitectures": [ + "SCMP_ARCH_S390" + ] + } + ], + "syscalls": [ + { + "names": [ + "bdflush", + "cachestat", + "futex_requeue", + "futex_wait", + "futex_waitv", + "futex_wake", + "io_pgetevents", + "io_pgetevents_time64", + "kexec_file_load", + "kexec_load", + "map_shadow_stack", + "migrate_pages", + "move_pages", + "nfsservctl", + "nice", + "oldfstat", + "oldlstat", + "oldolduname", + "oldstat", + "olduname", + "pciconfig_iobase", + "pciconfig_read", + "pciconfig_write", + "sgetmask", + "ssetmask", + "swapoff", + "swapon", + "syscall", + "sysfs", + "uselib", + "userfaultfd", + "ustat", + "vm86", + "vm86old", + "vmsplice" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": {}, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "_llseek", + "_newselect", + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "clone", + "clone3", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_init", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsconfig", + "fsetxattr", + "fsmount", + "fsopen", + "fspick", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_time64", + "futimesat", + "get_mempolicy", + "get_robust_list", + "get_thread_area", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "io_destroy", + "io_getevents", + "io_setup", + "io_submit", + "ioctl", + "ioprio_get", + "ioprio_set", + "ipc", + "keyctl", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listxattr", + "llistxattr", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "mbind", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mount", + "mount_setattr", + "move_mount", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "open", + "open_tree", + "openat", + "openat2", + "pause", + "pidfd_getfd", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pivot_root", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "process_vm_readv", + "process_vm_writev", + "pselect6", + "pselect6_time64", + "ptrace", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "reboot", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "set_mempolicy", + "set_robust_list", + "set_thread_area", + "set_tid_address", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setns", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "setsid", + "setsockopt", + "setuid", + "setuid32", + "setxattr", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signal", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "syslog", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "umount", + "umount2", + "uname", + "unlink", + "unlinkat", + "unshare", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "sync_file_range2", + "swapcontext" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "ppc64le" + ] + }, + "excludes": {} + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "breakpoint", + "cacheflush", + "set_tls", + "sync_file_range2" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "arm", + "arm64" + ] + }, + "excludes": {} + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "amd64", + "x32" + ] + }, + "excludes": {} + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + }, + "excludes": {} + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": {} + }, + { + "names": [ + "riscv_flush_icache" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "riscv64" + ] + }, + "excludes": {} + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + }, + "excludes": {} + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "bpf", + "lookup_dcookie", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + }, + "excludes": {} + }, + { + "names": [ + "lookup_dcookie", + "perf_event_open", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + }, + "excludes": {} + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "delete_module", + "finit_module", + "init_module", + "query_module" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + }, + "excludes": {} + }, + { + "names": [ + "delete_module", + "finit_module", + "init_module", + "query_module" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_MODULE" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + }, + "excludes": {} + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_PACCT" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "kcmp", + "process_madvise" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + }, + "excludes": {} + }, + { + "names": [ + "kcmp", + "process_madvise" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "ioperm", + "iopl" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + }, + "excludes": {} + }, + { + "names": [ + "ioperm", + "iopl" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "clock_settime", + "clock_settime64", + "settimeofday", + "stime" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + }, + "excludes": {} + }, + { + "names": [ + "clock_settime", + "clock_settime64", + "settimeofday", + "stime" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_TIME" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + }, + "excludes": {} + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ERRNO", + "args": [ + { + "index": 0, + "value": 16, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + }, + { + "index": 2, + "value": 9, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + }, + "errnoRet": 22, + "errno": "EINVAL" + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 2, + "value": 9, + "valueTwo": 0, + "op": "SCMP_CMP_NE" + } + ], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 16, + "valueTwo": 0, + "op": "SCMP_CMP_NE" + } + ], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 2, + "value": 9, + "valueTwo": 0, + "op": "SCMP_CMP_NE" + } + ], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": null, + "comment": "", + "includes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + }, + "excludes": {} + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN", + "CAP_BPF" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_BPF" + ] + }, + "excludes": {} + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN", + "CAP_BPF" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_PERFMON" + ] + }, + "excludes": {} + } + ] +} \ No newline at end of file From cef0f3f988372caee95b1436df35bcfae1ccda24 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 19:03:06 +0000 Subject: [PATCH 2/4] refactor: replace podman-seccomp.json with minimal cap_add, harden bwrap, add sandbox tests --- docker-compose.yml | 6 +- nanobot/agent/tools/sandbox.py | 2 +- podman-seccomp.json | 1129 -------------------------------- tests/tools/test_sandbox.py | 105 +++ 4 files changed, 111 insertions(+), 1131 deletions(-) delete mode 100644 podman-seccomp.json create mode 100644 tests/tools/test_sandbox.py diff --git a/docker-compose.yml b/docker-compose.yml index 88b9f4d07..2b2c9acd1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,13 @@ x-common-config: &common-config dockerfile: Dockerfile volumes: - ~/.nanobot:/home/nanobot/.nanobot + cap_drop: + - ALL + cap_add: + - SYS_ADMIN security_opt: - apparmor=unconfined - - seccomp=./podman-seccomp.json + - seccomp=unconfined services: nanobot-gateway: diff --git a/nanobot/agent/tools/sandbox.py b/nanobot/agent/tools/sandbox.py index 67818ec00..25f869daa 100644 --- a/nanobot/agent/tools/sandbox.py +++ b/nanobot/agent/tools/sandbox.py @@ -25,7 +25,7 @@ def _bwrap(command: str, workspace: str, cwd: str) -> str: optional = ["/bin", "/lib", "/lib64", "/etc/alternatives", "/etc/ssl/certs", "/etc/resolv.conf", "/etc/ld.so.cache"] - args = ["bwrap"] + 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 += [ diff --git a/podman-seccomp.json b/podman-seccomp.json deleted file mode 100644 index 92d882b5c..000000000 --- a/podman-seccomp.json +++ /dev/null @@ -1,1129 +0,0 @@ -{ - "defaultAction": "SCMP_ACT_ERRNO", - "defaultErrnoRet": 38, - "defaultErrno": "ENOSYS", - "archMap": [ - { - "architecture": "SCMP_ARCH_X86_64", - "subArchitectures": [ - "SCMP_ARCH_X86", - "SCMP_ARCH_X32" - ] - }, - { - "architecture": "SCMP_ARCH_AARCH64", - "subArchitectures": [ - "SCMP_ARCH_ARM" - ] - }, - { - "architecture": "SCMP_ARCH_MIPS64", - "subArchitectures": [ - "SCMP_ARCH_MIPS", - "SCMP_ARCH_MIPS64N32" - ] - }, - { - "architecture": "SCMP_ARCH_MIPS64N32", - "subArchitectures": [ - "SCMP_ARCH_MIPS", - "SCMP_ARCH_MIPS64" - ] - }, - { - "architecture": "SCMP_ARCH_MIPSEL64", - "subArchitectures": [ - "SCMP_ARCH_MIPSEL", - "SCMP_ARCH_MIPSEL64N32" - ] - }, - { - "architecture": "SCMP_ARCH_MIPSEL64N32", - "subArchitectures": [ - "SCMP_ARCH_MIPSEL", - "SCMP_ARCH_MIPSEL64" - ] - }, - { - "architecture": "SCMP_ARCH_S390X", - "subArchitectures": [ - "SCMP_ARCH_S390" - ] - } - ], - "syscalls": [ - { - "names": [ - "bdflush", - "cachestat", - "futex_requeue", - "futex_wait", - "futex_waitv", - "futex_wake", - "io_pgetevents", - "io_pgetevents_time64", - "kexec_file_load", - "kexec_load", - "map_shadow_stack", - "migrate_pages", - "move_pages", - "nfsservctl", - "nice", - "oldfstat", - "oldlstat", - "oldolduname", - "oldstat", - "olduname", - "pciconfig_iobase", - "pciconfig_read", - "pciconfig_write", - "sgetmask", - "ssetmask", - "swapoff", - "swapon", - "syscall", - "sysfs", - "uselib", - "userfaultfd", - "ustat", - "vm86", - "vm86old", - "vmsplice" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": {}, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "_llseek", - "_newselect", - "accept", - "accept4", - "access", - "adjtimex", - "alarm", - "bind", - "brk", - "capget", - "capset", - "chdir", - "chmod", - "chown", - "chown32", - "clock_adjtime", - "clock_adjtime64", - "clock_getres", - "clock_getres_time64", - "clock_gettime", - "clock_gettime64", - "clock_nanosleep", - "clock_nanosleep_time64", - "clone", - "clone3", - "close", - "close_range", - "connect", - "copy_file_range", - "creat", - "dup", - "dup2", - "dup3", - "epoll_create", - "epoll_create1", - "epoll_ctl", - "epoll_ctl_old", - "epoll_pwait", - "epoll_pwait2", - "epoll_wait", - "epoll_wait_old", - "eventfd", - "eventfd2", - "execve", - "execveat", - "exit", - "exit_group", - "faccessat", - "faccessat2", - "fadvise64", - "fadvise64_64", - "fallocate", - "fanotify_init", - "fanotify_mark", - "fchdir", - "fchmod", - "fchmodat", - "fchmodat2", - "fchown", - "fchown32", - "fchownat", - "fcntl", - "fcntl64", - "fdatasync", - "fgetxattr", - "flistxattr", - "flock", - "fork", - "fremovexattr", - "fsconfig", - "fsetxattr", - "fsmount", - "fsopen", - "fspick", - "fstat", - "fstat64", - "fstatat64", - "fstatfs", - "fstatfs64", - "fsync", - "ftruncate", - "ftruncate64", - "futex", - "futex_time64", - "futimesat", - "get_mempolicy", - "get_robust_list", - "get_thread_area", - "getcpu", - "getcwd", - "getdents", - "getdents64", - "getegid", - "getegid32", - "geteuid", - "geteuid32", - "getgid", - "getgid32", - "getgroups", - "getgroups32", - "getitimer", - "getpeername", - "getpgid", - "getpgrp", - "getpid", - "getppid", - "getpriority", - "getrandom", - "getresgid", - "getresgid32", - "getresuid", - "getresuid32", - "getrlimit", - "getrusage", - "getsid", - "getsockname", - "getsockopt", - "gettid", - "gettimeofday", - "getuid", - "getuid32", - "getxattr", - "inotify_add_watch", - "inotify_init", - "inotify_init1", - "inotify_rm_watch", - "io_cancel", - "io_destroy", - "io_getevents", - "io_setup", - "io_submit", - "ioctl", - "ioprio_get", - "ioprio_set", - "ipc", - "keyctl", - "kill", - "landlock_add_rule", - "landlock_create_ruleset", - "landlock_restrict_self", - "lchown", - "lchown32", - "lgetxattr", - "link", - "linkat", - "listen", - "listxattr", - "llistxattr", - "lremovexattr", - "lseek", - "lsetxattr", - "lstat", - "lstat64", - "madvise", - "mbind", - "membarrier", - "memfd_create", - "memfd_secret", - "mincore", - "mkdir", - "mkdirat", - "mknod", - "mknodat", - "mlock", - "mlock2", - "mlockall", - "mmap", - "mmap2", - "mount", - "mount_setattr", - "move_mount", - "mprotect", - "mq_getsetattr", - "mq_notify", - "mq_open", - "mq_timedreceive", - "mq_timedreceive_time64", - "mq_timedsend", - "mq_timedsend_time64", - "mq_unlink", - "mremap", - "msgctl", - "msgget", - "msgrcv", - "msgsnd", - "msync", - "munlock", - "munlockall", - "munmap", - "name_to_handle_at", - "nanosleep", - "newfstatat", - "open", - "open_tree", - "openat", - "openat2", - "pause", - "pidfd_getfd", - "pidfd_open", - "pidfd_send_signal", - "pipe", - "pipe2", - "pivot_root", - "pkey_alloc", - "pkey_free", - "pkey_mprotect", - "poll", - "ppoll", - "ppoll_time64", - "prctl", - "pread64", - "preadv", - "preadv2", - "prlimit64", - "process_mrelease", - "process_vm_readv", - "process_vm_writev", - "pselect6", - "pselect6_time64", - "ptrace", - "pwrite64", - "pwritev", - "pwritev2", - "read", - "readahead", - "readlink", - "readlinkat", - "readv", - "reboot", - "recv", - "recvfrom", - "recvmmsg", - "recvmmsg_time64", - "recvmsg", - "remap_file_pages", - "removexattr", - "rename", - "renameat", - "renameat2", - "restart_syscall", - "rmdir", - "rseq", - "rt_sigaction", - "rt_sigpending", - "rt_sigprocmask", - "rt_sigqueueinfo", - "rt_sigreturn", - "rt_sigsuspend", - "rt_sigtimedwait", - "rt_sigtimedwait_time64", - "rt_tgsigqueueinfo", - "sched_get_priority_max", - "sched_get_priority_min", - "sched_getaffinity", - "sched_getattr", - "sched_getparam", - "sched_getscheduler", - "sched_rr_get_interval", - "sched_rr_get_interval_time64", - "sched_setaffinity", - "sched_setattr", - "sched_setparam", - "sched_setscheduler", - "sched_yield", - "seccomp", - "select", - "semctl", - "semget", - "semop", - "semtimedop", - "semtimedop_time64", - "send", - "sendfile", - "sendfile64", - "sendmmsg", - "sendmsg", - "sendto", - "set_mempolicy", - "set_robust_list", - "set_thread_area", - "set_tid_address", - "setfsgid", - "setfsgid32", - "setfsuid", - "setfsuid32", - "setgid", - "setgid32", - "setgroups", - "setgroups32", - "setitimer", - "setns", - "setpgid", - "setpriority", - "setregid", - "setregid32", - "setresgid", - "setresgid32", - "setresuid", - "setresuid32", - "setreuid", - "setreuid32", - "setrlimit", - "setsid", - "setsockopt", - "setuid", - "setuid32", - "setxattr", - "shmat", - "shmctl", - "shmdt", - "shmget", - "shutdown", - "sigaltstack", - "signal", - "signalfd", - "signalfd4", - "sigprocmask", - "sigreturn", - "socketcall", - "socketpair", - "splice", - "stat", - "stat64", - "statfs", - "statfs64", - "statx", - "symlink", - "symlinkat", - "sync", - "sync_file_range", - "syncfs", - "sysinfo", - "syslog", - "tee", - "tgkill", - "time", - "timer_create", - "timer_delete", - "timer_getoverrun", - "timer_gettime", - "timer_gettime64", - "timer_settime", - "timer_settime64", - "timerfd_create", - "timerfd_gettime", - "timerfd_gettime64", - "timerfd_settime", - "timerfd_settime64", - "times", - "tkill", - "truncate", - "truncate64", - "ugetrlimit", - "umask", - "umount", - "umount2", - "uname", - "unlink", - "unlinkat", - "unshare", - "utime", - "utimensat", - "utimensat_time64", - "utimes", - "vfork", - "wait4", - "waitid", - "waitpid", - "write", - "writev" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": {}, - "excludes": {} - }, - { - "names": [ - "personality" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 0, - "value": 0, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - } - ], - "comment": "", - "includes": {}, - "excludes": {} - }, - { - "names": [ - "personality" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 0, - "value": 8, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - } - ], - "comment": "", - "includes": {}, - "excludes": {} - }, - { - "names": [ - "personality" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 0, - "value": 131072, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - } - ], - "comment": "", - "includes": {}, - "excludes": {} - }, - { - "names": [ - "personality" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 0, - "value": 131080, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - } - ], - "comment": "", - "includes": {}, - "excludes": {} - }, - { - "names": [ - "personality" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 0, - "value": 4294967295, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - } - ], - "comment": "", - "includes": {}, - "excludes": {} - }, - { - "names": [ - "sync_file_range2", - "swapcontext" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "arches": [ - "ppc64le" - ] - }, - "excludes": {} - }, - { - "names": [ - "arm_fadvise64_64", - "arm_sync_file_range", - "breakpoint", - "cacheflush", - "set_tls", - "sync_file_range2" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "arches": [ - "arm", - "arm64" - ] - }, - "excludes": {} - }, - { - "names": [ - "arch_prctl" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "arches": [ - "amd64", - "x32" - ] - }, - "excludes": {} - }, - { - "names": [ - "modify_ldt" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "arches": [ - "amd64", - "x32", - "x86" - ] - }, - "excludes": {} - }, - { - "names": [ - "s390_pci_mmio_read", - "s390_pci_mmio_write", - "s390_runtime_instr" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "arches": [ - "s390", - "s390x" - ] - }, - "excludes": {} - }, - { - "names": [ - "riscv_flush_icache" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "arches": [ - "riscv64" - ] - }, - "excludes": {} - }, - { - "names": [ - "open_by_handle_at" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_DAC_READ_SEARCH" - ] - }, - "excludes": {} - }, - { - "names": [ - "open_by_handle_at" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_DAC_READ_SEARCH" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "bpf", - "lookup_dcookie", - "quotactl", - "quotactl_fd", - "setdomainname", - "sethostname", - "setns" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_ADMIN" - ] - }, - "excludes": {} - }, - { - "names": [ - "lookup_dcookie", - "perf_event_open", - "quotactl", - "quotactl_fd", - "setdomainname", - "sethostname", - "setns" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_ADMIN" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "chroot" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_CHROOT" - ] - }, - "excludes": {} - }, - { - "names": [ - "chroot" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_CHROOT" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "delete_module", - "finit_module", - "init_module", - "query_module" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_MODULE" - ] - }, - "excludes": {} - }, - { - "names": [ - "delete_module", - "finit_module", - "init_module", - "query_module" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_MODULE" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "acct" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_PACCT" - ] - }, - "excludes": {} - }, - { - "names": [ - "acct" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_PACCT" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "kcmp", - "process_madvise" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_PTRACE" - ] - }, - "excludes": {} - }, - { - "names": [ - "kcmp", - "process_madvise" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_PTRACE" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "ioperm", - "iopl" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_RAWIO" - ] - }, - "excludes": {} - }, - { - "names": [ - "ioperm", - "iopl" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_RAWIO" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "clock_settime", - "clock_settime64", - "settimeofday", - "stime" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_TIME" - ] - }, - "excludes": {} - }, - { - "names": [ - "clock_settime", - "clock_settime64", - "settimeofday", - "stime" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_TIME" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "vhangup" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_SYS_TTY_CONFIG" - ] - }, - "excludes": {} - }, - { - "names": [ - "vhangup" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_TTY_CONFIG" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "socket" - ], - "action": "SCMP_ACT_ERRNO", - "args": [ - { - "index": 0, - "value": 16, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - }, - { - "index": 2, - "value": 9, - "valueTwo": 0, - "op": "SCMP_CMP_EQ" - } - ], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_AUDIT_WRITE" - ] - }, - "errnoRet": 22, - "errno": "EINVAL" - }, - { - "names": [ - "socket" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 2, - "value": 9, - "valueTwo": 0, - "op": "SCMP_CMP_NE" - } - ], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_AUDIT_WRITE" - ] - } - }, - { - "names": [ - "socket" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 0, - "value": 16, - "valueTwo": 0, - "op": "SCMP_CMP_NE" - } - ], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_AUDIT_WRITE" - ] - } - }, - { - "names": [ - "socket" - ], - "action": "SCMP_ACT_ALLOW", - "args": [ - { - "index": 2, - "value": 9, - "valueTwo": 0, - "op": "SCMP_CMP_NE" - } - ], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_AUDIT_WRITE" - ] - } - }, - { - "names": [ - "socket" - ], - "action": "SCMP_ACT_ALLOW", - "args": null, - "comment": "", - "includes": { - "caps": [ - "CAP_AUDIT_WRITE" - ] - }, - "excludes": {} - }, - { - "names": [ - "bpf" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_ADMIN", - "CAP_BPF" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "bpf" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_BPF" - ] - }, - "excludes": {} - }, - { - "names": [ - "perf_event_open" - ], - "action": "SCMP_ACT_ERRNO", - "args": [], - "comment": "", - "includes": {}, - "excludes": { - "caps": [ - "CAP_SYS_ADMIN", - "CAP_BPF" - ] - }, - "errnoRet": 1, - "errno": "EPERM" - }, - { - "names": [ - "perf_event_open" - ], - "action": "SCMP_ACT_ALLOW", - "args": [], - "comment": "", - "includes": { - "caps": [ - "CAP_PERFMON" - ] - }, - "excludes": {} - } - ] -} \ No newline at end of file diff --git a/tests/tools/test_sandbox.py b/tests/tools/test_sandbox.py new file mode 100644 index 000000000..315bcf7c8 --- /dev/null +++ b/tests/tools/test_sandbox.py @@ -0,0 +1,105 @@ +"""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 + + +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) From 9f96be6e9bd0bdef7980d13affa092dffac7d484 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 19:08:38 +0000 Subject: [PATCH 3/4] fix(sandbox): mount media directory read-only inside bwrap sandbox --- nanobot/agent/tools/sandbox.py | 8 +++++++- tests/tools/test_sandbox.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/sandbox.py b/nanobot/agent/tools/sandbox.py index 25f869daa..459ce16a3 100644 --- a/nanobot/agent/tools/sandbox.py +++ b/nanobot/agent/tools/sandbox.py @@ -8,14 +8,19 @@ 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. + 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: @@ -33,6 +38,7 @@ def _bwrap(command: str, workspace: str, cwd: str) -> str: "--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, ] diff --git a/tests/tools/test_sandbox.py b/tests/tools/test_sandbox.py index 315bcf7c8..82232d83e 100644 --- a/tests/tools/test_sandbox.py +++ b/tests/tools/test_sandbox.py @@ -92,6 +92,22 @@ class TestBwrapBackend: 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): From 9823130432de872e9b1f63e5e1505845683e40d8 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 5 Apr 2026 19:28:46 +0000 Subject: [PATCH 4/4] docs: clarify bwrap sandbox is Linux-only --- README.md | 5 ++++- SECURITY.md | 20 ++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b62079351..3735addda 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SECURITY.md b/SECURITY.md index d98adb6e9..8e65d4042 100644 --- a/SECURITY.md +++ b/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