diff --git a/Dockerfile b/Dockerfile index 3732d20a6..ac4c3045f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ nodejs \ npm \ chromium \ + bubblewrap \ + libseccomp2 \ + util-linux \ tmux \ openssh-client \ gosu \ @@ -35,6 +38,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libglib2.0-0t64 \ libxcb1 \ libmagic1 \ + && BWRAP_POLICY_VERSION=0.11.0 \ + && BWRAP_POLICY_PACKAGE=0.11.0-2+deb13u1 \ + && BWRAP_ACTUAL="$(bwrap --version)" \ + && BWRAP_PACKAGE="$(dpkg-query -W -f='${Version}' bubblewrap)" \ + && if [ "$BWRAP_ACTUAL" != "bubblewrap ${BWRAP_POLICY_VERSION}" ]; then \ + echo "unsupported Bubblewrap version: $BWRAP_ACTUAL (expected ${BWRAP_POLICY_VERSION})" >&2; \ + exit 1; \ + fi \ + && if [ "$BWRAP_PACKAGE" != "$BWRAP_POLICY_PACKAGE" ]; then \ + echo "unsupported Bubblewrap package: $BWRAP_PACKAGE (expected $BWRAP_POLICY_PACKAGE)" >&2; \ + exit 1; \ + fi \ && rm -rf /var/lib/apt/lists/* # libgl1/libglib2.0-0t64/libxcb1 are runtime shared libs (libGL.so.1, @@ -95,6 +110,14 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \ # Copy app code COPY . . +# Compile and install the fixed-purpose inner-seccomp launcher and constrained +# HTTP(S) egress broker under a root-owned path that the dropped runtime user +# and model workspace cannot modify. The policy generator verifies pinned Moby +# provenance first. +RUN make -C security/seccomp install \ + && make -C security/egress install \ + && rm -rf security/seccomp/build + # Create data directory (mount a volume here for persistence) RUN mkdir -p data logs services/cache/search diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index ee656087c..27b84209d 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -72,7 +72,7 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se These are open, acknowledged, and contributor help is welcome: -1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. +1. **Linux sandbox portability and explicit Full Access.** Agent `bash`, Python, tmux, and detached background commands default to a Bubblewrap profile with a private network namespace, cleared environment, private temp/home, one writable workspace, credential-path overlays, read-only `.git` metadata, and generous per-process rlimits. Internet-enabled process execution is limited to the trusted HTTP(S) egress broker; raw container networking is never shared with either mode. Odysseus performs the actual capability probes under the service user at startup and before process execution. A failed probe blocks only process tools and never downgrades automatically. An administrator may temporarily enable **Full Access** only after a warning plus typed confirmation. Full Access grants the process the Odysseus operating-system user's filesystem view while retaining the private PID/network namespace and brokered-only Internet policy; in Docker that filesystem authority includes the container and mounted volumes, while native execution includes everything available to the service user. New process launches reset to Sandbox at application restart; an already-running Full Access process retains its launch-time authority until it exits or is killed. The process boundary mounts a fresh procfs scoped to its private PID namespace. Hard workspace-disk quotas and aggregate agent-pool/per-instance CPU, memory, and PID ceilings are not established in this slice; current limits are per process and the aggregate design is tracked separately. The supported default Compose path must not require `privileged: true`, a globally unconfined profile, or an automatic Full Access fallback. 2. **SSRF via `/api/v1/chat` `base_url` parameter.** A chat-scoped API token can supply an arbitrary `base_url`; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this. diff --git a/app.py b/app.py index bb4f51ffb..f1192940e 100644 --- a/app.py +++ b/app.py @@ -1032,6 +1032,38 @@ async def _lifespan(app): async def _startup_event(): global upload_cleanup_task logger.info("Application starting up...") + try: + from src.process_execution import ( + process_capability, + reset_process_execution_mode, + ) + + # Full Access is intentionally transient: every application start returns + # to the least-authority Sandbox default and requires a new confirmation. + reset_process_execution_mode() + capability = await asyncio.to_thread(process_capability, refresh=True) + if capability.sandbox.networkless: + logger.info("Agent process Sandbox capability probe passed") + if not capability.sandbox.brokered: + logger.warning( + "Brokered process Internet is unavailable in Sandbox mode: %s", + capability.sandbox.brokered_reason, + ) + else: + logger.warning( + "Agent process Sandbox unavailable; Bash, Python, tmux, and " + "detached jobs remain blocked by default: %s", + capability.sandbox.networkless_reason, + ) + if not capability.full_access.networkless: + logger.warning( + "Explicit Full Access process mode is also unavailable: %s", + capability.full_access.networkless_reason, + ) + except Exception: + logger.exception( + "Agent process capability probe failed; process tools remain blocked" + ) webhook_manager.set_loop(asyncio.get_running_loop()) # Wipe any leftover incognito sessions from previous process — they're # ephemeral by design and must not survive a restart. diff --git a/core/database.py b/core/database.py index 65ad40316..6b69cdbb3 100644 --- a/core/database.py +++ b/core/database.py @@ -4,9 +4,12 @@ from datetime import datetime, timezone from pathlib import Path from typing import Optional -from urllib.parse import unquote, urlparse +from src.sqlite_paths import ( + normalize_sqlite_url as _normalize_sqlite_url_impl, + sqlite_db_path as _sqlite_db_path_impl, +) from sqlalchemy import DDL, event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, inspect, text -from sqlalchemy.engine import Engine, make_url +from sqlalchemy.engine import Engine from sqlalchemy.types import TypeDecorator from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.orm import relationship, sessionmaker, backref @@ -45,28 +48,7 @@ def _default_database_url() -> str: def _normalize_sqlite_url(url: str) -> str: - """Resolve relative ordinary SQLite paths without rewriting URI filenames.""" - try: - parsed = make_url(url) - except Exception: - return url - - if parsed.get_backend_name() != "sqlite": - return url - - db_path = parsed.database - if ( - not db_path - or db_path == ":memory:" - or str(db_path).lower().startswith("file:") - or os.path.isabs(str(db_path)) - ): - return url - - absolute_path = (Path(get_app_root()) / str(db_path)).resolve().as_posix() - return parsed.set(database=absolute_path).render_as_string( - hide_password=False - ) + return _normalize_sqlite_url_impl(url, app_root=get_app_root()) # Get database URL from environment, default to SQLite in DATA_DIR @@ -86,50 +68,7 @@ def _normalize_sqlite_url(url: str) -> str: def _sqlite_db_path(url) -> Optional[str]: - """Return the filesystem path for a file-backed SQLite URL. - - SQLite query parameters such as ``mode=memory`` only affect filename - semantics when SQLAlchemy enables URI handling with ``uri=true``. Ordinary - file URLs must therefore remain file-backed even when they contain a query - parameter named ``mode``. - - For SQLite ``file:`` URIs, an empty authority or ``localhost`` identifies a - local path. Other authorities are retained as UNC-style paths. - """ - if url.get_backend_name() != "sqlite": - return None - - db_path = url.database - if not db_path or db_path == ":memory:": - return None - - db_path = str(db_path) - query = { - str(key).lower(): str(value).strip().lower() - for key, value in dict(getattr(url, "query", {}) or {}).items() - } - uri_enabled = query.get("uri") in {"1", "true", "yes", "on"} - is_file_uri = db_path.lower().startswith("file:") - - if not uri_enabled or not is_file_uri: - return db_path - - if ( - db_path.lower().startswith("file::memory:") - or query.get("mode") == "memory" - ): - return None - - parsed = urlparse(db_path) - fs_path = parsed.path or "" - if not fs_path or fs_path == ":memory:": - return None - - authority = parsed.netloc - if authority and authority.lower() != "localhost": - fs_path = f"//{authority}{fs_path}" - - return unquote(fs_path) + return _sqlite_db_path_impl(url) # Create session factory SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 8d0cf1653..c548fa816 100644 --- a/docker-compose.gpu-amd.yml +++ b/docker-compose.gpu-amd.yml @@ -13,6 +13,8 @@ services: odysseus: build: . + security_opt: + - seccomp=./docker/seccomp/odysseus-bubblewrap.json ports: - "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000" volumes: diff --git a/docker-compose.gpu-nvidia.yml b/docker-compose.gpu-nvidia.yml index 69331ffb6..9eca51a87 100644 --- a/docker-compose.gpu-nvidia.yml +++ b/docker-compose.gpu-nvidia.yml @@ -12,6 +12,8 @@ services: odysseus: build: . + security_opt: + - seccomp=./docker/seccomp/odysseus-bubblewrap.json ports: - "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000" volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 708e5df82..5a2ec8b39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: odysseus: build: . + security_opt: + - seccomp=./docker/seccomp/odysseus-bubblewrap.json ports: - "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000" volumes: diff --git a/docker/seccomp/odysseus-bubblewrap.json b/docker/seccomp/odysseus-bubblewrap.json new file mode 100644 index 000000000..09d7124ff --- /dev/null +++ b/docker/seccomp/odysseus-bubblewrap.json @@ -0,0 +1,898 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 1, + "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" + ] + }, + { + "architecture": "SCMP_ARCH_RISCV64", + "subArchitectures": null + }, + { + "architecture": "SCMP_ARCH_LOONGARCH64", + "subArchitectures": null + } + ], + "syscalls": [ + { + "names": [ + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "cachestat", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "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_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "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", + "get_robust_list", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "get_thread_area", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "getxattrat", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "ioctl", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "ioprio_get", + "ioprio_set", + "io_setup", + "io_submit", + "ipc", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listmount", + "listxattr", + "listxattrat", + "llistxattr", + "_llseek", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "map_shadow_stack", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "mseal", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "_newselect", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "removexattrat", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "riscv_hwprobe", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_get_priority_max", + "sched_get_priority_min", + "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", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "set_robust_list", + "setsid", + "setsockopt", + "set_thread_area", + "set_tid_address", + "setuid", + "setuid32", + "setxattr", + "setxattrat", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statmount", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "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", + "uname", + "unlink", + "unlinkat", + "uretprobe", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "minKernel": "4.8" + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 38, + "op": "SCMP_CMP_LT" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 39, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 40, + "op": "SCMP_CMP_GT" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "sync_file_range2", + "swapcontext" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "ppc64le" + ] + } + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "sync_file_range2", + "breakpoint", + "cacheflush", + "set_tls" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "arm", + "arm64" + ] + } + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + } + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "riscv_flush_icache" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "riscv64" + ] + } + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + } + }, + { + "names": [ + "bpf", + "clone", + "clone3", + "fanotify_init", + "fsconfig", + "fsmount", + "fsopen", + "fspick", + "lookup_dcookie", + "lsm_get_self_attr", + "lsm_list_modules", + "lsm_set_self_attr", + "mount", + "mount_setattr", + "move_mount", + "open_tree", + "perf_event_open", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns", + "syslog", + "umount", + "umount2", + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ], + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 1, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "comment": "s390 parameter ordering for clone is different", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone3" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 38, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "reboot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_BOOT" + ] + } + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + } + }, + { + "names": [ + "delete_module", + "init_module", + "finit_module" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + } + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + } + }, + { + "names": [ + "kcmp", + "pidfd_getfd", + "process_madvise", + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + } + }, + { + "names": [ + "iopl", + "ioperm" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + } + }, + { + "names": [ + "settimeofday", + "stime", + "clock_settime", + "clock_settime64" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + } + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + } + }, + { + "names": [ + "get_mempolicy", + "mbind", + "set_mempolicy", + "set_mempolicy_home_node" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_NICE" + ] + } + }, + { + "names": [ + "syslog" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYSLOG" + ] + } + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_BPF" + ] + } + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_PERFMON" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2114060305, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "Odysseus trusted Bubblewrap namespace bootstrap only" + }, + { + "names": [ + "mount", + "pivot_root", + "umount2" + ], + "action": "SCMP_ACT_ALLOW", + "comment": "Odysseus trusted Bubblewrap mount bootstrap; inner filter denies payload use" + } + ] +} diff --git a/routes/auth_routes.py b/routes/auth_routes.py index a35d466c7..4288865ed 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -83,6 +83,12 @@ class SetAdminRequest(BaseModel): class SetOpenRegistrationRequest(BaseModel): enabled: bool + +class SetProcessExecutionModeRequest(BaseModel): + mode: str + confirmation: str = "" + + SESSION_COOKIE = "odysseus_session" @@ -754,6 +760,73 @@ async def set_settings(request: Request): _save_settings(current) return without_retired_settings(current) + @router.get("/process-execution") + async def get_process_execution_mode(request: Request): + """Return the transient admin-owned mode and actual capability state.""" + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + + from src.process_execution import ( + FULL_ACCESS_CONFIRMATION, + FULL_ACCESS_WARNING, + configured_process_execution_mode, + process_capability, + ) + + capability = await asyncio.to_thread(process_capability) + return { + "mode": configured_process_execution_mode().value, + "capability": capability.as_dict(), + "full_access_warning": FULL_ACCESS_WARNING, + "confirmation_phrase": FULL_ACCESS_CONFIRMATION, + "transient": True, + } + + @router.post("/process-execution") + async def set_process_execution_mode_route( + body: SetProcessExecutionModeRequest, + request: Request, + ): + """Change transient process authority only after explicit confirmation.""" + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + + from src.process_execution import ( + FULL_ACCESS_WARNING, + ProcessExecutionMode, + process_capability, + set_process_execution_mode, + ) + + try: + mode = ProcessExecutionMode(str(body.mode or "").strip().lower()) + except ValueError as exc: + raise HTTPException(400, "Invalid process execution mode") from exc + + capability = await asyncio.to_thread(process_capability) + profile = capability.for_mode(mode) + if mode is ProcessExecutionMode.FULL_ACCESS and not profile.networkless: + raise HTTPException( + 409, + "Full Access cannot retain the required private-network boundary: " + + profile.networkless_reason, + ) + try: + set_process_execution_mode(mode, confirmation=body.confirmation) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return { + "ok": True, + "mode": mode.value, + "capability": capability.as_dict(), + "mode_available": profile.networkless, + "mode_unavailable_reason": profile.networkless_reason, + "full_access_warning": FULL_ACCESS_WARNING, + "transient": True, + } + # ---- Integrations CRUD ---- # Run migration on startup diff --git a/routes/chat_routes.py b/routes/chat_routes.py index 0b181796f..ee658a08c 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -39,6 +39,7 @@ ) from src.session_search import search_session_messages from src.prompt_security import untrusted_context_message +from src.execution_sandbox import network_profile_for_internet_preference from core.exceptions import SessionNotFoundError from src.auth_helpers import effective_user, get_current_user from routes.session_routes import _verify_session_owner @@ -2198,6 +2199,14 @@ def _commit_chat_compaction(candidate_index: int) -> bool: elif _explicit_browser_intent: _forced_tools = set(_BROWSER_MCP_TOOLS) + # For now, Bubblewrap networking follows the existing user + # web toggle. This mapping may change in the future; keep + # every other toggle's behavior unchanged for now. The + # server snapshots a BROKERED_ONLY or NETWORKLESS profile; + # Sandbox mode never exposes the raw container namespace. + _sandbox_network_profile = network_profile_for_internet_preference( + _search_enabled + ) async for chunk in stream_agent_loop( sess.endpoint_url, sess.model, @@ -2235,6 +2244,7 @@ def _commit_chat_compaction(candidate_index: int) -> bool: ) ), exact_approval=exact_tool_approval, + network_profile=_sandbox_network_profile, ): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: diff --git a/security/egress/Makefile b/security/egress/Makefile new file mode 100644 index 000000000..86723c49c --- /dev/null +++ b/security/egress/Makefile @@ -0,0 +1,22 @@ +PYTHON ?= python3 +PYTHON_PATH := $(shell realpath "$$(command -v $(PYTHON))") +INSTALL_DIR := /usr/local/libexec +INSTALL_OWNER ?= root +INSTALL_GROUP ?= root +BROKER := odysseus_egress_broker.py +BRIDGE := odysseus_egress_bridge.py + +.PHONY: check install + +check: + test -n "$(PYTHON_PATH)" -a -x "$(PYTHON_PATH)" + $(PYTHON) -m py_compile $(BROKER) $(BRIDGE) + +install: check + install -d -o $(INSTALL_OWNER) -g $(INSTALL_GROUP) -m 0755 $(INSTALL_DIR) + install -o $(INSTALL_OWNER) -g $(INSTALL_GROUP) -m 0755 $(BROKER) $(INSTALL_DIR)/odysseus-egress-broker + install -o $(INSTALL_OWNER) -g $(INSTALL_GROUP) -m 0755 $(BRIDGE) $(INSTALL_DIR)/odysseus-egress-bridge + sed -i '1c\#!$(PYTHON_PATH) -I' $(INSTALL_DIR)/odysseus-egress-broker + sed -i '1c\#!$(PYTHON_PATH) -I' $(INSTALL_DIR)/odysseus-egress-bridge + chown $(INSTALL_OWNER):$(INSTALL_GROUP) $(INSTALL_DIR)/odysseus-egress-broker $(INSTALL_DIR)/odysseus-egress-bridge + chmod 0755 $(INSTALL_DIR)/odysseus-egress-broker $(INSTALL_DIR)/odysseus-egress-bridge diff --git a/security/egress/README.md b/security/egress/README.md new file mode 100644 index 000000000..dcdc77d1c --- /dev/null +++ b/security/egress/README.md @@ -0,0 +1,25 @@ +# Brokered sandbox egress + +`odysseus-egress-broker` is a fixed-purpose parent for the trusted seccomp +launcher. It owns a unique mode-0700 runtime directory and Unix socket outside +Bubblewrap, injects that directory as a read-only mount, clears its environment, +and resolves every requested destination itself. + +`odysseus-egress-bridge` runs after Bubblewrap has created a private network +namespace and loaded the inner seccomp filter. It exposes only +`127.0.0.1:3128` and forwards bytes to the mounted Unix socket. The payload can +talk to the broker but cannot acquire the container's raw network namespace. + +The broker supports absolute-form HTTP requests to public TCP port 80 and +HTTPS `CONNECT` to public TCP port 443. Every DNS answer must be globally +routable; mixed public/private answers fail closed. It connects to the exact +validated sockaddr, strips proxy authorization and hop-by-hop request headers, +and bounds headers, HTTP bodies, tunnels, concurrent connections, idle time, +connection lifetime, and total broker lifetime. It intentionally does not +support arbitrary TCP, UDP, SSH, SOCKS, private destinations, or raw network +sharing. + +Installation rewrites each helper's shebang to the absolute Python interpreter +selected at build/install time and enables Python isolated mode. Runtime launch +therefore never resolves a Python executable or imports user-site startup code +through inherited or model-writable state. diff --git a/security/egress/odysseus_egress_bridge.py b/security/egress/odysseus_egress_bridge.py new file mode 100644 index 000000000..70c654021 --- /dev/null +++ b/security/egress/odysseus_egress_bridge.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Trusted loopback-to-Unix bridge inside a Bubblewrap network namespace.""" + +from __future__ import annotations + +import os +import signal +import socket +import subprocess +import sys +import threading +import time +from typing import Callable, Sequence + + +BROKER_SOCKET = "/run/odysseus-egress/broker.sock" +PROXY_HOST = "127.0.0.1" +PROXY_PORT = 3128 +MAX_CONNECTIONS = 16 +COPY_BUFFER_BYTES = 64 * 1024 +IDLE_TIMEOUT_SECONDS = 60.0 +CONNECTION_LIFETIME_SECONDS = 15 * 60.0 + + +class BridgeError(RuntimeError): + """The trusted bridge could not be established safely.""" + + +def _copy( + source: socket.socket, + destination: socket.socket, + stop: threading.Event, + touch: Callable[[], None], + expired: Callable[[], bool], + *, + stop_after_eof: bool, +) -> None: + try: + while not stop.is_set(): + if expired(): + stop.set() + break + try: + data = source.recv(COPY_BUFFER_BYTES) + except socket.timeout: + continue + if not data: + if stop_after_eof: + stop.set() + break + destination.sendall(data) + touch() + except OSError: + stop.set() + finally: + try: + destination.shutdown(socket.SHUT_WR) + except OSError: + pass + + +def _relay( + left: socket.socket, + right: socket.socket, + *, + idle_timeout: float = IDLE_TIMEOUT_SECONDS, + connection_lifetime: float = CONNECTION_LIFETIME_SECONDS, +) -> None: + stop = threading.Event() + activity_lock = threading.Lock() + last_activity = [time.monotonic()] + deadline = time.monotonic() + connection_lifetime + + def touch() -> None: + with activity_lock: + last_activity[0] = time.monotonic() + + def expired() -> bool: + with activity_lock: + idle = time.monotonic() - last_activity[0] >= idle_timeout + return idle or time.monotonic() >= deadline + + timeout = min(idle_timeout, 1.0) + left.settimeout(timeout) + right.settimeout(timeout) + upstream = threading.Thread( + target=_copy, + args=(left, right, stop, touch, expired), + kwargs={"stop_after_eof": False}, + daemon=True, + ) + downstream = threading.Thread( + target=_copy, + args=(right, left, stop, touch, expired), + kwargs={"stop_after_eof": True}, + daemon=True, + ) + upstream.start() + downstream.start() + while (upstream.is_alive() or downstream.is_alive()) and not stop.is_set(): + if expired(): + stop.set() + break + time.sleep(0.05) + if stop.is_set(): + # Preserve any broker response already delivered to the client while + # interrupting the opposite read direction that would otherwise keep + # the slot alive after broker EOF. + try: + left.shutdown(socket.SHUT_RD) + except OSError: + pass + try: + right.shutdown(socket.SHUT_RDWR) + except OSError: + pass + upstream.join(timeout=1.0) + downstream.join(timeout=1.0) + left.close() + right.close() + + +class LoopbackBridge: + def __init__( + self, + broker_socket: str = BROKER_SOCKET, + *, + host: str = PROXY_HOST, + port: int = PROXY_PORT, + max_connections: int = MAX_CONNECTIONS, + idle_timeout: float = IDLE_TIMEOUT_SECONDS, + connection_lifetime: float = CONNECTION_LIFETIME_SECONDS, + ) -> None: + if not os.path.isabs(broker_socket): + raise BridgeError("broker socket path must be absolute") + if idle_timeout <= 0 or connection_lifetime <= 0: + raise BridgeError("bridge time limits must be positive") + self.broker_socket = broker_socket + self.idle_timeout = idle_timeout + self.connection_lifetime = connection_lifetime + self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.listener.bind((host, port)) + self.listener.listen(max_connections) + self.listener.settimeout(0.5) + self.stop = threading.Event() + self.slots = threading.BoundedSemaphore(max_connections) + self.accept_thread: threading.Thread | None = None + self.connections: list[threading.Thread] = [] + self.connections_lock = threading.Lock() + + @property + def address(self) -> tuple[str, int]: + host, port = self.listener.getsockname()[:2] + return str(host), int(port) + + def verify_broker(self) -> None: + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + probe.settimeout(2.0) + probe.connect(self.broker_socket) + except OSError as exc: + raise BridgeError("trusted egress broker is unavailable") from exc + finally: + probe.close() + + def start(self) -> None: + self.verify_broker() + self.accept_thread = threading.Thread(target=self._accept, daemon=True) + self.accept_thread.start() + + def _accept(self) -> None: + while not self.stop.is_set(): + try: + client, _address = self.listener.accept() + except socket.timeout: + continue + except OSError: + break + if not self.slots.acquire(blocking=False): + client.close() + continue + thread = threading.Thread( + target=self._connect, + args=(client,), + daemon=True, + ) + with self.connections_lock: + self.connections = [ + worker for worker in self.connections if worker.is_alive() + ] + thread.start() + self.connections.append(thread) + + def _connect(self, client: socket.socket) -> None: + broker = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + broker.connect(self.broker_socket) + except OSError: + client.close() + broker.close() + self.slots.release() + return + try: + _relay( + client, + broker, + idle_timeout=self.idle_timeout, + connection_lifetime=self.connection_lifetime, + ) + finally: + self.slots.release() + + def close(self) -> None: + self.stop.set() + try: + self.listener.close() + except OSError: + pass + if self.accept_thread is not None: + self.accept_thread.join(timeout=1.0) + with self.connections_lock: + connections = list(self.connections) + for thread in connections: + thread.join(timeout=0.2) + + +def _parse_command(arguments: Sequence[str]) -> list[str]: + if len(arguments) < 3 or arguments[0] != BROKER_SOCKET or arguments[1] != "--": + raise BridgeError("invalid trusted bridge arguments") + command = list(arguments[2:]) + if not command or not os.path.isabs(command[0]): + raise BridgeError("trusted bridge requires an absolute payload executable") + return command + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + bridge: LoopbackBridge | None = None + child: subprocess.Popen[bytes] | None = None + try: + command = _parse_command(arguments) + bridge = LoopbackBridge() + bridge.start() + child = subprocess.Popen(command, close_fds=True) + + def forward_signal(_signum: int, _frame: object) -> None: + if child is not None and child.poll() is None: + child.terminate() + + signal.signal(signal.SIGTERM, forward_signal) + signal.signal(signal.SIGINT, forward_signal) + return int(child.wait()) + except BridgeError as exc: + print(f"odysseus-egress-bridge: {exc}", file=sys.stderr) + return 64 + except (OSError, subprocess.SubprocessError): + print("odysseus-egress-bridge: trusted bridge setup failed", file=sys.stderr) + if child is not None and child.poll() is None: + child.terminate() + return 70 + finally: + if bridge is not None: + bridge.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/security/egress/odysseus_egress_broker.py b/security/egress/odysseus_egress_broker.py new file mode 100644 index 000000000..2cf28d5b4 --- /dev/null +++ b/security/egress/odysseus_egress_broker.py @@ -0,0 +1,774 @@ +#!/usr/bin/env python3 +"""Fixed-purpose public HTTP(S) egress broker for process sandboxes. + +The broker runs outside Bubblewrap with an empty environment. A unique Unix +socket is mounted read-only into one private network namespace, where the +trusted loopback bridge exposes it as a conventional HTTP proxy. The broker +resolves every requested destination itself and connects only to globally +routable addresses on TCP port 80 or 443. +""" + +from __future__ import annotations + +import ipaddress +import os +import re +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time +from dataclasses import dataclass +from typing import Callable, Iterable, Sequence +from urllib.parse import urlsplit + + +TRUSTED_LAUNCHER = "/usr/local/libexec/odysseus-seccomp-launcher" +TRUSTED_BWRAP = "/usr/bin/bwrap" +SANDBOX_RUNTIME_DIR = "/run/odysseus-egress" +SANDBOX_SOCKET = f"{SANDBOX_RUNTIME_DIR}/broker.sock" + +MAX_CONNECTIONS = 16 +MAX_HEADER_BYTES = 64 * 1024 +MAX_HTTP_BODY_BYTES = 16 * 1024 * 1024 +MAX_TUNNEL_BYTES_PER_DIRECTION = 1024 * 1024 * 1024 +MAX_RESOLVED_TARGETS = 16 +CONNECT_TIMEOUT_SECONDS = 10.0 +HEADER_TIMEOUT_SECONDS = 10.0 +IDLE_TIMEOUT_SECONDS = 60.0 +CONNECTION_LIFETIME_SECONDS = 15 * 60.0 +BROKER_LIFETIME_SECONDS = 60 * 60.0 + +_TOKEN = re.compile(rb"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") +_HOP_BY_HOP = { + "connection", + "keep-alive", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "upgrade", +} +_CONNECTION_PROTECTED_HEADERS = { + "content-length", + "expect", + "host", + "transfer-encoding", +} +_NAT64_WELL_KNOWN = ipaddress.ip_network("64:ff9b::/96") +_NAT64_LOCAL_USE = ipaddress.ip_network("64:ff9b:1::/48") + + +class BrokerError(RuntimeError): + """A fail-closed broker setup or destination-policy error.""" + + +class RequestError(BrokerError): + """A client request that the constrained proxy must reject.""" + + def __init__(self, status: int, reason: str): + super().__init__(reason) + self.status = status + self.reason = reason + + +Resolver = Callable[..., Iterable[tuple]] +Connector = Callable[[int, tuple, float], socket.socket] + + +@dataclass(frozen=True) +class PublicTarget: + family: int + sockaddr: tuple + + +def _public_ip(value: object) -> ipaddress.IPv4Address | ipaddress.IPv6Address: + if not isinstance(value, str): + raise BrokerError("destination did not resolve to an IP address") + try: + address = ipaddress.ip_address(value.split("%", 1)[0]) + except ValueError as exc: + raise BrokerError("destination did not resolve to an IP address") from exc + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: + address = address.ipv4_mapped + if isinstance(address, ipaddress.IPv6Address): + if address in _NAT64_LOCAL_USE: + raise BrokerError("destination resolved to a non-public address") + if address in _NAT64_WELL_KNOWN: + embedded = ipaddress.IPv4Address(address.packed[-4:]) + if ( + not embedded.is_global + or embedded.is_multicast + or embedded.is_private + or embedded.is_loopback + or embedded.is_link_local + or embedded.is_reserved + or embedded.is_unspecified + ): + raise BrokerError("destination resolved to a non-public address") + return embedded + # is_global rejects loopback, RFC1918, link-local/metadata, CGNAT, ULA, + # multicast, unspecified, benchmarking, documentation, and reserved space. + if ( + not address.is_global + or address.is_multicast + or address.is_private + or address.is_loopback + or address.is_link_local + or address.is_reserved + or address.is_unspecified + ): + raise BrokerError("destination resolved to a non-public address") + return address + + +def resolve_public_targets( + host: str, + port: int, + *, + resolver: Resolver = socket.getaddrinfo, +) -> list[PublicTarget]: + """Resolve once, reject mixed public/private answers, and retain IP tuples.""" + if not isinstance(host, str) or not host or len(host) > 253 or "\x00" in host: + raise BrokerError("invalid destination host") + try: + answers = list( + resolver( + host, + port, + family=socket.AF_UNSPEC, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + ) + ) + except (OSError, UnicodeError, ValueError) as exc: + raise BrokerError("destination resolution failed") from exc + if not answers: + raise BrokerError("destination resolution failed") + + targets: list[PublicTarget] = [] + seen: set[tuple[int, tuple]] = set() + too_many_targets = False + for answer in answers: + if not isinstance(answer, tuple) or len(answer) < 5: + raise BrokerError("destination resolution returned an invalid answer") + family, socket_type, _protocol, _canonical, sockaddr = answer[:5] + if family not in (socket.AF_INET, socket.AF_INET6): + raise BrokerError("destination resolution returned an unsupported family") + if socket_type not in (0, socket.SOCK_STREAM): + raise BrokerError("destination resolution returned an unsupported socket type") + if not isinstance(sockaddr, tuple) or len(sockaddr) < 2: + raise BrokerError("destination resolution returned an invalid address") + _public_ip(sockaddr[0]) + normalized = (family, sockaddr) + if normalized not in seen: + if len(seen) >= MAX_RESOLVED_TARGETS: + too_many_targets = True + continue + seen.add(normalized) + targets.append(PublicTarget(family, sockaddr)) + if too_many_targets: + raise BrokerError("destination resolved to too many addresses") + if not targets: + raise BrokerError("destination resolution failed") + return targets + + +def _default_connector(family: int, sockaddr: tuple, timeout: float) -> socket.socket: + outbound = socket.socket(family, socket.SOCK_STREAM, socket.IPPROTO_TCP) + try: + outbound.settimeout(timeout) + outbound.connect(sockaddr) + return outbound + except Exception: + outbound.close() + raise + + +def connect_public_target( + host: str, + port: int, + *, + resolver: Resolver = socket.getaddrinfo, + connector: Connector = _default_connector, +) -> socket.socket: + """Connect to the exact approved sockaddr without a second name lookup.""" + targets = resolve_public_targets(host, port, resolver=resolver) + for target in targets: + target_address = _public_ip(target.sockaddr[0]) + target_port = int(target.sockaddr[1]) + try: + outbound = connector( + target.family, + target.sockaddr, + CONNECT_TIMEOUT_SECONDS, + ) + except OSError: + continue + try: + peer = outbound.getpeername() + if not isinstance(peer, tuple) or len(peer) < 2: + raise BrokerError("connected peer address is unavailable") + peer_address = _public_ip(peer[0]) + if peer_address != target_address or int(peer[1]) != target_port: + raise BrokerError("connected peer does not match the approved target") + except Exception: + outbound.close() + continue + outbound.settimeout(IDLE_TIMEOUT_SECONDS) + return outbound + raise BrokerError("public destination connection failed") + + +def _read_headers(client: socket.socket) -> tuple[bytes, bytes]: + client.settimeout(HEADER_TIMEOUT_SECONDS) + data = bytearray() + while True: + marker = data.find(b"\r\n\r\n") + if marker >= 0: + return bytes(data[:marker]), bytes(data[marker + 4:]) + if len(data) >= MAX_HEADER_BYTES: + raise RequestError(431, "request headers too large") + chunk = client.recv(min(4096, MAX_HEADER_BYTES - len(data))) + if not chunk: + raise RequestError(400, "incomplete proxy request") + data.extend(chunk) + + +def _parse_headers(block: bytes) -> tuple[str, str, str, list[tuple[str, str]]]: + try: + lines = block.split(b"\r\n") + request_line = lines[0].decode("ascii") + except (IndexError, UnicodeDecodeError) as exc: + raise RequestError(400, "invalid proxy request line") from exc + parts = request_line.split(" ") + if len(parts) != 3: + raise RequestError(400, "invalid proxy request line") + method, target, version = parts + if not _TOKEN.fullmatch(method.encode("ascii", errors="ignore")): + raise RequestError(400, "invalid HTTP method") + if version not in {"HTTP/1.0", "HTTP/1.1"}: + raise RequestError(400, "unsupported HTTP version") + if any(ord(character) < 0x21 or ord(character) == 0x7F for character in target): + raise RequestError(400, "invalid proxy destination") + + headers: list[tuple[str, str]] = [] + for raw in lines[1:]: + if not raw or raw[:1] in {b" ", b"\t"} or b":" not in raw: + raise RequestError(400, "invalid proxy request header") + name, value = raw.split(b":", 1) + if not _TOKEN.fullmatch(name): + raise RequestError(400, "invalid proxy request header") + try: + decoded_value = value.decode("iso-8859-1").strip() + except UnicodeDecodeError as exc: # pragma: no cover - latin-1 is total + raise RequestError(400, "invalid proxy request header") from exc + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in decoded_value): + raise RequestError(400, "invalid proxy request header") + headers.append((name.decode("ascii"), decoded_value)) + return method, target, version, headers + + +def _parse_authority(authority: str, *, default_port: int) -> tuple[str, int]: + if not authority or "@" in authority or any(char.isspace() for char in authority): + raise RequestError(400, "invalid proxy destination") + if authority.startswith("["): + end = authority.find("]") + if end <= 1: + raise RequestError(400, "invalid proxy destination") + host = authority[1:end] + suffix = authority[end + 1:] + if suffix: + if not suffix.startswith(":"): + raise RequestError(400, "invalid proxy destination") + port_text = suffix[1:] + else: + port_text = str(default_port) + else: + if authority.count(":") > 1: + raise RequestError(400, "IPv6 proxy destinations must be bracketed") + if ":" in authority: + host, port_text = authority.rsplit(":", 1) + else: + host, port_text = authority, str(default_port) + if not host or not port_text.isascii() or not port_text.isdigit(): + raise RequestError(400, "invalid proxy destination") + port = int(port_text) + if not 1 <= port <= 65535: + raise RequestError(400, "invalid proxy destination port") + return host, port + + +def _format_authority(host: str, port: int) -> str: + bracketed = f"[{host}]" if ":" in host and not host.startswith("[") else host + return f"{bracketed}:{port}" + + +def _send_error(client: socket.socket, status: int, reason: str) -> None: + labels = { + 400: "Bad Request", + 403: "Forbidden", + 431: "Request Header Fields Too Large", + 502: "Bad Gateway", + 503: "Service Unavailable", + } + label = labels.get(status, "Proxy Error") + body = f"{reason}\n".encode("utf-8", errors="replace")[:512] + response = ( + f"HTTP/1.1 {status} {label}\r\n" + "Content-Type: text/plain\r\n" + f"Content-Length: {len(body)}\r\n" + "Connection: close\r\n\r\n" + ).encode("ascii") + body + try: + client.sendall(response) + except OSError: + pass + + +def _relay( + client: socket.socket, + outbound: socket.socket, + *, + client_bytes_remaining: int | None, +) -> None: + stop = threading.Event() + activity_lock = threading.Lock() + last_activity = [time.monotonic()] + deadline = time.monotonic() + CONNECTION_LIFETIME_SECONDS + + def touch() -> None: + with activity_lock: + last_activity[0] = time.monotonic() + + def idle_expired() -> bool: + with activity_lock: + return time.monotonic() - last_activity[0] >= IDLE_TIMEOUT_SECONDS + + def pump( + source: socket.socket, + destination: socket.socket, + limit: int | None, + *, + stop_after_eof: bool, + ) -> None: + remaining = limit + try: + source.settimeout(min(IDLE_TIMEOUT_SECONDS, 5.0)) + destination.settimeout(min(IDLE_TIMEOUT_SECONDS, 5.0)) + while not stop.is_set() and time.monotonic() < deadline: + if remaining == 0: + break + size = 64 * 1024 if remaining is None else min(64 * 1024, remaining) + try: + data = source.recv(size) + except socket.timeout: + if idle_expired(): + stop.set() + continue + if not data: + if stop_after_eof: + stop.set() + break + destination.sendall(data) + touch() + if remaining is not None: + remaining -= len(data) + try: + destination.shutdown(socket.SHUT_WR) + except OSError: + pass + except OSError: + stop.set() + + client_limit = ( + MAX_TUNNEL_BYTES_PER_DIRECTION + if client_bytes_remaining is None + else client_bytes_remaining + ) + upstream = threading.Thread( + target=pump, + args=(client, outbound, client_limit), + kwargs={"stop_after_eof": False}, + daemon=True, + ) + downstream = threading.Thread( + target=pump, + args=(outbound, client, MAX_TUNNEL_BYTES_PER_DIRECTION), + kwargs={"stop_after_eof": True}, + daemon=True, + ) + upstream.start() + downstream.start() + while (upstream.is_alive() or downstream.is_alive()) and not stop.is_set(): + if time.monotonic() >= deadline or idle_expired(): + stop.set() + break + time.sleep(0.05) + if stop.is_set(): + for stream in (client, outbound): + try: + stream.shutdown(socket.SHUT_RDWR) + except OSError: + pass + upstream.join(timeout=1.0) + downstream.join(timeout=1.0) + + +def _single_header(headers: Sequence[tuple[str, str]], name: str) -> str | None: + values = [value for key, value in headers if key.casefold() == name] + if len(values) > 1: + raise RequestError(400, f"multiple {name} headers are not allowed") + return values[0] if values else None + + +def _connection_options(headers: Sequence[tuple[str, str]]) -> set[str]: + """Parse hop-by-hop names without allowing request-framing removal.""" + options: set[str] = set() + for name, value in headers: + if name.casefold() != "connection": + continue + for raw_option in value.split(","): + option = raw_option.strip() + if not option: + continue + try: + encoded = option.encode("ascii") + except UnicodeEncodeError as exc: + raise RequestError(400, "invalid Connection header") from exc + if not _TOKEN.fullmatch(encoded): + raise RequestError(400, "invalid Connection header") + options.add(option.casefold()) + if options & _CONNECTION_PROTECTED_HEADERS: + raise RequestError(400, "Connection header cannot remove request framing") + return options + + +def _serve_connect( + client: socket.socket, + target: str, + remainder: bytes, + *, + resolver: Resolver, + connector: Connector, +) -> None: + host, port = _parse_authority(target, default_port=443) + if port != 443: + raise RequestError(403, "CONNECT is limited to public TCP port 443") + outbound = connect_public_target( + host, + port, + resolver=resolver, + connector=connector, + ) + try: + client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") + if len(remainder) > MAX_TUNNEL_BYTES_PER_DIRECTION: + raise RequestError(400, "tunnel preface too large") + if remainder: + outbound.sendall(remainder) + _relay( + client, + outbound, + client_bytes_remaining=MAX_TUNNEL_BYTES_PER_DIRECTION - len(remainder), + ) + finally: + outbound.close() + + +def _serve_http( + client: socket.socket, + method: str, + target: str, + version: str, + headers: Sequence[tuple[str, str]], + remainder: bytes, + *, + resolver: Resolver, + connector: Connector, +) -> None: + try: + parsed = urlsplit(target) + parsed_port = parsed.port + except ValueError as exc: + raise RequestError(400, "invalid HTTP proxy destination") from exc + if ( + parsed.scheme.casefold() != "http" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + ): + raise RequestError(400, "plain proxy requests require an absolute HTTP URL") + host = parsed.hostname + port = parsed_port or 80 + if port != 80: + raise RequestError(403, "plain HTTP proxying is limited to public TCP port 80") + + host_header = _single_header(headers, "host") + if host_header is not None: + header_host, header_port = _parse_authority(host_header, default_port=80) + if header_host.rstrip(".").casefold() != host.rstrip(".").casefold() or header_port != port: + raise RequestError(400, "Host header does not match proxy destination") + if _single_header(headers, "expect") is not None: + raise RequestError(400, "Expect requests are not supported") + if _single_header(headers, "transfer-encoding") is not None: + raise RequestError(400, "streaming HTTP request bodies are not supported") + content_length = _single_header(headers, "content-length") + if content_length is None: + body_length = 0 + elif not content_length.isascii() or not content_length.isdigit(): + raise RequestError(400, "invalid Content-Length") + else: + body_length = int(content_length) + if body_length > MAX_HTTP_BODY_BYTES: + raise RequestError(400, "HTTP request body too large") + if len(remainder) > body_length: + raise RequestError(400, "pipelined proxy requests are not supported") + connection_tokens = _connection_options(headers) + + outbound = connect_public_target( + host, + port, + resolver=resolver, + connector=connector, + ) + try: + path = parsed.path or "/" + if parsed.query: + path = f"{path}?{parsed.query}" + forwarded = [f"{method} {path} {version}\r\n"] + forwarded.append(f"Host: {_format_authority(host, port)}\r\n") + for name, value in headers: + folded = name.casefold() + if folded == "host" or folded in _HOP_BY_HOP or folded in connection_tokens: + continue + forwarded.append(f"{name}: {value}\r\n") + forwarded.append("Connection: close\r\n\r\n") + outbound.sendall("".join(forwarded).encode("iso-8859-1") + remainder) + _relay( + client, + outbound, + client_bytes_remaining=body_length - len(remainder), + ) + finally: + outbound.close() + + +def serve_proxy_client( + client: socket.socket, + *, + resolver: Resolver = socket.getaddrinfo, + connector: Connector = _default_connector, +) -> None: + try: + header_block, remainder = _read_headers(client) + method, target, version, headers = _parse_headers(header_block) + if method == "CONNECT": + _serve_connect( + client, + target, + remainder, + resolver=resolver, + connector=connector, + ) + else: + _serve_http( + client, + method, + target, + version, + headers, + remainder, + resolver=resolver, + connector=connector, + ) + except RequestError as exc: + _send_error(client, exc.status, exc.reason) + except BrokerError: + _send_error(client, 403, "destination blocked by sandbox egress policy") + except (OSError, UnicodeError, ValueError): + _send_error(client, 502, "public destination connection failed") + finally: + try: + client.close() + except OSError: + pass + + +class BrokerServer: + def __init__( + self, + listener: socket.socket, + *, + resolver: Resolver = socket.getaddrinfo, + connector: Connector = _default_connector, + max_connections: int = MAX_CONNECTIONS, + ) -> None: + self.listener = listener + self.resolver = resolver + self.connector = connector + self.stop = threading.Event() + self.slots = threading.BoundedSemaphore(max_connections) + self.threads: list[threading.Thread] = [] + self.threads_lock = threading.Lock() + self.accept_thread: threading.Thread | None = None + + def start(self) -> None: + self.listener.settimeout(0.5) + self.accept_thread = threading.Thread(target=self._accept, daemon=True) + self.accept_thread.start() + + def _accept(self) -> None: + while not self.stop.is_set(): + try: + client, _address = self.listener.accept() + except socket.timeout: + continue + except OSError: + break + if not self.slots.acquire(blocking=False): + _send_error(client, 503, "sandbox egress connection limit reached") + client.close() + continue + thread = threading.Thread( + target=self._handle, + args=(client,), + daemon=True, + ) + with self.threads_lock: + self.threads = [worker for worker in self.threads if worker.is_alive()] + thread.start() + self.threads.append(thread) + + def _handle(self, client: socket.socket) -> None: + try: + serve_proxy_client( + client, + resolver=self.resolver, + connector=self.connector, + ) + finally: + self.slots.release() + + def close(self) -> None: + self.stop.set() + try: + self.listener.close() + except OSError: + pass + if self.accept_thread is not None: + self.accept_thread.join(timeout=1.0) + with self.threads_lock: + threads = list(self.threads) + for thread in threads: + thread.join(timeout=0.2) + + +def build_child_argv(arguments: Sequence[str], runtime_dir: str) -> list[str]: + """Validate the fixed launch chain and inject one read-only broker mount.""" + if ( + len(arguments) < 4 + or arguments[0] != TRUSTED_LAUNCHER + or arguments[1] != TRUSTED_BWRAP + ): + raise BrokerError("invalid trusted sandbox launch chain") + try: + separator = arguments.index("--", 2) + except ValueError as exc: + raise BrokerError("invalid Bubblewrap arguments") from exc + setup = list(arguments[2:separator]) + if setup.count("--unshare-net") != 1: + raise BrokerError("private network namespace is required") + if any(value == "--share-net" or value.startswith("--share-net=") for value in setup): + raise BrokerError("raw network sharing is forbidden") + if SANDBOX_RUNTIME_DIR in setup or SANDBOX_SOCKET in setup: + raise BrokerError("caller-supplied broker mounts are forbidden") + injected = [ + "--dir", + "/run", + "--ro-bind", + runtime_dir, + SANDBOX_RUNTIME_DIR, + ] + return [*arguments[:separator], *injected, *arguments[separator:]] + + +def _unix_listener(path: str) -> socket.socket: + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + listener.bind(path) + os.chmod(path, 0o600) + listener.listen(MAX_CONNECTIONS) + return listener + except Exception: + listener.close() + raise + + +def _terminate(child: subprocess.Popen[bytes]) -> None: + if child.poll() is not None: + return + child.terminate() + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.wait(timeout=5) + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + # The broker never needs application configuration or credentials. Clear + # them before opening the socket or starting any worker threads. + os.environ.clear() + runtime_dir: str | None = None + server: BrokerServer | None = None + child: subprocess.Popen[bytes] | None = None + try: + runtime_dir = tempfile.mkdtemp(prefix="odysseus-egress-", dir="/tmp") + os.chmod(runtime_dir, 0o700) + socket_path = os.path.join(runtime_dir, "broker.sock") + listener = _unix_listener(socket_path) + server = BrokerServer(listener) + server.start() + child_argv = build_child_argv(arguments, runtime_dir) + child = subprocess.Popen(child_argv, env={}, close_fds=True) + + def forward_signal(_signum: int, _frame: object) -> None: + if child is not None and child.poll() is None: + child.terminate() + + signal.signal(signal.SIGTERM, forward_signal) + signal.signal(signal.SIGINT, forward_signal) + deadline = time.monotonic() + BROKER_LIFETIME_SECONDS + while child.poll() is None and time.monotonic() < deadline: + time.sleep(0.1) + if child.poll() is None: + print( + "odysseus-egress-broker: sandbox network lifetime exceeded", + file=sys.stderr, + ) + _terminate(child) + return 72 + return int(child.returncode or 0) + except BrokerError as exc: + print(f"odysseus-egress-broker: {exc}", file=sys.stderr) + return 64 + except (OSError, subprocess.SubprocessError): + print("odysseus-egress-broker: trusted broker setup failed", file=sys.stderr) + if child is not None: + _terminate(child) + return 70 + finally: + if server is not None: + server.close() + if runtime_dir is not None: + shutil.rmtree(runtime_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/security/seccomp/Makefile b/security/seccomp/Makefile new file mode 100644 index 000000000..ba5f37dce --- /dev/null +++ b/security/seccomp/Makefile @@ -0,0 +1,35 @@ +CC ?= cc +CPPFLAGS := -D_FORTIFY_SOURCE=3 +CFLAGS := -std=c11 -O2 -fPIE -fstack-protector-strong -Wall -Wextra -Wpedantic -Werror -Wformat=2 -Werror=format-security +LDFLAGS := -pie -Wl,-z,relro,-z,now,-z,noexecstack +LDLIBS := -ldl + +BUILD_DIR := build +LAUNCHER := $(BUILD_DIR)/odysseus-seccomp-launcher +TEST_LAUNCHER := $(BUILD_DIR)/odysseus-seccomp-launcher-test +INSTALL_DIR := /usr/local/libexec + +.PHONY: all check-generated clean install test-launcher + +all: check-generated $(LAUNCHER) + +check-generated: + python3 generate.py --check --verify-arches + +$(BUILD_DIR): + mkdir -p $(BUILD_DIR) + +$(LAUNCHER): odysseus-seccomp-launcher.c generated_inner_policy.h | $(BUILD_DIR) + $(CC) $(CPPFLAGS) $(CFLAGS) $< -o $@ $(LDFLAGS) $(LDLIBS) + +test-launcher: check-generated $(TEST_LAUNCHER) + +$(TEST_LAUNCHER): odysseus-seccomp-launcher.c generated_inner_policy.h | $(BUILD_DIR) + $(CC) $(CPPFLAGS) -DODYSSEUS_LAUNCHER_TESTING $(CFLAGS) $< -o $@ $(LDFLAGS) $(LDLIBS) + +install: all + install -d -o root -g root -m 0755 $(INSTALL_DIR) + install -o root -g root -m 0755 $(LAUNCHER) $(INSTALL_DIR)/odysseus-seccomp-launcher + +clean: + rm -rf $(BUILD_DIR) diff --git a/security/seccomp/README.md b/security/seccomp/README.md new file mode 100644 index 000000000..555ac2ec2 --- /dev/null +++ b/security/seccomp/README.md @@ -0,0 +1,9 @@ +# Odysseus process seccomp policies + +The payload allowlist is derived deterministically from Moby's default seccomp profile at commit `35797366d7cdae8d1d84eac06fbb314ccaf3ccaf` (`vendor/github.com/moby/profiles/seccomp/default.json`). The upstream blob SHA-256 is recorded in `policy.json` and verified before generation. + +`generate.py` removes capability-dependent and argument-dependent rules, then adds the reviewed payload constraints recorded in `policy.json`. It emits the C allowlist consumed by the trusted launcher and the outer OCI profile used only by the Odysseus Compose service. Run `python3 generate.py --check --verify-arches` to verify provenance, deterministic output, and syscall resolution for x86_64 and ARM64. + +The outer bootstrap trace is tied to the upstream Bubblewrap v0.11.0 release commit and annotated-tag object, its release-archive digest, and Debian package `0.11.0-2+deb13u1`. The image build rejects a different package or reported Bubblewrap version until the exact namespace and mount trace is reviewed and the policy is deliberately regenerated. + +The launcher dynamically loads the stable libseccomp ABI from `libseccomp.so.2`, compiles cBPF in trusted code, exports it to a sealed anonymous memfd, and injects that descriptor into the fixed `/usr/bin/bwrap` invocation. It is intentionally not a generic program launcher. Native Linux installations must build it with `make` and install it as root with `make install`; Sandbox mode refuses to run if the fixed root-owned installation is missing or writable by non-root users. diff --git a/security/seccomp/generate.py b/security/seccomp/generate.py new file mode 100644 index 000000000..b1f92832a --- /dev/null +++ b/security/seccomp/generate.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Generate Odysseus inner and outer seccomp artifacts deterministically.""" + +from __future__ import annotations + +import argparse +import ctypes +import ctypes.util +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +SOURCE = HERE / "moby-default.json" +POLICY = HERE / "policy.json" +GENERATED_HEADER = HERE / "generated_inner_policy.h" +OUTER_PROFILE = ROOT / "docker" / "seccomp" / "odysseus-bubblewrap.json" + + +def _load_json(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"{path} must contain a JSON object") + return value + + +def _verify_source(policy: dict[str, Any]) -> None: + raw = SOURCE.read_bytes() + # The GitHub blob has no final newline. The checked-in copy has the normal + # repository newline, so provenance is verified against the blob bytes. + upstream_bytes = raw[:-1] if raw.endswith(b"\n") else raw + actual = hashlib.sha256(upstream_bytes).hexdigest() + expected = policy["moby"]["upstream_sha256"] + if actual != expected: + raise ValueError( + f"vendored Moby profile digest mismatch: {actual} != {expected}" + ) + + +def _block_applies(block: dict[str, Any], moby_arch: str) -> bool: + includes = block.get("includes") or {} + excludes = block.get("excludes") or {} + # Capability-dependent rules describe the outer container authority, not + # the cap-dropped payload. They are intentionally excluded from the inner + # policy and represented only by explicit rules below when required. + if includes.get("caps") or excludes.get("caps"): + return False + include_arches = set(includes.get("arches") or ()) + exclude_arches = set(excludes.get("arches") or ()) + if include_arches and moby_arch not in include_arches: + return False + return moby_arch not in exclude_arches + + +def _allowlist_for_arch( + source: dict[str, Any], + policy: dict[str, Any], + moby_arch: str, +) -> list[str]: + denied = set(policy["denied_syscalls"]) + conditional = set(policy["conditional_syscalls"]) + names: set[str] = set() + for block in source["syscalls"]: + if block.get("action") != "SCMP_ACT_ALLOW": + continue + if not _block_applies(block, moby_arch): + continue + if block.get("args"): + # Argument-sensitive rules are rebuilt explicitly by the trusted + # launcher; never flatten them into unconditional allows. + continue + names.update(str(name) for name in block.get("names") or ()) + return sorted(names - denied - conditional) + + +def _c_array(name: str, values: list[str]) -> str: + lines = [f"static const char *const {name}[] = {{"] + lines.extend(f' "{value}",' for value in values) + lines.append("};") + lines.append( + f"static const size_t {name}_COUNT = sizeof({name}) / sizeof({name}[0]);" + ) + return "\n".join(lines) + + +def _render_header(source: dict[str, Any], policy: dict[str, Any]) -> str: + provenance = policy["moby"] + x86 = _allowlist_for_arch(source, policy, policy["target_arches"]["x86_64"]) + arm = _allowlist_for_arch(source, policy, policy["target_arches"]["aarch64"]) + return "\n".join( + [ + "/* Generated by security/seccomp/generate.py; do not edit. */", + f"/* Moby {provenance['commit']} {provenance['path']} */", + "#ifndef ODYSSEUS_GENERATED_INNER_POLICY_H", + "#define ODYSSEUS_GENERATED_INNER_POLICY_H", + "", + "#include ", + "", + _c_array("ODYSSEUS_ALLOWED_X86_64", x86), + "", + _c_array("ODYSSEUS_ALLOWED_AARCH64", arm), + "", + f"#define ODYSSEUS_CLONE_NAMESPACE_MASK {policy['clone_namespace_mask']}ULL", + "", + "#endif", + "", + ] + ) + + +def _render_outer(source: dict[str, Any], policy: dict[str, Any]) -> str: + outer = json.loads(json.dumps(source)) + bubblewrap = policy["outer_bubblewrap"] + outer["syscalls"].append( + { + "names": ["clone"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": bubblewrap["clone_flags"], + "op": "SCMP_CMP_EQ", + } + ], + "comment": "Odysseus trusted Bubblewrap namespace bootstrap only", + } + ) + outer["syscalls"].append( + { + "names": sorted(bubblewrap["bootstrap_syscalls"]), + "action": "SCMP_ACT_ALLOW", + "comment": "Odysseus trusted Bubblewrap mount bootstrap; inner filter denies payload use", + } + ) + return json.dumps(outer, indent=2, sort_keys=False) + "\n" + + +def _load_libseccomp() -> ctypes.CDLL: + library = ctypes.util.find_library("seccomp") or "libseccomp.so.2" + try: + lib = ctypes.CDLL(library) + except OSError as exc: + raise RuntimeError("libseccomp is required for architecture verification") from exc + lib.seccomp_arch_resolve_name.argtypes = [ctypes.c_char_p] + lib.seccomp_arch_resolve_name.restype = ctypes.c_uint32 + lib.seccomp_syscall_resolve_name_arch.argtypes = [ctypes.c_uint32, ctypes.c_char_p] + lib.seccomp_syscall_resolve_name_arch.restype = ctypes.c_int + return lib + + +def _verify_arches(policy: dict[str, Any]) -> None: + lib = _load_libseccomp() + # These names are either installed through explicit argument-sensitive + # rules by the launcher or are required for its core execution path. The + # pinned Moby allowlists can also contain newer syscall names that an older + # build-host libseccomp does not know yet. The launcher safely skips those + # names under default-deny, so they must not make artifact verification + # depend on the build host's syscall table version. + required = { + "clone", + "clone3", + "execve", + "ioctl", + "openat", + "personality", + "seccomp", + "socket", + "socketpair", + } + for generated_arch in policy["target_arches"]: + token = lib.seccomp_arch_resolve_name(generated_arch.encode("ascii")) + if token == 0: + raise RuntimeError(f"libseccomp does not recognize {generated_arch}") + missing_required = { + name + for name in required + if lib.seccomp_syscall_resolve_name_arch( + token, name.encode("ascii") + ) < 0 + } + if missing_required: + raise RuntimeError( + f"libseccomp cannot resolve {generated_arch} syscalls: " + + ", ".join(sorted(missing_required)) + ) + + +def _check_or_write(path: Path, content: str, check: bool) -> bool: + if check: + return path.is_file() and path.read_text(encoding="utf-8") == content + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return True + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + parser.add_argument("--verify-arches", action="store_true") + args = parser.parse_args() + + source = _load_json(SOURCE) + policy = _load_json(POLICY) + _verify_source(policy) + if args.verify_arches: + _verify_arches(policy) + + outputs = { + GENERATED_HEADER: _render_header(source, policy), + OUTER_PROFILE: _render_outer(source, policy), + } + stale = [ + str(path.relative_to(ROOT)) + for path, content in outputs.items() + if not _check_or_write(path, content, args.check) + ] + if stale: + print("generated seccomp artifacts are stale: " + ", ".join(stale), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/security/seccomp/generated_inner_policy.h b/security/seccomp/generated_inner_policy.h new file mode 100644 index 000000000..86cf64c60 --- /dev/null +++ b/security/seccomp/generated_inner_policy.h @@ -0,0 +1,734 @@ +/* Generated by security/seccomp/generate.py; do not edit. */ +/* Moby 35797366d7cdae8d1d84eac06fbb314ccaf3ccaf vendor/github.com/moby/profiles/seccomp/default.json */ +#ifndef ODYSSEUS_GENERATED_INNER_POLICY_H +#define ODYSSEUS_GENERATED_INNER_POLICY_H + +#include + +static const char *const ODYSSEUS_ALLOWED_X86_64[] = { + "_llseek", + "_newselect", + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "arch_prctl", + "bind", + "brk", + "cachestat", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "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", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "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", + "getxattrat", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "io_setup", + "io_submit", + "ioprio_get", + "ioprio_set", + "ipc", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listmount", + "listxattr", + "listxattrat", + "llistxattr", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "map_shadow_stack", + "membarrier", + "memfd_create", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "modify_ldt", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "mseal", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "nanosleep", + "newfstatat", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "removexattrat", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "riscv_hwprobe", + "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_robust_list", + "set_thread_area", + "set_tid_address", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "setsid", + "setsockopt", + "setuid", + "setuid32", + "setxattr", + "setxattrat", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statmount", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "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", + "uname", + "unlink", + "unlinkat", + "uretprobe", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev", +}; +static const size_t ODYSSEUS_ALLOWED_X86_64_COUNT = sizeof(ODYSSEUS_ALLOWED_X86_64) / sizeof(ODYSSEUS_ALLOWED_X86_64[0]); + +static const char *const ODYSSEUS_ALLOWED_AARCH64[] = { + "_llseek", + "_newselect", + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "arm_fadvise64_64", + "arm_sync_file_range", + "bind", + "breakpoint", + "brk", + "cacheflush", + "cachestat", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "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", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "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", + "getxattrat", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "io_setup", + "io_submit", + "ioprio_get", + "ioprio_set", + "ipc", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listmount", + "listxattr", + "listxattrat", + "llistxattr", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "map_shadow_stack", + "membarrier", + "memfd_create", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "mseal", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "nanosleep", + "newfstatat", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "removexattrat", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "riscv_hwprobe", + "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_robust_list", + "set_thread_area", + "set_tid_address", + "set_tls", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "setsid", + "setsockopt", + "setuid", + "setuid32", + "setxattr", + "setxattrat", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statmount", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "sync_file_range2", + "syncfs", + "sysinfo", + "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", + "uname", + "unlink", + "unlinkat", + "uretprobe", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev", +}; +static const size_t ODYSSEUS_ALLOWED_AARCH64_COUNT = sizeof(ODYSSEUS_ALLOWED_AARCH64) / sizeof(ODYSSEUS_ALLOWED_AARCH64[0]); + +#define ODYSSEUS_CLONE_NAMESPACE_MASK 2114060288ULL + +#endif diff --git a/security/seccomp/moby-default.json b/security/seccomp/moby-default.json new file mode 100644 index 000000000..3b35e8d84 --- /dev/null +++ b/security/seccomp/moby-default.json @@ -0,0 +1,875 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 1, + "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" + ] + }, + { + "architecture": "SCMP_ARCH_RISCV64", + "subArchitectures": null + }, + { + "architecture": "SCMP_ARCH_LOONGARCH64", + "subArchitectures": null + } + ], + "syscalls": [ + { + "names": [ + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "cachestat", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "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_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "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", + "get_robust_list", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "get_thread_area", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "getxattrat", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "ioctl", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "ioprio_get", + "ioprio_set", + "io_setup", + "io_submit", + "ipc", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listmount", + "listxattr", + "listxattrat", + "llistxattr", + "_llseek", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "map_shadow_stack", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "mseal", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "_newselect", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "removexattrat", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "riscv_hwprobe", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_get_priority_max", + "sched_get_priority_min", + "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", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "set_robust_list", + "setsid", + "setsockopt", + "set_thread_area", + "set_tid_address", + "setuid", + "setuid32", + "setxattr", + "setxattrat", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statmount", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "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", + "uname", + "unlink", + "unlinkat", + "uretprobe", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "minKernel": "4.8" + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 38, + "op": "SCMP_CMP_LT" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 39, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 40, + "op": "SCMP_CMP_GT" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "sync_file_range2", + "swapcontext" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "ppc64le" + ] + } + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "sync_file_range2", + "breakpoint", + "cacheflush", + "set_tls" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "arm", + "arm64" + ] + } + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + } + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "riscv_flush_icache" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "riscv64" + ] + } + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + } + }, + { + "names": [ + "bpf", + "clone", + "clone3", + "fanotify_init", + "fsconfig", + "fsmount", + "fsopen", + "fspick", + "lookup_dcookie", + "lsm_get_self_attr", + "lsm_list_modules", + "lsm_set_self_attr", + "mount", + "mount_setattr", + "move_mount", + "open_tree", + "perf_event_open", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns", + "syslog", + "umount", + "umount2", + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ], + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 1, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "comment": "s390 parameter ordering for clone is different", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone3" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 38, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "reboot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_BOOT" + ] + } + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + } + }, + { + "names": [ + "delete_module", + "init_module", + "finit_module" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + } + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + } + }, + { + "names": [ + "kcmp", + "pidfd_getfd", + "process_madvise", + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + } + }, + { + "names": [ + "iopl", + "ioperm" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + } + }, + { + "names": [ + "settimeofday", + "stime", + "clock_settime", + "clock_settime64" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + } + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + } + }, + { + "names": [ + "get_mempolicy", + "mbind", + "set_mempolicy", + "set_mempolicy_home_node" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_NICE" + ] + } + }, + { + "names": [ + "syslog" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYSLOG" + ] + } + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_BPF" + ] + } + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_PERFMON" + ] + } + } + ] +} diff --git a/security/seccomp/odysseus-seccomp-launcher.c b/security/seccomp/odysseus-seccomp-launcher.c new file mode 100644 index 000000000..26c03c0a7 --- /dev/null +++ b/security/seccomp/odysseus-seccomp-launcher.c @@ -0,0 +1,515 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "generated_inner_policy.h" + +/* Minimal libseccomp ABI copied from seccomp.h.in at + * de2bf463afa565e1573f58096167e31eaf6e08b6. */ +typedef void *scmp_filter_ctx; + +enum scmp_compare { + SCMP_CMP_NE = 1, + SCMP_CMP_EQ = 4, + SCMP_CMP_MASKED_EQ = 7, +}; + +struct scmp_arg_cmp { + unsigned int arg; + enum scmp_compare op; + uint64_t datum_a; + uint64_t datum_b; +}; + +#define SCMP_ACT_ERRNO(value) (0x00050000U | ((uint32_t)(value) & 0x0000ffffU)) +#define SCMP_ACT_ALLOW 0x7fff0000U + +#define TRUSTED_BWRAP "/usr/bin/bwrap" +#define FILTER_FD 3 + +enum launcher_exit { + EXIT_INVALID_BWRAP = 64, + EXIT_INVALID_ARGUMENTS = 65, + EXIT_LIBSECCOMP = 66, + EXIT_FILTER = 67, + EXIT_MEMFD = 68, + EXIT_EXPORT = 69, + EXIT_SEAL = 70, + EXIT_EXEC = 71, +}; + +struct seccomp_api { + void *handle; + scmp_filter_ctx (*init)(uint32_t); + void (*release)(scmp_filter_ctx); + int (*rule_add_exact_array)( + scmp_filter_ctx, + uint32_t, + int, + unsigned int, + const struct scmp_arg_cmp * + ); + int (*export_bpf)(scmp_filter_ctx, int); + int (*resolve_name)(const char *); +}; + +static void fail_message(const char *message) +{ + (void)fprintf(stderr, "odysseus-seccomp-launcher: %s\n", message); +} + +#ifdef ODYSSEUS_LAUNCHER_TESTING +static bool injected_failure(const char *stage) +{ + const char *requested = getenv("ODYSSEUS_TEST_FAIL"); + return requested != NULL && strcmp(requested, stage) == 0; +} +#else +static bool injected_failure(const char *stage) +{ + (void)stage; + return false; +} +#endif + +static bool load_symbol(void *handle, const char *name, void *destination, size_t size) +{ + void *symbol = dlsym(handle, name); + if (symbol == NULL || size != sizeof(symbol)) { + return false; + } + memcpy(destination, &symbol, sizeof(symbol)); + return true; +} + +static bool load_seccomp(struct seccomp_api *api) +{ + if (injected_failure("libseccomp")) { + return false; + } + api->handle = dlopen("libseccomp.so.2", RTLD_NOW | RTLD_LOCAL); + if (api->handle == NULL) { + return false; + } + return load_symbol(api->handle, "seccomp_init", &api->init, sizeof(api->init)) + && load_symbol( + api->handle, + "seccomp_release", + &api->release, + sizeof(api->release) + ) + && load_symbol( + api->handle, + "seccomp_rule_add_exact_array", + &api->rule_add_exact_array, + sizeof(api->rule_add_exact_array) + ) + && load_symbol( + api->handle, + "seccomp_export_bpf", + &api->export_bpf, + sizeof(api->export_bpf) + ) + && load_symbol( + api->handle, + "seccomp_syscall_resolve_name", + &api->resolve_name, + sizeof(api->resolve_name) + ); +} + +static int add_rule( + const struct seccomp_api *api, + scmp_filter_ctx filter, + uint32_t action, + const char *name, + unsigned int argument_count, + const struct scmp_arg_cmp *arguments +) +{ + int syscall_number = api->resolve_name(name); + if (syscall_number < 0) { + return -1; + } + return api->rule_add_exact_array( + filter, + action, + syscall_number, + argument_count, + arguments + ); +} + +static int add_allowlist( + const struct seccomp_api *api, + scmp_filter_ctx filter, + const char *const *names, + size_t count +) +{ + for (size_t index = 0; index < count; index++) { + int syscall_number = api->resolve_name(names[index]); + /* Moby's portable lists contain pseudo syscall numbers for calls that + * do not exist on the running architecture. The default-deny action + * already covers them; only add native, nonnegative syscall numbers. */ + if (syscall_number < 0) { + continue; + } + if (api->rule_add_exact_array( + filter, + SCMP_ACT_ALLOW, + syscall_number, + 0, + NULL + ) < 0) { + return -1; + } + } + return 0; +} + +static int add_exact_argument_rules( + const struct seccomp_api *api, + scmp_filter_ctx filter, + const char *name, + unsigned int argument, + const uint64_t *values, + size_t count +) +{ + for (size_t index = 0; index < count; index++) { + const struct scmp_arg_cmp comparison = { + .arg = argument, + .op = SCMP_CMP_EQ, + .datum_a = values[index], + .datum_b = 0, + }; + if (add_rule(api, filter, SCMP_ACT_ALLOW, name, 1, &comparison) < 0) { + return -1; + } + } + return 0; +} + +static scmp_filter_ctx build_filter(const struct seccomp_api *api) +{ + static const uint64_t socket_families[] = {AF_UNIX, AF_INET, AF_INET6}; + static const uint64_t socketpair_families[] = {AF_UNIX}; + static const uint64_t personality_values[] = { + 0, + 8, + 131072, + 131080, + UINT32_MAX, + }; +#if defined(__x86_64__) + const char *const *allowlist = ODYSSEUS_ALLOWED_X86_64; + const size_t allowlist_count = ODYSSEUS_ALLOWED_X86_64_COUNT; +#elif defined(__aarch64__) + const char *const *allowlist = ODYSSEUS_ALLOWED_AARCH64; + const size_t allowlist_count = ODYSSEUS_ALLOWED_AARCH64_COUNT; +#else +#error "odysseus-seccomp-launcher supports only x86_64 and aarch64" +#endif + + if (injected_failure("filter")) { + return NULL; + } + scmp_filter_ctx filter = api->init(SCMP_ACT_ERRNO(EPERM)); + if (filter == NULL) { + return NULL; + } + + const struct scmp_arg_cmp clone_comparison = { + .arg = 0, + .op = SCMP_CMP_MASKED_EQ, + .datum_a = ODYSSEUS_CLONE_NAMESPACE_MASK, + .datum_b = 0, + }; + const struct scmp_arg_cmp ioctl_comparison = { + .arg = 1, + .op = SCMP_CMP_NE, + .datum_a = TIOCSTI, + .datum_b = 0, + }; + const struct scmp_arg_cmp tiocsti_comparison = { + /* Linux truncates the ioctl command to unsigned int after the seccomp + * check. Match the low 32 bits so high bits cannot bypass the deny. */ + .arg = 1, + .op = SCMP_CMP_MASKED_EQ, + .datum_a = UINT32_MAX, + .datum_b = TIOCSTI, + }; + + if (add_allowlist(api, filter, allowlist, allowlist_count) < 0 + || add_rule(api, filter, SCMP_ACT_ALLOW, "clone", 1, &clone_comparison) < 0 + || add_rule(api, filter, SCMP_ACT_ERRNO(ENOSYS), "clone3", 0, NULL) < 0 + /* The action must differ from the default EPERM for libseccomp to + * retain a masked deny rule alongside the compatibility allow rule. + * Add the deny first: affected libseccomp releases can otherwise + * weaken an overlapping 64-bit comparison while merging the tree. */ + || add_rule( + api, + filter, + SCMP_ACT_ERRNO(EACCES), + "ioctl", + 1, + &tiocsti_comparison + ) < 0 + || add_rule(api, filter, SCMP_ACT_ALLOW, "ioctl", 1, &ioctl_comparison) < 0 + || add_exact_argument_rules( + api, + filter, + "personality", + 0, + personality_values, + sizeof(personality_values) / sizeof(personality_values[0]) + ) < 0 + || add_exact_argument_rules( + api, + filter, + "socket", + 0, + socket_families, + sizeof(socket_families) / sizeof(socket_families[0]) + ) < 0 + || add_exact_argument_rules( + api, + filter, + "socketpair", + 0, + socketpair_families, + sizeof(socketpair_families) / sizeof(socketpair_families[0]) + ) < 0) { + api->release(filter); + return NULL; + } + return filter; +} + +static bool valid_bwrap(const char *path) +{ + struct stat metadata; + char resolved[PATH_MAX]; + if (path == NULL || strcmp(path, TRUSTED_BWRAP) != 0) { + return false; + } + if (realpath(path, resolved) == NULL || strcmp(resolved, TRUSTED_BWRAP) != 0) { + return false; + } + return stat(path, &metadata) == 0 + && S_ISREG(metadata.st_mode) + && metadata.st_uid == 0 + && (metadata.st_mode & (S_ISUID | S_ISGID | S_IWGRP | S_IWOTH)) == 0 + && access(path, X_OK) == 0; +} + +static bool forbidden_seccomp_option(const char *argument) +{ + return strcmp(argument, "--seccomp") == 0 + || strncmp(argument, "--seccomp=", 10) == 0 + || strcmp(argument, "--add-seccomp-fd") == 0 + || strncmp(argument, "--add-seccomp-fd=", 17) == 0 + /* An args file could smuggle either seccomp option past this scan. */ + || strcmp(argument, "--args") == 0 + || strncmp(argument, "--args=", 7) == 0; +} + +static int find_command_separator(int argc, char **argv) +{ + for (int index = 2; index < argc; index++) { + if (strcmp(argv[index], "--") == 0) { + return index; + } + if (forbidden_seccomp_option(argv[index])) { + return -2; + } + } + return -1; +} + +static bool seal_filter_fd(int filter_fd) +{ + const int required = F_SEAL_WRITE | F_SEAL_GROW | F_SEAL_SHRINK | F_SEAL_SEAL; + if (injected_failure("seal") || lseek(filter_fd, 0, SEEK_SET) < 0) { + return false; + } + if (fcntl(filter_fd, F_ADD_SEALS, required) < 0) { + return false; + } + int actual = fcntl(filter_fd, F_GET_SEALS); + return actual >= 0 && (actual & required) == required; +} + +#ifdef ODYSSEUS_LAUNCHER_TESTING +static bool inspect_filter_fd(int filter_fd) +{ + static const char memfd_prefix[] = "/memfd:odysseus-inner-seccomp"; + struct stat metadata; + char link_path[64]; + char target[128]; + int written = snprintf(link_path, sizeof(link_path), "/proc/self/fd/%d", filter_fd); + if (written < 0 || (size_t)written >= sizeof(link_path)) { + return false; + } + ssize_t length = readlink(link_path, target, sizeof(target) - 1); + if (length < 0 || (size_t)length >= sizeof(target)) { + return false; + } + target[length] = '\0'; + return fstat(filter_fd, &metadata) == 0 + && S_ISREG(metadata.st_mode) + && strncmp(target, memfd_prefix, strlen(memfd_prefix)) == 0; +} +#endif + +static bool retain_only_filter_fd(int filter_fd) +{ + if (filter_fd != FILTER_FD) { + if (dup3(filter_fd, FILTER_FD, 0) < 0) { + return false; + } + (void)close(filter_fd); + } else if (fcntl(FILTER_FD, F_SETFD, 0) < 0) { + return false; + } + +#ifdef SYS_close_range + if (syscall(SYS_close_range, 4U, UINT_MAX, 0U) == 0) { + return true; + } + if (errno != ENOSYS && errno != EINVAL) { + return false; + } +#endif + + struct rlimit limit; + if (getrlimit(RLIMIT_NOFILE, &limit) < 0) { + return false; + } + rlim_t maximum = limit.rlim_cur == RLIM_INFINITY ? 1048576U : limit.rlim_cur; + for (rlim_t descriptor = 4; descriptor < maximum; descriptor++) { + (void)close((int)descriptor); + } + return true; +} + +int main(int argc, char **argv) +{ + if (argc < 4 || !valid_bwrap(argv[1])) { + fail_message("invalid trusted Bubblewrap path"); + return EXIT_INVALID_BWRAP; + } + int separator = find_command_separator(argc, argv); + if (separator < 2 || separator + 1 >= argc) { + fail_message("invalid Bubblewrap arguments"); + return EXIT_INVALID_ARGUMENTS; + } + + struct seccomp_api api = {0}; + if (!load_seccomp(&api)) { + if (api.handle != NULL) { + (void)dlclose(api.handle); + } + fail_message("libseccomp is unavailable or incompatible"); + return EXIT_LIBSECCOMP; + } + scmp_filter_ctx filter = build_filter(&api); + if (filter == NULL) { + (void)dlclose(api.handle); + fail_message("inner seccomp filter creation failed"); + return EXIT_FILTER; + } + + int filter_fd = injected_failure("memfd") + ? -1 + : memfd_create( + "odysseus-inner-seccomp", + MFD_CLOEXEC | MFD_ALLOW_SEALING + ); + if (filter_fd < 0) { + api.release(filter); + (void)dlclose(api.handle); + fail_message("anonymous filter storage creation failed"); + return EXIT_MEMFD; + } + if (injected_failure("export") || api.export_bpf(filter, filter_fd) < 0) { + (void)close(filter_fd); + api.release(filter); + (void)dlclose(api.handle); + fail_message("inner seccomp filter export failed"); + return EXIT_EXPORT; + } + if (!seal_filter_fd(filter_fd)) { + (void)close(filter_fd); + api.release(filter); + (void)dlclose(api.handle); + fail_message("inner seccomp filter sealing failed"); + return EXIT_SEAL; + } + +#ifdef ODYSSEUS_LAUNCHER_TESTING + if (injected_failure("inspect")) { + bool valid = inspect_filter_fd(filter_fd); + (void)close(filter_fd); + api.release(filter); + (void)dlclose(api.handle); + return valid ? 0 : EXIT_SEAL; + } +#endif + + api.release(filter); + (void)dlclose(api.handle); + if (!retain_only_filter_fd(filter_fd)) { + fail_message("inner seccomp filter descriptor setup failed"); + return EXIT_SEAL; + } + + char descriptor[16]; + int descriptor_length = snprintf(descriptor, sizeof(descriptor), "%d", FILTER_FD); + if (descriptor_length <= 0 || (size_t)descriptor_length >= sizeof(descriptor)) { + fail_message("inner seccomp filter descriptor setup failed"); + return EXIT_SEAL; + } + char **bwrap_argv = calloc((size_t)argc + 2U, sizeof(*bwrap_argv)); + if (bwrap_argv == NULL) { + fail_message("inner seccomp filter creation failed"); + return EXIT_FILTER; + } + int output = 0; + for (int index = 1; index < separator; index++) { + bwrap_argv[output++] = argv[index]; + } + bwrap_argv[output++] = "--seccomp"; + bwrap_argv[output++] = descriptor; + for (int index = separator; index < argc; index++) { + bwrap_argv[output++] = argv[index]; + } + bwrap_argv[output] = NULL; + + if (injected_failure("exec")) { + errno = ENOENT; + } else { + execv(TRUSTED_BWRAP, bwrap_argv); + } + free(bwrap_argv); + fail_message("trusted Bubblewrap execution failed"); + return EXIT_EXEC; +} diff --git a/security/seccomp/policy.json b/security/seccomp/policy.json new file mode 100644 index 000000000..eb5d70af8 --- /dev/null +++ b/security/seccomp/policy.json @@ -0,0 +1,111 @@ +{ + "version": 1, + "moby": { + "repository": "https://github.com/moby/moby", + "commit": "35797366d7cdae8d1d84eac06fbb314ccaf3ccaf", + "path": "vendor/github.com/moby/profiles/seccomp/default.json", + "upstream_sha256": "536529b665dd0972c37bfb569f5d4ac8a53592e7b00752bc39ff063ca9864c74" + }, + "target_arches": { + "x86_64": "amd64", + "aarch64": "arm64" + }, + "default_errno": "EPERM", + "clone3_errno": "ENOSYS", + "tiocsti_errno": "EACCES", + "clone_namespace_mask": 2114060288, + "socket_families": [ + "AF_UNIX", + "AF_INET", + "AF_INET6" + ], + "socketpair_families": [ + "AF_UNIX" + ], + "personality_values": [ + 0, + 8, + 131072, + 131080, + 4294967295 + ], + "conditional_syscalls": [ + "clone", + "clone3", + "ioctl", + "personality", + "socket", + "socketpair" + ], + "denied_syscalls": [ + "acct", + "add_key", + "bpf", + "delete_module", + "fanotify_init", + "fanotify_mark", + "finit_module", + "fsconfig", + "fsmount", + "fsopen", + "fspick", + "init_module", + "io_uring_enter", + "io_uring_register", + "io_uring_setup", + "ioperm", + "iopl", + "kcmp", + "kexec_file_load", + "kexec_load", + "keyctl", + "lookup_dcookie", + "lsm_get_self_attr", + "lsm_list_modules", + "lsm_set_self_attr", + "memfd_secret", + "mount", + "mount_setattr", + "move_mount", + "name_to_handle_at", + "open_by_handle_at", + "open_tree", + "perf_event_open", + "pidfd_getfd", + "pivot_root", + "process_madvise", + "process_mrelease", + "process_vm_readv", + "process_vm_writev", + "ptrace", + "quotactl", + "quotactl_fd", + "reboot", + "request_key", + "setdomainname", + "sethostname", + "setns", + "socketcall", + "swapoff", + "swapon", + "syslog", + "umount", + "umount2", + "unshare", + "userfaultfd", + "vhangup" + ], + "outer_bubblewrap": { + "clone_flags": 2114060305, + "bootstrap_syscalls": [ + "mount", + "pivot_root", + "umount2" + ], + "bubblewrap_version_basis": "v0.11.0", + "bubblewrap_tag_object": "a871b148b7bc0571f50b917cd5fd03b427f54ed1", + "bubblewrap_commit": "9ca3b05ec787acfb4b17bed37db5719fa777834f", + "bubblewrap_release_sha256": "988fd6b232dafa04b8b8198723efeaccdb3c6aa9c1c7936219d5791a8b7a8646", + "shipped_package_basis": "0.11.0-2+deb13u1" + } +} diff --git a/src/agent_loop.py b/src/agent_loop.py index eb1ebe65e..fe5725a58 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -31,6 +31,7 @@ ) from src.settings import get_setting from src.prompt_security import untrusted_context_message +from src.execution_sandbox import SandboxNetworkProfile from src.tool_security import ( blocked_tools_for_owner, email_tool_policy_names, @@ -3440,6 +3441,7 @@ async def stream_agent_loop( _is_teacher_run: bool = False, history_session=None, defer_context_shaping: bool = False, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, ) -> AsyncGenerator[str, None]: """Streaming agent loop generator. @@ -4547,6 +4549,7 @@ async def _run_approved_tool(): workspace=workspace, security_context=run_security, exact_approval=exact_approval, + network_profile=network_profile, ) finally: await approved_progress_q.put(None) @@ -5768,6 +5771,7 @@ async def _run_tool(): progress_cb=_push_progress, workspace=workspace, security_context=run_security, + network_profile=network_profile, ) finally: # Sentinel so the drainer knows to stop. @@ -6412,6 +6416,7 @@ async def _run_tool(): tool_policy=tool_policy, active_document=active_document, active_email=active_email, + network_profile=network_profile, ): yield evt except Exception as _esc_err: diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index 1c407b112..072df9bf1 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -1,4 +1,5 @@ import asyncio +import hashlib import os import re import shutil @@ -8,6 +9,21 @@ from typing import Optional, Callable, Awaitable, Tuple, Dict from core.platform_compat import IS_WINDOWS, find_bash from src.constants import MAX_OUTPUT_CHARS +from src.execution_sandbox import ( + SandboxNetworkProfile, + SandboxUnavailable, + environment_for_sandbox_launcher, + full_access_command, + sandbox_command, + sandbox_python_executable, +) +from src.process_execution import ( + FULL_ACCESS_WARNING, + ProcessExecutionMode, + blocked_process_result, + configured_process_execution_mode, + process_capability, +) DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour DEFAULT_PYTHON_TIMEOUT = 60 * 60 @@ -15,18 +31,13 @@ PROGRESS_INTERVAL_S = 2.0 PROGRESS_TAIL_LINES = 12 TMUX_CAPTURE_LINES = 2000 +_TMUX_ENV_SCRUBBER = "/usr/bin/env" +_TMUX_LOCKS: dict[str, asyncio.Lock] = {} +_TMUX_OWNED_SESSIONS: set[str] = set() async def _create_bash_subprocess(command: str, **kwargs): - """Start the agent shell with Bash semantics on every supported OS. - - ``asyncio.create_subprocess_shell`` delegates to ``cmd.exe`` on native - Windows. That contradicts the Bash tool contract and makes POSIX commands - such as ``pwd``, ``ls -la``, and ``cat`` unreliable even when the launcher - has found Git Bash. Pass the selected workspace as a structural ``cwd`` - argument; Git Bash inherits that native Windows directory and exposes it - using its normal ``/c/...`` representation. - """ + """Start the compatibility Bash subprocess path for direct callers.""" if IS_WINDOWS: bash = find_bash() if not bash: @@ -38,16 +49,68 @@ async def _create_bash_subprocess(command: str, **kwargs): return await asyncio.create_subprocess_shell(command, **kwargs) -def _tmux_session_name(session_id: Optional[str]) -> str: +def _tmux_session_prefix( + session_id: Optional[str], + workspace: str = "", + *, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, +) -> str: + raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-") + workspace_key = hashlib.sha256( + os.path.realpath(workspace or ".").encode("utf-8", errors="replace") + ).hexdigest()[:10] + network_key = network_profile.value.replace("_", "-") + return f"ody-agent-sbx-v2-{raw[:60] or 'default'}-{workspace_key}-{network_key}" + + +def _tmux_legacy_session_name( + session_id: Optional[str], + workspace: str, + *, + network_profile: SandboxNetworkProfile, +) -> str: + raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-") + workspace_key = hashlib.sha256( + os.path.realpath(workspace or ".").encode("utf-8", errors="replace") + ).hexdigest()[:10] + network_key = network_profile.value.replace("_", "-") + return f"ody-agent-sbx-v1-{raw[:60] or 'default'}-{workspace_key}-{network_key}" + + +def _tmux_pre_sandbox_session_name(session_id: Optional[str]) -> str: + """Return the exact tmux name used before the sandboxed v1 format.""" raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-") return f"ody-agent-{raw[:80] or 'default'}" +def _tmux_session_name( + session_id: Optional[str], + workspace: str, + *, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, + policy_key: str, +) -> str: + if not isinstance(policy_key, str) or not policy_key.strip(): + raise ValueError("tmux sandbox sessions require a policy key") + safe_policy_key = re.sub(r"[^A-Za-z0-9_.-]+", "-", policy_key).strip("-") + if not safe_policy_key: + raise ValueError("tmux sandbox sessions require a policy key") + return f"{_tmux_session_prefix(session_id, workspace, network_profile=network_profile)}-{safe_policy_key}" + + +def _tmux_policy_key(workspace_stat: os.stat_result, shell_argv: list[str]) -> str: + encoded = "\0".join( + [str(workspace_stat.st_dev), str(workspace_stat.st_ino), *shell_argv] + ).encode("utf-8", errors="surrogateescape") + return hashlib.sha256(encoded).hexdigest()[:16] + + async def _run_exec(*args: str, timeout: float = 10) -> Tuple[str, str, int]: proc = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env={}, ) try: out_b, err_b = await asyncio.wait_for(proc.communicate(), timeout=timeout) @@ -69,6 +132,63 @@ async def _tmux_has_session(name: str) -> bool: return rc == 0 +async def _tmux_session_names() -> list[str]: + out, err, rc = await _run_exec( + "tmux", + "list-sessions", + "-F", + "#{session_name}", + timeout=5, + ) + if rc == 0: + return [line.strip() for line in out.splitlines() if line.strip()] + detail = f"{out}\n{err}".casefold() + if "no server running" in detail or "failed to connect to server" in detail: + return [] + raise RuntimeError(f"failed to list tmux sessions: {(err or out).strip()}") + + +async def _tmux_kill_session(name: str) -> None: + out, err, rc = await _run_exec("tmux", "kill-session", "-t", name, timeout=5) + if rc == 0: + _TMUX_OWNED_SESSIONS.discard(name) + return + detail = f"{out}\n{err}".casefold() + if ( + "no server running" in detail + or "session not found" in detail + or "can't find session" in detail + ): + _TMUX_OWNED_SESSIONS.discard(name) + return + raise RuntimeError(f"failed to terminate stale tmux session {name}: {(err or out).strip()}") + + +async def _cleanup_stale_tmux_sessions( + prefix: str, + legacy_names: tuple[str, ...], + current_name: Optional[str], +) -> None: + """Terminate stale sessions for one logical workspace/network identity. + + ``current_name=None`` means fresh policy construction failed, so every v2 + session for this logical identity is stale and must be terminated. + """ + existing = await _tmux_session_names() + legacy = set(legacy_names) + stale = { + name + for name in existing + if name in legacy + or ( + name.startswith(f"{prefix}-") + and (current_name is None or name != current_name) + ) + } + for name in sorted(stale): + await _tmux_kill_session(name) + + async def _tmux_capture(name: str) -> str: out, _, _ = await _run_exec( "tmux", "capture-pane", "-p", "-J", "-S", f"-{TMUX_CAPTURE_LINES}", "-t", name, @@ -83,23 +203,42 @@ async def _tmux_send_line(name: str, line: str) -> None: await _run_exec("tmux", "send-keys", "-t", name, "C-m", timeout=5) -async def _ensure_tmux_session(name: str, cwd: str, env: Optional[dict]) -> None: +async def _ensure_tmux_session( + name: str, + cwd: str, + shell_argv: list[str], +) -> None: if await _tmux_has_session(name): - await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5) - return - await _run_exec( + if name not in _TMUX_OWNED_SESSIONS: + # A matching name that this process did not create is not evidence + # of the expected namespace or launch policy. Recreate it rather + # than sending model commands into an unverifiable host shell. + await _tmux_kill_session(name) + else: + await _run_exec( + "tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5 + ) + return + if not os.path.isfile(_TMUX_ENV_SCRUBBER): + raise RuntimeError("trusted tmux environment scrubber is unavailable") + _, launch_error, _ = await _run_exec( "tmux", "new-session", "-d", "-s", name, "-c", cwd, - "env", - f"TERM={env.get('TERM', 'xterm-256color') if env else 'xterm-256color'}", - f"COLUMNS={env.get('COLUMNS', '120') if env else '120'}", - f"LINES={env.get('LINES', '40') if env else '40'}", - "/bin/bash", - "--noprofile", - "--norc", + _TMUX_ENV_SCRUBBER, "-i", *shell_argv, timeout=10, ) if not await _tmux_has_session(name): + if ( + launch_error.startswith("odysseus-seccomp-launcher:") + or launch_error.startswith("odysseus-egress-broker:") + or launch_error.startswith("odysseus-egress-bridge:") + or launch_error.startswith("bwrap:") + ): + raise RuntimeError( + "sandbox setup failed for the persistent shell; verify the " + "trusted launcher and outer OCI seccomp compatibility" + ) raise RuntimeError(f"failed to create tmux session {name}") + _TMUX_OWNED_SESSIONS.add(name) await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5) @@ -135,53 +274,90 @@ async def _run_tmux_bash( *, session_id: str, cwd: str, - env: Optional[dict], timeout: float, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, -) -> Tuple[str, str, Optional[int], bool]: - name = _tmux_session_name(session_id) - await _ensure_tmux_session(name, cwd, env) - - stamp = f"{int(time.time() * 1000)}-{abs(hash(content)) % 1000000}" - start_marker = f"__ODYSSEUS_CMD_START_{stamp}__" - end_prefix = f"__ODYSSEUS_CMD_END_{stamp}__:" - wrapped = ( - f"printf '\\n{start_marker}\\n'\n" - f"{content}\n" - f"__ody_rc=$?\n" - f"printf '\\n{end_prefix}%s\\n' \"$__ody_rc\"\n" + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, +) -> Tuple[str, str, Optional[int], bool, str]: + canonical_cwd = os.path.realpath(cwd) + prefix = _tmux_session_prefix( + session_id, + canonical_cwd, + network_profile=network_profile, + ) + legacy_names = ( + _tmux_legacy_session_name( + session_id, + canonical_cwd, + network_profile=network_profile, + ), + _tmux_pre_sandbox_session_name(session_id), ) - for line in wrapped.splitlines(): - await _tmux_send_line(name, line) + lock = _TMUX_LOCKS.setdefault(prefix, asyncio.Lock()) - started = time.time() - last_tail = "" - while True: - capture = await _tmux_capture(name) - body, done = _output_after_marker(capture, start_marker, end_prefix) - tail = "\n".join(body.splitlines()[-PROGRESS_TAIL_LINES:]) - if progress_cb and tail != last_tail: - last_tail = tail - try: - await progress_cb({ - "elapsed_s": round(time.time() - started, 1), - "tail": tail, - "tmux_session": name, - }) - except Exception: - pass - if done: - rc = _extract_marker_rc(capture, end_prefix) - cleaned = _clean_tmux_command_output(body, wrapped) - return cleaned, "", rc, False - if time.time() - started > timeout: - try: - await _run_exec("tmux", "send-keys", "-t", name, "C-c", timeout=3) - except Exception: - pass - cleaned = _clean_tmux_command_output(body, wrapped) - return cleaned, "", 124, True - await asyncio.sleep(0.5) + async with lock: + try: + shell_argv = sandbox_command( + ["/bin/bash", "--noprofile", "--norc"], + workspace=canonical_cwd, + network_profile=network_profile, + ) + workspace_stat = os.stat(canonical_cwd) + except (OSError, RuntimeError, SandboxUnavailable): + # A previously valid persistent shell must not survive after the + # workspace can no longer produce an acceptable sandbox policy. + await _cleanup_stale_tmux_sessions(prefix, legacy_names, None) + raise + + policy_key = _tmux_policy_key(workspace_stat, shell_argv) + name = _tmux_session_name( + session_id, + canonical_cwd, + network_profile=network_profile, + policy_key=policy_key, + ) + await _cleanup_stale_tmux_sessions(prefix, legacy_names, name) + await _ensure_tmux_session(name, canonical_cwd, shell_argv) + + stamp = f"{int(time.time() * 1000)}-{abs(hash(content)) % 1000000}" + start_marker = f"__ODYSSEUS_CMD_START_{stamp}__" + end_prefix = f"__ODYSSEUS_CMD_END_{stamp}__:" + wrapped = ( + f"printf '\\n{start_marker}\\n'\n" + f"{content}\n" + f"__ody_rc=$?\n" + f"printf '\\n{end_prefix}%s\\n' \"$__ody_rc\"\n" + ) + for line in wrapped.splitlines(): + await _tmux_send_line(name, line) + + started = time.time() + last_tail = "" + while True: + capture = await _tmux_capture(name) + body, done = _output_after_marker(capture, start_marker, end_prefix) + tail = "\n".join(body.splitlines()[-PROGRESS_TAIL_LINES:]) + if progress_cb and tail != last_tail: + last_tail = tail + try: + await progress_cb({ + "elapsed_s": round(time.time() - started, 1), + "tail": tail, + "tmux_session": name, + }) + except Exception: + pass + if done: + rc = _extract_marker_rc(capture, end_prefix) + cleaned = _clean_tmux_command_output(body, wrapped) + return cleaned, "", rc, False, name + if time.time() - started > timeout: + try: + await _run_exec("tmux", "send-keys", "-t", name, "C-c", timeout=3) + except Exception: + pass + cleaned = _clean_tmux_command_output(body, wrapped) + return cleaned, "", 124, True, name + await asyncio.sleep(0.5) def _clean_tmux_command_output(text: str, wrapped_command: str) -> str: @@ -294,90 +470,408 @@ async def _progress_emitter(): timed_out, ) + +def _sandbox_setup_failure( + tool: str, + stderr: str, + returncode: Optional[int], +) -> Optional[Dict]: + """Convert trusted-launcher/Bubblewrap setup failures into a safe result.""" + stripped = (stderr or "").strip() + if stripped.startswith("odysseus-seccomp-launcher:"): + detail = stripped.split(":", 1)[1].strip() + return { + "error": ( + f"{tool}: Sandbox setup failed: {detail}. " + "No unsandboxed fallback was attempted." + ), + "exit_code": 1, + "blocked": True, + } + if stripped.startswith(("odysseus-egress-broker:", "odysseus-egress-bridge:")): + detail = stripped.split(":", 1)[1].strip() + return { + "error": ( + f"{tool}: Brokered Internet setup failed: {detail}. " + "No raw-network or unsandboxed fallback was attempted." + ), + "exit_code": 1, + "blocked": True, + } + if returncode and stripped.startswith("bwrap:"): + return { + "error": ( + f"{tool}: Bubblewrap could not establish the required private " + "namespaces and mounts. Verify the shipped outer OCI seccomp " + "profile and host user-namespace support. No unsandboxed " + "fallback was attempted." + ), + "exit_code": 1, + "blocked": True, + } + return None + class BashTool: async def execute(self, content: str, ctx: dict) -> dict: from src.tool_execution import agent_cwd, _truncate + if isinstance(content, dict): - content = str(content.get("command") or content.get("cmd") or content.get("code") or "") + content = str( + content.get("command") + or content.get("cmd") + or content.get("code") + or "" + ) progress_cb = ctx.get("progress_cb") - _subproc_env = ctx.get("subproc_env") session_id = ctx.get("session_id") - # tmux is a POSIX persistence path. A stray MSYS/Cygwin tmux.exe on - # native Windows must not bypass the Git Bash launcher below: the tmux - # setup hard-codes /bin/bash and cannot safely consume a native cwd. - if session_id and not IS_WINDOWS and shutil.which("tmux"): - stdout, stderr, rc, timed_out = await _run_tmux_bash( - content, - session_id=str(session_id), - cwd=agent_cwd(), - env=_subproc_env, + network_profile = ctx.get( + "network_profile", SandboxNetworkProfile.NETWORKLESS + ) + workspace = agent_cwd() + execution_mode = configured_process_execution_mode() + + if execution_mode is ProcessExecutionMode.FULL_ACCESS: + if IS_WINDOWS: + return blocked_process_result( + "bash", + execution_mode, + "Full Access with retained network isolation requires Linux and Bubblewrap.", + ) + capability = process_capability().full_access + if not capability.supports(network_profile): + return blocked_process_result( + "bash", + execution_mode, + capability.reason_for(network_profile), + ) + try: + argv = full_access_command( + ["/bin/bash", "--noprofile", "--norc", "-c", content], + working_directory=workspace, + network_profile=network_profile, + ) + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=environment_for_sandbox_launcher(), + cwd=workspace, + ) + except (OSError, RuntimeError, SandboxUnavailable) as exc: + return { + "error": f"bash: {exc}", + "exit_code": 1, + "blocked": True, + "execution_mode": execution_mode.value, + } + stdout, stderr, rc, timed_out = await _run_subprocess_streaming( + proc, timeout=DEFAULT_BASH_TIMEOUT, progress_cb=progress_cb, ) if timed_out: return { - "error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — sent Ctrl-C to tmux session", + "error": ( + f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — " + "process killed" + ), "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), - "tmux_session": _tmux_session_name(str(session_id)), + "execution_mode": execution_mode.value, + "warning": FULL_ACCESS_WARNING, } + setup_failure = _sandbox_setup_failure("bash", stderr, rc) + if setup_failure: + setup_failure["execution_mode"] = execution_mode.value + setup_failure["warning"] = FULL_ACCESS_WARNING + return setup_failure output = stdout.rstrip() err = stderr.rstrip() if err: - output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err + output = ( + (output + "\nSTDERR: " + err).strip() + if output + else "STDERR: " + err + ) return { "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)", "exit_code": rc or 0, - "tmux_session": _tmux_session_name(str(session_id)), + "execution_mode": execution_mode.value, + "network_enforcement": ( + "brokered_http_https" + if network_profile is SandboxNetworkProfile.BROKERED_ONLY + else "networkless" + ), + "warning": FULL_ACCESS_WARNING, + } + + if IS_WINDOWS: + return blocked_process_result( + "bash", + execution_mode, + "Sandbox mode requires Linux with bubblewrap.", + ) + capability = process_capability().sandbox + if not capability.supports(network_profile): + return blocked_process_result( + "bash", + execution_mode, + capability.reason_for(network_profile), + ) + + # Persistent tmux is available only in Sandbox mode. Full Access uses + # one-shot processes so an unsandboxed shell cannot silently outlive a + # later mode change. + if session_id and shutil.which("tmux"): + try: + stdout, stderr, rc, timed_out, tmux_session = await _run_tmux_bash( + content, + session_id=str(session_id), + cwd=workspace, + timeout=DEFAULT_BASH_TIMEOUT, + progress_cb=progress_cb, + network_profile=network_profile, + ) + except (OSError, RuntimeError, SandboxUnavailable) as exc: + return { + "error": f"bash: {exc}", + "exit_code": 1, + "blocked": True, + "execution_mode": execution_mode.value, + } + if timed_out: + return { + "error": ( + f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — " + "sent Ctrl-C to tmux session" + ), + "exit_code": 124, + "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), + "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), + "tmux_session": tmux_session, + "execution_mode": execution_mode.value, + } + output = stdout.rstrip() + err = stderr.rstrip() + if err: + output = ( + (output + "\nSTDERR: " + err).strip() + if output + else "STDERR: " + err + ) + return { + "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)", + "exit_code": rc or 0, + "tmux_session": tmux_session, + "execution_mode": execution_mode.value, } try: - proc = await _create_bash_subprocess( - content, + argv = sandbox_command( + ["/bin/bash", "--noprofile", "--norc", "-c", content], + workspace=workspace, + network_profile=network_profile, + ) + proc = await asyncio.create_subprocess_exec( + *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - env=_subproc_env, - cwd=agent_cwd(), + env=environment_for_sandbox_launcher(), + cwd=workspace, ) - except RuntimeError as e: - return {"error": f"bash: {e}", "exit_code": 1} + except (OSError, RuntimeError, SandboxUnavailable) as exc: + return { + "error": f"bash: {exc}", + "exit_code": 1, + "blocked": True, + "execution_mode": execution_mode.value, + } stdout, stderr, rc, timed_out = await _run_subprocess_streaming( proc, timeout=DEFAULT_BASH_TIMEOUT, progress_cb=progress_cb, ) if timed_out: - return {"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS)} + return { + "error": ( + f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — process killed" + ), + "exit_code": 124, + "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), + "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), + "execution_mode": execution_mode.value, + } + setup_failure = _sandbox_setup_failure("bash", stderr, rc) + if setup_failure: + setup_failure["execution_mode"] = execution_mode.value + return setup_failure output = stdout.rstrip() err = stderr.rstrip() if err: - output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err - output = _truncate(output, MAX_OUTPUT_CHARS) - return {"output": output or "(no output)", "exit_code": rc or 0} + output = ( + (output + "\nSTDERR: " + err).strip() + if output + else "STDERR: " + err + ) + return { + "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)", + "exit_code": rc or 0, + "execution_mode": execution_mode.value, + } + class PythonTool: async def execute(self, content: str, ctx: dict) -> dict: from src.tool_execution import agent_cwd, _truncate + + if isinstance(content, dict): + content = str(content.get("code") or content.get("command") or "") progress_cb = ctx.get("progress_cb") - _subproc_env = ctx.get("subproc_env") - proc = await asyncio.create_subprocess_exec( - (sys.executable or "python"), "-I", "-c", content, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=_subproc_env, - cwd=agent_cwd(), + network_profile = ctx.get( + "network_profile", SandboxNetworkProfile.NETWORKLESS ) + workspace = agent_cwd() + execution_mode = configured_process_execution_mode() + + if execution_mode is ProcessExecutionMode.FULL_ACCESS: + if IS_WINDOWS: + return blocked_process_result( + "python", + execution_mode, + "Full Access with retained network isolation requires Linux and Bubblewrap.", + ) + capability = process_capability().full_access + if not capability.supports(network_profile): + return blocked_process_result( + "python", + execution_mode, + capability.reason_for(network_profile), + ) + try: + argv = full_access_command( + [sys.executable, "-I", "-c", content], + working_directory=workspace, + network_profile=network_profile, + ) + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=environment_for_sandbox_launcher(), + cwd=workspace, + ) + except (OSError, RuntimeError, SandboxUnavailable) as exc: + return { + "error": f"python: {exc}", + "exit_code": 1, + "blocked": True, + "execution_mode": execution_mode.value, + } + stdout, stderr, rc, timed_out = await _run_subprocess_streaming( + proc, + timeout=DEFAULT_PYTHON_TIMEOUT, + progress_cb=progress_cb, + ) + if timed_out: + return { + "error": ( + f"python: timed out after {DEFAULT_PYTHON_TIMEOUT}s — " + "process killed" + ), + "exit_code": 124, + "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), + "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), + "execution_mode": execution_mode.value, + "warning": FULL_ACCESS_WARNING, + } + setup_failure = _sandbox_setup_failure("python", stderr, rc) + if setup_failure: + setup_failure["execution_mode"] = execution_mode.value + setup_failure["warning"] = FULL_ACCESS_WARNING + return setup_failure + output = stdout.rstrip() + err = stderr.rstrip() + if err: + output = ( + (output + "\nSTDERR: " + err).strip() + if output + else "STDERR: " + err + ) + return { + "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)", + "exit_code": rc or 0, + "execution_mode": execution_mode.value, + "network_enforcement": ( + "brokered_http_https" + if network_profile is SandboxNetworkProfile.BROKERED_ONLY + else "networkless" + ), + "warning": FULL_ACCESS_WARNING, + } + + if IS_WINDOWS: + return blocked_process_result( + "python", + execution_mode, + "Sandbox mode requires Linux with bubblewrap.", + ) + capability = process_capability().sandbox + if not capability.supports(network_profile): + return blocked_process_result( + "python", + execution_mode, + capability.reason_for(network_profile), + ) + try: + argv = sandbox_command( + [sandbox_python_executable(), "-I", "-c", content], + workspace=workspace, + network_profile=network_profile, + ) + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=environment_for_sandbox_launcher(), + cwd=workspace, + ) + except (OSError, RuntimeError, SandboxUnavailable) as exc: + return { + "error": f"python: {exc}", + "exit_code": 1, + "blocked": True, + "execution_mode": execution_mode.value, + } stdout, stderr, rc, timed_out = await _run_subprocess_streaming( proc, timeout=DEFAULT_PYTHON_TIMEOUT, progress_cb=progress_cb, ) if timed_out: - return {"error": f"python: timed out after {DEFAULT_PYTHON_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS)} + return { + "error": ( + f"python: timed out after {DEFAULT_PYTHON_TIMEOUT}s — process killed" + ), + "exit_code": 124, + "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), + "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), + "execution_mode": execution_mode.value, + } + setup_failure = _sandbox_setup_failure("python", stderr, rc) + if setup_failure: + setup_failure["execution_mode"] = execution_mode.value + return setup_failure output = stdout.rstrip() err = stderr.rstrip() if err: - output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err - output = _truncate(output, MAX_OUTPUT_CHARS) - return {"output": output or "(no output)", "exit_code": rc or 0} + output = ( + (output + "\nSTDERR: " + err).strip() + if output + else "STDERR: " + err + ) + return { + "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)", + "exit_code": rc or 0, + "execution_mode": execution_mode.value, + } diff --git a/src/bg_jobs.py b/src/bg_jobs.py index f864f8ef1..6844a95de 100644 --- a/src/bg_jobs.py +++ b/src/bg_jobs.py @@ -1,4 +1,4 @@ -"""Background job execution for the agent's `bash` tool. +"""Sandboxed background job execution for the agent's `bash` tool. Long commands (installs, ffmpeg, model downloads) should NOT block the chat stream — a multi-minute held SSE connection is fragile (model-stops-early, @@ -14,16 +14,21 @@ * Bounded: a hard max-runtime marks a runaway job failed and STILL triggers a follow-up ("timed out"), so you always hear back. -This module only owns launch + state. The monitor / agent re-invocation lives -in the caller (so this stays import-light and unit-testable). +This module only owns launch + state. Model commands execute through the +server-selected process boundary: the default workspace Sandbox or explicitly +confirmed Full Access, both retaining the private network policy. A tiny isolated +Python wrapper outside that boundary only records output and the exit code. The +monitor / agent re-invocation lives in the caller (so this stays import-light and +unit-testable). """ from __future__ import annotations +import hashlib import json import os -import shlex import subprocess +import sys import time import uuid from pathlib import Path @@ -31,14 +36,25 @@ from core.atomic_io import atomic_write_json from core.platform_compat import ( + IS_WINDOWS, detached_popen_kwargs, - find_bash, - git_bash_path, kill_process_tree, pid_alive, ) from src.constants import BG_JOBS_DIR, BG_JOBS_FILE +from src.execution_sandbox import ( + SandboxNetworkProfile, + environment_for_sandbox_launcher, + full_access_command, + sandbox_command, +) +from src.process_execution import ( + FULL_ACCESS_WARNING, + ProcessExecutionMode, + configured_process_execution_mode, + process_capability, +) _JOBS_DIR = Path(BG_JOBS_DIR) _STORE = Path(BG_JOBS_FILE) @@ -53,6 +69,59 @@ # without bound. The agent has already consumed the result by then. _RETENTION_S = 3600 # 1 hour after follow-up +_DETACHED_SANDBOX_WRAPPER = """ +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + +plan_path = Path(sys.argv[1]) +expected_digest = sys.argv[2] +log_path = Path(sys.argv[3]) +exit_path = Path(sys.argv[4]) +code = 1 + +def write_private_text(path, text): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(text) + +try: + plan_bytes = plan_path.read_bytes() + actual_digest = hashlib.sha256(plan_bytes).hexdigest() + if actual_digest != expected_digest: + raise RuntimeError("detached process plan digest mismatch") + plan = json.loads(plan_bytes) + if plan.get("version") != 1 or not isinstance(plan.get("argv"), list): + raise RuntimeError("invalid detached process plan") + argv = plan["argv"] + if not argv or not all(isinstance(part, str) for part in argv): + raise RuntimeError("invalid detached process argv") + child_env = {} + log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(log_fd, "wb") as output: + completed = subprocess.run( + argv, + stdin=subprocess.DEVNULL, + stdout=output, + stderr=subprocess.STDOUT, + env=child_env, + check=False, + ) + code = int(completed.returncode) +except Exception as exc: + try: + write_private_text(log_path, f"process launch failed: {exc}\\n") + except Exception: + pass +try: + write_private_text(exit_path, str(code)) +except Exception: + pass +""".strip() + def _load() -> Dict[str, Dict[str, Any]]: try: @@ -78,85 +147,197 @@ def _pid_alive(pid: Optional[int]) -> bool: return pid_alive(pid) -def launch(command: str, session_id: str, cwd: Optional[str] = None, - max_runtime_s: int = DEFAULT_MAX_RUNTIME_S) -> Dict[str, Any]: +def _make_jobs_dir_private() -> None: + _JOBS_DIR.mkdir(parents=True, exist_ok=True, mode=0o700) + try: + _JOBS_DIR.chmod(0o700) + except (AttributeError, NotImplementedError): + pass + + +def _write_private_file(path: Path, content: str) -> None: + fd = os.open( + path, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as stream: + stream.write(content) + except BaseException: + try: + os.close(fd) + except OSError: + pass + raise + + +def _remove_job_record(job_id: str) -> None: + try: + jobs = _load() + if job_id not in jobs: + return + jobs.pop(job_id, None) + try: + _save(jobs) + except BaseException: + atomic_write_json(str(_STORE), jobs, indent=2) + except BaseException: + pass + + +def _remove_job_artifacts( + job_id: str, + created_paths: list[Path], + preexisting_paths: set[Path] = frozenset(), +) -> None: + paths = set(created_paths) + try: + paths.update(_JOBS_DIR.glob(f"{job_id}.*")) + except OSError: + pass + paths.difference_update(preexisting_paths) + for path in paths: + try: + path.unlink() + except (FileNotFoundError, IsADirectoryError, OSError): + pass + + +def _kill_untracked_process(proc: subprocess.Popen) -> None: + try: + _kill(proc.pid) + except BaseException: + pass + try: + proc.wait(timeout=2) + except BaseException: + pass + + +def launch( + command: str, + session_id: str, + cwd: Optional[str] = None, + max_runtime_s: int = DEFAULT_MAX_RUNTIME_S, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, +) -> Dict[str, Any]: """Launch `command` detached. Returns the job record (status='running'). Output + the final exit code are written to files so status survives a server restart. The process is put in its own session (setsid) so it outlives the request/stream that started it. """ - _JOBS_DIR.mkdir(parents=True, exist_ok=True) + if IS_WINDOWS: + raise RuntimeError( + "Sandboxed agent execution requires Linux with bubblewrap." + ) + _make_jobs_dir_private() job_id = uuid.uuid4().hex[:12] log_path = _JOBS_DIR / f"{job_id}.log" exit_path = _JOBS_DIR / f"{job_id}.exit" + cmd_path = _JOBS_DIR / f"{job_id}.cmd.sh" + plan_path = _JOBS_DIR / f"{job_id}.plan.json" + try: + preexisting_paths = set(_JOBS_DIR.glob(f"{job_id}.*")) + except OSError: + preexisting_paths = set() + created_paths: list[Path] = [cmd_path, plan_path, log_path, exit_path] + proc: subprocess.Popen | None = None + record_saved = False - # The user command goes in its OWN script file, run as a child `bash`. This - # is what isolates it: an `exit` inside it only ends that child (so the - # wrapper still records the exit code), and — unlike textually wrapping the - # command in `( … )` — the wrapper can't be broken by an unbalanced paren or - # a trailing line-continuation in the command. `$?` is the child's real - # exit status. - bash = find_bash() - if bash: - # POSIX, or Windows with Git Bash/WSL. The user command goes in its OWN - # script file, run as a child `bash` — an `exit` inside it only ends - # that child (so the wrapper still records the exit code), and an - # unbalanced paren / trailing line-continuation in the command can't - # break the wrapper. `$?` is the child's real exit status. Paths are - # emitted as POSIX (forward-slash) + shell-quoted so Git Bash on Windows - # handles drive paths and spaces correctly. - cmd_path = _JOBS_DIR / f"{job_id}.cmd.sh" - cmd_path.write_text(command + "\n", encoding="utf-8") - lp, xp, cp = (shlex.quote(git_bash_path(p)) for p in (log_path, exit_path, cmd_path)) - script_path = _JOBS_DIR / f"{job_id}.sh" - script_path.write_text( - f"bash {cp} > {lp} 2>&1\n" - f"echo $? > {xp}\n", - encoding="utf-8", - ) - argv = [bash, str(script_path)] - else: - # Windows without any bash installed: cmd.exe wrapper. The command runs - # in its own child .cmd so %ERRORLEVEL% is the command's real exit code. - child_path = _JOBS_DIR / f"{job_id}.child.cmd" - child_path.write_text("@echo off\r\n" + command + "\r\n", encoding="utf-8") - script_path = _JOBS_DIR / f"{job_id}.cmd" - script_path.write_text( - "@echo off\r\n" - f'call "{child_path}" > "{log_path}" 2>&1\r\n' - f'echo %ERRORLEVEL%> "{exit_path}"\r\n', - encoding="utf-8", + try: + _write_private_file(cmd_path, command + "\n") + execution_mode = configured_process_execution_mode() + capability = process_capability().for_mode(execution_mode) + if not capability.supports(network_profile): + raise RuntimeError( + f"{execution_mode.value} process boundary unavailable: " + + capability.reason_for(network_profile) + ) + if execution_mode is ProcessExecutionMode.SANDBOX: + process_argv = sandbox_command( + ["/bin/bash", "--noprofile", "--norc", "/run/odysseus/command.sh"], + workspace=cwd or "", + readonly_files={str(cmd_path): "/run/odysseus/command.sh"}, + network_profile=network_profile, + ) + else: + process_argv = full_access_command( + ["/bin/bash", "--noprofile", "--norc", str(cmd_path)], + working_directory=cwd or "", + network_profile=network_profile, + ) + wrapper_environment = environment_for_sandbox_launcher() + + plan_bytes = json.dumps( + { + "version": 1, + "argv": process_argv, + }, + separators=(",", ":"), + ).encode("utf-8") + plan_digest = hashlib.sha256(plan_bytes).hexdigest() + _write_private_file(plan_path, plan_bytes.decode("utf-8")) + + argv = [ + sys.executable, + "-I", + "-c", + _DETACHED_SANDBOX_WRAPPER, + str(plan_path), + plan_digest, + str(log_path), + str(exit_path), + ] + proc = subprocess.Popen( + argv, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + cwd=None, + env=wrapper_environment, + **detached_popen_kwargs(), # detach from the request lifecycle (setsid) ) - argv = [os.environ.get("ComSpec", "cmd.exe"), "/c", str(script_path)] - - proc = subprocess.Popen( - argv, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - cwd=cwd or None, - **detached_popen_kwargs(), # detach from the request lifecycle (setsid / DETACHED_PROCESS) - ) - rec = { - "id": job_id, - "session_id": session_id, - "command": command, - "status": "running", # running | done | failed - "pid": proc.pid, - "started_at": time.time(), - "ended_at": None, - "exit_code": None, - "max_runtime_s": max_runtime_s, - "followed_up": False, # has the agent been re-invoked with the result? - "log_path": str(log_path), - "exit_path": str(exit_path), - } - jobs = _load() - jobs[job_id] = rec - _save(jobs) - return rec + rec = { + "id": job_id, + "session_id": session_id, + "command": command, + "status": "running", # running | done | failed + "pid": proc.pid, + "started_at": time.time(), + "ended_at": None, + "exit_code": None, + "max_runtime_s": max_runtime_s, + "network_profile": network_profile.value, + "execution_mode": execution_mode.value, + "network_enforcement": ( + "brokered_http_https" + if network_profile is SandboxNetworkProfile.BROKERED_ONLY + else "networkless" + ), + "warning": ( + FULL_ACCESS_WARNING + if execution_mode is ProcessExecutionMode.FULL_ACCESS + else "" + ), + "followed_up": False, # has the agent been re-invoked with the result? + "log_path": str(log_path), + "exit_path": str(exit_path), + } + jobs = _load() + jobs[job_id] = rec + _save(jobs) + record_saved = True + return rec + except BaseException: + if proc is not None and not record_saved: + _kill_untracked_process(proc) + if not record_saved: + _remove_job_record(job_id) + _remove_job_artifacts(job_id, created_paths, preexisting_paths) + raise def _read_output(rec: Dict[str, Any]) -> str: @@ -294,4 +475,10 @@ def result_text(rec: Dict[str, Any]) -> str: head = "Background job process died unexpectedly (no exit code)." else: head = f"Background job finished with exit code {rec.get('exit_code')}." - return f"{head}\nCommand: {rec.get('command')}\n\nOutput:\n{out or '(no output)'}" + authority = f"Execution mode: {rec.get('execution_mode', 'sandbox')}" + if rec.get("warning"): + authority += f"\nWARNING: {rec['warning']}" + return ( + f"{head}\n{authority}\nCommand: {rec.get('command')}" + f"\n\nOutput:\n{out or '(no output)'}" + ) diff --git a/src/bg_monitor.py b/src/bg_monitor.py index c45066e3d..7fb20852a 100644 --- a/src/bg_monitor.py +++ b/src/bg_monitor.py @@ -16,6 +16,10 @@ from src import bg_jobs from src.prompt_security import untrusted_context_message +from src.execution_sandbox import ( + SandboxNetworkProfile, + network_profile_from_snapshot, +) logger = logging.getLogger(__name__) @@ -36,7 +40,12 @@ def _background_result_message(rec): return untrusted_context_message("background job output", inject) -async def _drain_agent(sess, messages): +async def _drain_agent( + sess, + messages, + *, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, +): """Run the agent loop headless against a session. Returns (final_prose, tool_events) — tool_events in the same shape the live chat saves, so the frontend rebuilds them as standard agent-thread tool cards.""" @@ -51,6 +60,7 @@ async def _drain_agent(sess, messages): session_id=sess.id, max_rounds=_FOLLOWUP_MAX_ROUNDS, owner=getattr(sess, "owner", None), + network_profile=network_profile, ): if not chunk.startswith("data: "): continue @@ -121,7 +131,11 @@ async def _run_followup(rec: dict) -> bool: context = sess.get_context_messages() context.append(_background_result_message(rec)) - full, tool_events = await _drain_agent(sess, context) + full, tool_events = await _drain_agent( + sess, + context, + network_profile=network_profile_from_snapshot(rec.get("network_profile")), + ) # Persist ONLY the assistant continuation so it renders as a normal agent # turn — a standard chat bubble plus `tool_events` that the frontend @@ -135,6 +149,8 @@ async def _run_followup(rec: dict) -> bool: "model": sess.model, "bg_job_id": rec["id"], "bg_result": bg_jobs.result_text(rec)[:4000], + "execution_mode": rec.get("execution_mode", "sandbox"), + "execution_warning": rec.get("warning") or "", }, )) sm.save_sessions() diff --git a/src/constants.py b/src/constants.py index 584494290..87818943c 100644 --- a/src/constants.py +++ b/src/constants.py @@ -9,6 +9,7 @@ # Base paths BASE_DIR = os.path.join(get_app_root(), "") STATIC_DIR = os.path.join(BASE_DIR, "static") +LOGS_DIR = os.path.join(BASE_DIR, "logs") DATA_DIR = os.getenv("ODYSSEUS_DATA_DIR", get_default_data_dir()) # Data file paths @@ -44,6 +45,7 @@ RAG_DIR = os.path.join(DATA_DIR, "rag") CHROMA_DIR = os.path.join(DATA_DIR, "chroma") BG_JOBS_DIR = os.path.join(DATA_DIR, "bg_jobs") +AGENT_WORKSPACE_DIR = os.path.join(DATA_DIR, "agent_workspace") DEEP_RESEARCH_DIR = os.path.join(DATA_DIR, "deep_research") MCP_OAUTH_DIR = os.path.join(DATA_DIR, "mcp_oauth") GENERATED_IMAGES_DIR = os.path.join(DATA_DIR, "generated_images") diff --git a/src/execution_sandbox.py b/src/execution_sandbox.py new file mode 100644 index 000000000..b480a588f --- /dev/null +++ b/src/execution_sandbox.py @@ -0,0 +1,742 @@ +"""Linux process sandbox construction for model-requested code execution. + +The application process remains the policy authority. Model-supplied commands +are only appended after a fixed bubblewrap profile has removed the host +filesystem, inherited environment, network namespace, and ambient capabilities. +""" + +from __future__ import annotations + +import os +import re +import stat +import sys +from enum import Enum +from pathlib import Path +from typing import Mapping, Sequence + + +class SandboxUnavailable(RuntimeError): + """Raised when the requested sandbox cannot be established safely.""" + + +class SandboxNetworkProfile(str, Enum): + """Server-owned network authority snapshotted when a process starts.""" + + NETWORKLESS = "networkless" + BROKERED_ONLY = "brokered_only" + + +def network_profile_for_internet_preference(enabled: bool) -> SandboxNetworkProfile: + """Map the existing user Internet preference to process-boundary policy.""" + return ( + SandboxNetworkProfile.BROKERED_ONLY + if enabled + else SandboxNetworkProfile.NETWORKLESS + ) + + +def network_profile_from_snapshot(value: object) -> SandboxNetworkProfile: + """Restore a persisted server snapshot without ever widening authority.""" + try: + return SandboxNetworkProfile(value) + except (TypeError, ValueError): + return SandboxNetworkProfile.NETWORKLESS + + +_BROAD_WORKSPACE_ROOTS = frozenset( + { + "/", + "/bin", + "/boot", + "/dev", + "/etc", + "/home", + "/lib", + "/lib64", + "/opt", + "/proc", + "/root", + "/run", + "/srv", + "/sys", + "/tmp", + "/usr", + "/var", + } +) +_SYSTEM_WORKSPACE_ROOTS = frozenset( + { + "/bin", + "/boot", + "/dev", + "/etc", + "/lib", + "/lib64", + "/proc", + "/root", + "/run", + "/sys", + "/usr", + } +) +_SENSITIVE_DIR_NAMES = frozenset( + { + ".agents", + ".aws", + ".azure", + ".codex", + ".cargo", + ".docker", + ".gnupg", + ".kube", + ".ssh", + } +) +_SENSITIVE_WORKSPACE_ROOT_NAMES = frozenset( + { + ".aws", + ".azure", + ".config", + ".docker", + ".git", + ".gnupg", + ".kube", + ".ssh", + } +) +_SENSITIVE_FILE_NAMES = frozenset( + { + ".bash_login", + ".bash_profile", + ".bash_logout", + ".bashrc", + ".cshrc", + ".git-credentials", + ".gitconfig", + ".netrc", + ".npmrc", + ".pgpass", + ".profile", + ".pypirc", + ".tcshrc", + ".zprofile", + ".zshenv", + ".zshrc", + "authorized_keys", + "id_ecdsa", + "id_ed25519", + "id_rsa", + } +) +_MAX_WORKSPACE_SCAN_ENTRIES = 100_000 +_MOUNTINFO_ESCAPE = re.compile(r"\\([0-7]{3})") +_MOUNTINFO_PATH = "/proc/self/mountinfo" +_TRUSTED_BWRAP = "/usr/bin/bwrap" +_TRUSTED_SECCOMP_LAUNCHER = "/usr/local/libexec/odysseus-seccomp-launcher" +_TRUSTED_EGRESS_BROKER = "/usr/local/libexec/odysseus-egress-broker" +_TRUSTED_EGRESS_BRIDGE = "/usr/local/libexec/odysseus-egress-bridge" +_BROKER_SOCKET = "/run/odysseus-egress/broker.sock" +_BROKER_PROXY_URL = "http://127.0.0.1:3128" +_CA_CERTIFICATE = "/etc/ssl/certs/ca-certificates.crt" +_SANDBOX_LIMITS = ( + "--as=4294967296", # 4 GiB virtual address space per process + "--core=0", + "--cpu=3600", # one hour of CPU time per process + "--fsize=4294967296", # 4 GiB per output file + "--nofile=1024", +) + + +def _trusted_executable(path: str, description: str) -> str: + """Require a fixed root-owned executable outside model-writable storage.""" + try: + metadata = os.stat(path) + except OSError as exc: + raise SandboxUnavailable( + f"Sandboxed agent execution requires the trusted {description} at {path}." + ) from exc + if ( + os.path.realpath(path) != path + or not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != 0 + or metadata.st_mode + & (stat.S_ISUID | stat.S_ISGID | stat.S_IWGRP | stat.S_IWOTH) + or not os.access(path, os.X_OK) + ): + raise SandboxUnavailable( + f"Trusted {description} is not a root-owned, non-setuid, " + f"read-only executable at {path}." + ) + return path + + +def _trusted_python_helper(path: str, description: str) -> str: + """Require an isolated, fixed-interpreter trusted Python entry point.""" + helper = _trusted_executable(path, description) + try: + with open(helper, "rb") as stream: + first_line = stream.readline(256).decode("ascii").strip() + except (OSError, UnicodeDecodeError) as exc: + raise SandboxUnavailable( + f"Trusted {description} has an invalid interpreter declaration." + ) from exc + fields = first_line.removeprefix("#!").split() + if ( + not first_line.startswith("#!") + or len(fields) != 2 + or fields[1] != "-I" + or not fields[0].startswith("/usr/") + ): + raise SandboxUnavailable( + f"Trusted {description} must use an isolated absolute Python interpreter." + ) + _trusted_executable(fields[0], f"{description} Python interpreter") + return helper + + +def _bubblewrap_binary() -> str: + if not sys.platform.startswith("linux"): + raise SandboxUnavailable( + "Sandboxed agent execution requires Linux with bubblewrap." + ) + return _trusted_executable(_TRUSTED_BWRAP, "Bubblewrap binary") + + +def _seccomp_launcher_binary() -> str: + if not sys.platform.startswith("linux"): + raise SandboxUnavailable( + "Sandboxed agent execution requires Linux with the trusted seccomp launcher." + ) + return _trusted_executable(_TRUSTED_SECCOMP_LAUNCHER, "seccomp launcher") + + +def _egress_broker_binary() -> str: + if not sys.platform.startswith("linux"): + raise SandboxUnavailable( + "Brokered Internet requires Linux with the trusted egress broker." + ) + return _trusted_python_helper(_TRUSTED_EGRESS_BROKER, "egress broker") + + +def _egress_bridge_binary() -> str: + if not sys.platform.startswith("linux"): + raise SandboxUnavailable( + "Brokered Internet requires Linux with the trusted egress bridge." + ) + return _trusted_python_helper(_TRUSTED_EGRESS_BRIDGE, "egress bridge") + + +def _login_home_roots() -> set[str]: + """Return real login-home roots without making account lookup mandatory.""" + homes = { + os.path.realpath(path) + for path in (os.path.expanduser("~"), os.environ.get("HOME", "")) + if path + } + try: + import pwd + + homes.update( + os.path.realpath(entry.pw_dir) + for entry in pwd.getpwall() + if entry.pw_dir and os.path.isabs(entry.pw_dir) + ) + except (ImportError, KeyError, OSError): + pass + return homes + + +def _normalized_workspace(workspace: str) -> str: + if not isinstance(workspace, str) or not workspace.strip(): + raise SandboxUnavailable("Sandboxed execution requires a workspace.") + resolved = os.path.realpath(os.path.expanduser(workspace)) + from src.constants import AGENT_WORKSPACE_DIR + + managed_workspace = os.path.realpath(AGENT_WORKSPACE_DIR) + exposes_login_home = ( + not _is_within(resolved, managed_workspace) + and any( + resolved == home or _is_within(home, resolved) + for home in _login_home_roots() + ) + ) + sensitive_root = Path(resolved).name.casefold() in _SENSITIVE_WORKSPACE_ROOT_NAMES + if sensitive_root: + raise SandboxUnavailable( + f"Refusing sensitive sandbox workspace root: {resolved}" + ) + if ( + resolved in _BROAD_WORKSPACE_ROOTS + or exposes_login_home + or os.path.dirname(resolved) == resolved + or any(_is_within(resolved, root) for root in _SYSTEM_WORKSPACE_ROOTS) + ): + raise SandboxUnavailable( + f"Refusing broad or login-profile sandbox workspace: {resolved}" + ) + try: + Path(resolved).mkdir(mode=0o700, parents=True, exist_ok=True) + except OSError as exc: + raise SandboxUnavailable( + f"Unable to prepare sandbox workspace: {exc}" + ) from exc + if not os.path.isdir(resolved): + raise SandboxUnavailable("Sandbox workspace is not a directory.") + return resolved + + +def _directory_creation_args(path: str, *, include_leaf: bool = True) -> list[str]: + target = Path(path) + parts = target.parts + if not parts or parts[0] != os.sep: + raise SandboxUnavailable(f"Sandbox mount path must be absolute: {path}") + limit = len(parts) if include_leaf else len(parts) - 1 + args: list[str] = [] + current = Path(os.sep) + for part in parts[1:limit]: + current /= part + args.extend(("--dir", str(current))) + return args + + +def _is_sensitive_file(name: str) -> bool: + folded = name.casefold() + return ( + folded in _SENSITIVE_FILE_NAMES + or folded == ".env" + or folded.startswith(".env.") + ) + + +def _reject_nested_workspace_mounts(workspace: str) -> None: + """Reject mount points that a recursive workspace bind would carry in.""" + try: + with open( + _MOUNTINFO_PATH, + encoding="utf-8", + errors="surrogateescape", + ) as stream: + entries = list(stream) + except OSError as exc: + raise SandboxUnavailable( + "Unable to verify sandbox workspace mount boundaries." + ) from exc + + for entry in entries: + fields = entry.split() + if len(fields) < 5: + raise SandboxUnavailable( + "Unable to verify sandbox workspace mount boundaries." + ) + mount_point = _MOUNTINFO_ESCAPE.sub( + lambda match: chr(int(match.group(1), 8)), + fields[4], + ) + resolved_mount = os.path.realpath(mount_point) + if resolved_mount != workspace and _is_within(resolved_mount, workspace): + relative = os.path.relpath(resolved_mount, workspace).replace(os.sep, "/") + raise SandboxUnavailable( + f"Sandbox workspace contains a nested mount: {relative}" + ) + + +def _workspace_overlays( + workspace: str, + *, + excluded_roots: Sequence[str] = (), +) -> list[str]: + """Return mounts that protect repository metadata and credential paths.""" + _reject_nested_workspace_mounts(workspace) + args: list[str] = [] + scanned = 0 + for root, dirs, files in os.walk(workspace, followlinks=False): + dirs.sort() + files.sort() + scanned += len(dirs) + len(files) + if scanned > _MAX_WORKSPACE_SCAN_ENTRIES: + raise SandboxUnavailable( + "Workspace is too large to verify credential-path overlays " + "safely; narrow the workspace before running code." + ) + + retained_dirs: list[str] = [] + for name in dirs: + path = os.path.join(root, name) + folded = name.casefold() + relative = os.path.relpath(path, workspace).replace(os.sep, "/").casefold() + is_symlink = os.path.islink(path) + if is_symlink and ( + folded == ".git" + or folded in _SENSITIVE_DIR_NAMES + or relative == ".config/gh" + ): + raise SandboxUnavailable( + f"Sensitive sandbox path cannot be a symlink: {relative}" + ) + resolved_path = os.path.realpath(path) + if any( + resolved_path == excluded or _is_within(resolved_path, excluded) + for excluded in excluded_roots + ): + continue + if folded == ".git": + args.extend(("--ro-bind", path, path)) + elif folded in _SENSITIVE_DIR_NAMES or relative == ".config/gh": + args.extend(("--tmpfs", path)) + else: + retained_dirs.append(name) + dirs[:] = retained_dirs + + for name in files: + path = os.path.join(root, name) + relative = os.path.relpath(path, workspace).replace(os.sep, "/") + try: + metadata = os.lstat(path) + except OSError as exc: + raise SandboxUnavailable( + f"Unable to verify sandbox workspace entry: {relative}" + ) from exc + if not (stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode)): + raise SandboxUnavailable( + f"Sandbox workspace contains an unsupported special file: {relative}" + ) + if stat.S_ISREG(metadata.st_mode) and metadata.st_nlink > 1: + raise SandboxUnavailable( + f"Sandbox workspace contains a hard-linked file: {relative}" + ) + if name.casefold() == ".git" and os.path.islink(path): + raise SandboxUnavailable( + "Sensitive sandbox path cannot be a symlink: .git" + ) + if name.casefold() == ".git": + args.extend(("--ro-bind", path, path)) + elif _is_sensitive_file(name): + args.extend(("--ro-bind", "/dev/null", path)) + return args + + +def _is_within(path: str, root: str) -> bool: + try: + return os.path.commonpath((path, root)) == root + except (TypeError, ValueError): + return False + + +def _odysseus_data_overlays(workspace: str) -> tuple[list[str], list[str]]: + """Hide application-owned stores even inside a broader selected workspace.""" + from src.constants import ( + AGENT_WORKSPACE_DIR, + APP_DB, + DATA_DIR, + LOGS_DIR, + MAIL_ATTACHMENTS_DIR, + ) + + agent_workspace = os.path.realpath(AGENT_WORKSPACE_DIR) + protected_roots = { + os.path.realpath(DATA_DIR), + os.path.realpath(LOGS_DIR), + os.path.realpath(MAIL_ATTACHMENTS_DIR), + } + top_level_roots = { + candidate + for candidate in protected_roots + if not any( + candidate != other and _is_within(candidate, other) + for other in protected_roots + ) + } + args: list[str] = [] + hidden_roots: list[str] = [] + for protected in sorted(top_level_roots): + if _is_within(workspace, protected): + if _is_within(workspace, agent_workspace): + continue + raise SandboxUnavailable( + "Odysseus application data cannot be selected as an agent " + "process workspace." + ) + if _is_within(protected, workspace) and os.path.isdir(protected): + args.extend(("--tmpfs", protected)) + hidden_roots.append(protected) + + protected_database_paths = {os.path.realpath(APP_DB)} + configured_database = os.environ.get("DATABASE_URL", "").strip() + if configured_database: + from src.runtime_paths import get_app_root + from src.sqlite_paths import resolve_sqlite_db_path + + database_path = resolve_sqlite_db_path( + configured_database, + app_root=get_app_root(), + ) + if database_path is not None: + protected_database_paths.add(database_path) + + for database_path in sorted(protected_database_paths): + if _is_within(database_path, workspace) and not any( + _is_within(database_path, hidden) for hidden in hidden_roots + ): + raise SandboxUnavailable( + "The selected workspace contains an Odysseus SQLite database. " + "Choose a narrower workspace." + ) + return args, hidden_roots + + +def sandbox_python_executable() -> str: + """Choose an interpreter path covered by the read-only /usr runtime mount.""" + current = os.path.realpath(sys.executable or "") + if current.startswith("/usr/") and os.path.isfile(current): + return current + for candidate in ("/usr/local/bin/python3", "/usr/bin/python3"): + if os.path.isfile(candidate): + return candidate + raise SandboxUnavailable("No system Python interpreter is available in /usr.") + + +def sandbox_command( + command: Sequence[str], + *, + workspace: str, + readonly_files: Mapping[str, str] | None = None, + extra_environment: Mapping[str, str] | None = None, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, +) -> list[str]: + """Build a positive-mount bubblewrap command. + + `readonly_files` maps host source files to absolute paths inside the + sandbox. It is intended for server-generated command files, never broad + directories. Network authority is a server-owned launch snapshot. Raw + container networking is never available in Sandbox mode. + """ + if not command or not all(isinstance(part, str) for part in command): + raise SandboxUnavailable("Sandbox command must be a non-empty argv list.") + + if not isinstance(network_profile, SandboxNetworkProfile): + raise SandboxUnavailable("Invalid server-owned sandbox network profile.") + launcher = _seccomp_launcher_binary() + binary = _bubblewrap_binary() + broker = None + bridge = None + if network_profile is SandboxNetworkProfile.BROKERED_ONLY: + broker = _egress_broker_binary() + bridge = _egress_bridge_binary() + if not os.path.isfile(_CA_CERTIFICATE): + raise SandboxUnavailable( + "Brokered Internet requires the system CA certificate bundle." + ) + root = _normalized_workspace(workspace) + trusted_paths = [launcher, binary] + if broker is not None and bridge is not None: + trusted_paths.extend((broker, bridge)) + if any(_is_within(path, root) for path in trusted_paths): + raise SandboxUnavailable( + "Trusted sandbox installation overlaps the selected workspace." + ) + if not os.path.isfile("/usr/bin/prlimit"): + raise SandboxUnavailable( + "Sandboxed agent execution requires `/usr/bin/prlimit`." + ) + args = [launcher, binary] + if broker is not None: + args.insert(0, broker) + args.extend( + [ + "--unshare-user", + "--unshare-ipc", + "--unshare-pid", + "--unshare-net", + "--unshare-uts", + "--unshare-cgroup", + "--die-with-parent", + "--new-session", + "--clearenv", + "--cap-drop", + "ALL", + "--ro-bind", + "/usr", + "/usr", + "--symlink", + "usr/bin", + "/bin", + "--symlink", + "usr/lib", + "/lib", + ] + ) + if os.path.exists("/usr/lib64"): + args.extend(("--symlink", "usr/lib64", "/lib64")) + args.extend( + ( + "--dev", + "/dev", + "--proc", + "/proc", + "--tmpfs", + "/tmp", + "--dir", + "/tmp/odysseus-home", + ) + ) + + args.extend(_directory_creation_args(root)) + args.extend(("--bind", root, root)) + if os.path.isfile(_CA_CERTIFICATE): + args.extend(_directory_creation_args(_CA_CERTIFICATE, include_leaf=False)) + args.extend(("--ro-bind", _CA_CERTIFICATE, _CA_CERTIFICATE)) + data_overlays, hidden_data_roots = _odysseus_data_overlays(root) + args.extend(data_overlays) + args.extend(_workspace_overlays(root, excluded_roots=hidden_data_roots)) + + for source, destination in (readonly_files or {}).items(): + source_path = os.path.realpath(source) + if not os.path.isfile(source_path): + raise SandboxUnavailable( + f"Sandbox read-only input is not a file: {source}" + ) + if not isinstance(destination, str) or not destination.startswith("/"): + raise SandboxUnavailable( + "Sandbox read-only destinations must be absolute paths." + ) + args.extend(_directory_creation_args(destination, include_leaf=False)) + args.extend(("--ro-bind", source_path, destination)) + + environment = { + "COLUMNS": "120", + "HOME": "/tmp/odysseus-home", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "LINES": "40", + "PATH": "/usr/local/bin:/usr/bin:/bin", + "SSL_CERT_FILE": _CA_CERTIFICATE, + "TERM": "xterm-256color", + "TMPDIR": "/tmp", + } + if network_profile is SandboxNetworkProfile.BROKERED_ONLY: + environment.update( + { + "HTTP_PROXY": _BROKER_PROXY_URL, + "HTTPS_PROXY": _BROKER_PROXY_URL, + "http_proxy": _BROKER_PROXY_URL, + "https_proxy": _BROKER_PROXY_URL, + } + ) + for name, value in (extra_environment or {}).items(): + if name in {"COLUMNS", "LINES", "TERM"} and isinstance(value, str): + environment[name] = value[:80] + for name, value in environment.items(): + args.extend(("--setenv", name, value)) + + args.extend(("--chdir", root, "--")) + if bridge is not None: + args.extend((bridge, _BROKER_SOCKET, "--")) + args.extend(process_limited_command(command)) + return args + + +def full_access_command( + command: Sequence[str], + *, + working_directory: str, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, +) -> list[str]: + """Build the explicit full-filesystem profile with retained network policy. + + This profile grants the payload the same filesystem view and permissions as + the Odysseus service user. It still uses a private network namespace: no + Internet by default, or trusted brokered HTTP(S) when explicitly enabled. + It is not an unsandboxed fallback and therefore remains unavailable when the + minimum Bubblewrap/network boundary cannot be established. + """ + if not command or not all(isinstance(part, str) for part in command): + raise SandboxUnavailable("Full Access command must be a non-empty argv list.") + if not isinstance(network_profile, SandboxNetworkProfile): + raise SandboxUnavailable("Invalid server-owned sandbox network profile.") + + cwd = os.path.realpath(os.path.expanduser(working_directory or ".")) + if not os.path.isdir(cwd): + raise SandboxUnavailable("Full Access working directory is unavailable.") + + launcher = _seccomp_launcher_binary() + binary = _bubblewrap_binary() + broker = None + bridge = None + if network_profile is SandboxNetworkProfile.BROKERED_ONLY: + broker = _egress_broker_binary() + bridge = _egress_bridge_binary() + if not os.path.isfile(_CA_CERTIFICATE): + raise SandboxUnavailable( + "Brokered Internet requires the system CA certificate bundle." + ) + + args = [launcher, binary] + if broker is not None: + args.insert(0, broker) + args.extend( + ( + "--unshare-user", + "--unshare-ipc", + "--unshare-pid", + "--unshare-net", + "--unshare-uts", + "--unshare-cgroup", + "--die-with-parent", + "--new-session", + "--clearenv", + "--cap-drop", + "ALL", + "--bind", + "/", + "/", + "--proc", + "/proc", + ) + ) + + environment = { + "COLUMNS": "120", + "HOME": os.path.expanduser("~"), + "LANG": os.environ.get("LANG", "C.UTF-8"), + "LC_ALL": os.environ.get("LC_ALL", "C.UTF-8"), + "LINES": "40", + "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"), + "TERM": os.environ.get("TERM", "xterm-256color"), + "TMPDIR": os.environ.get("TMPDIR", "/tmp"), + } + if os.path.isfile(_CA_CERTIFICATE): + environment["SSL_CERT_FILE"] = _CA_CERTIFICATE + if network_profile is SandboxNetworkProfile.BROKERED_ONLY: + environment.update( + { + "HTTP_PROXY": _BROKER_PROXY_URL, + "HTTPS_PROXY": _BROKER_PROXY_URL, + "http_proxy": _BROKER_PROXY_URL, + "https_proxy": _BROKER_PROXY_URL, + } + ) + for name, value in environment.items(): + args.extend(("--setenv", name, value)) + + args.extend(("--chdir", cwd, "--")) + if bridge is not None: + args.extend((bridge, _BROKER_SOCKET, "--")) + args.extend(process_limited_command(command)) + return args + + +def process_limited_command(command: Sequence[str]) -> list[str]: + """Apply generous per-process ceilings without claiming tree containment.""" + if not command or not all(isinstance(part, str) for part in command): + raise SandboxUnavailable("Process command must be a non-empty argv list.") + if not os.path.isfile("/usr/bin/prlimit"): + raise SandboxUnavailable( + "Agent process execution requires `/usr/bin/prlimit`." + ) + return ["/usr/bin/prlimit", *_SANDBOX_LIMITS, "--", *command] + + +def environment_for_sandbox_launcher() -> dict[str, str]: + """Minimal environment for the trusted bubblewrap launcher itself.""" + return {} diff --git a/src/process_execution.py b/src/process_execution.py new file mode 100644 index 000000000..a9086000e --- /dev/null +++ b/src/process_execution.py @@ -0,0 +1,275 @@ +"""Server-owned process authority and capability state. + +Sandbox is the default on every start. Full Access is a transient administrator +choice for trusted work that grants the process the Odysseus service user's +filesystem authority while retaining the sandbox's private-network policy. It +is never selected automatically after a capability failure and is not persisted +across application restarts. +""" + +from __future__ import annotations + +import subprocess +import tempfile +import threading +import time +from dataclasses import asdict, dataclass +from enum import Enum +from pathlib import Path + + +FULL_ACCESS_CONFIRMATION = "ENABLE FULL ACCESS" +FULL_ACCESS_WARNING = ( + "Full Access lets Bash, Python, and detached process tools read or modify " + "everything available to the Odysseus operating-system user. In Docker " + "that includes the container and mounted volumes; natively it includes the " + "service user's accessible files. Process Internet remains networkless by " + "default and, when enabled, is limited to the trusted HTTP(S) broker. " + "New process launches reset to Sandbox when Odysseus restarts. Any " + "already-running Full Access process retains its launch-time authority " + "until it exits or is killed. Enable it only for trusted tasks." +) +_SANDBOX_STATUS_TTL_SECONDS = 30.0 +_STATUS_LOCK = threading.Lock() +_STATUS_CACHE: tuple[float, "ProcessCapability"] | None = None +_MODE_LOCK = threading.Lock() +_MODE = None + + +class ProcessExecutionMode(str, Enum): + SANDBOX = "sandbox" + FULL_ACCESS = "full_access" + + +@dataclass(frozen=True) +class ProfileCapability: + networkless: bool + networkless_reason: str + brokered: bool + brokered_reason: str + + def supports(self, network_profile: object) -> bool: + from src.execution_sandbox import SandboxNetworkProfile + + if network_profile is SandboxNetworkProfile.BROKERED_ONLY: + return self.networkless and self.brokered + return self.networkless + + def reason_for(self, network_profile: object) -> str: + from src.execution_sandbox import SandboxNetworkProfile + + if network_profile is SandboxNetworkProfile.BROKERED_ONLY: + return self.brokered_reason or self.networkless_reason + return self.networkless_reason + + +@dataclass(frozen=True) +class ProcessCapability: + sandbox: ProfileCapability + full_access: ProfileCapability + checked_at: float + + def as_dict(self) -> dict[str, object]: + return asdict(self) + + def for_mode(self, mode: ProcessExecutionMode) -> ProfileCapability: + if mode is ProcessExecutionMode.FULL_ACCESS: + return self.full_access + return self.sandbox + + +def process_execution_mode_from_value(value: object) -> ProcessExecutionMode: + try: + return ProcessExecutionMode(str(value or "").strip().lower()) + except ValueError: + return ProcessExecutionMode.SANDBOX + + +def configured_process_execution_mode() -> ProcessExecutionMode: + global _MODE + + with _MODE_LOCK: + if _MODE is None: + _MODE = ProcessExecutionMode.SANDBOX + return _MODE + + +def set_process_execution_mode( + mode: ProcessExecutionMode, + *, + confirmation: str = "", +) -> ProcessExecutionMode: + global _MODE + + if not isinstance(mode, ProcessExecutionMode): + raise ValueError("invalid process execution mode") + if ( + mode is ProcessExecutionMode.FULL_ACCESS + and confirmation != FULL_ACCESS_CONFIRMATION + ): + raise ValueError("Full Access confirmation did not match") + with _MODE_LOCK: + _MODE = mode + return mode + + +def reset_process_execution_mode() -> None: + global _MODE + + with _MODE_LOCK: + _MODE = ProcessExecutionMode.SANDBOX + + +def _public_probe_reason(stderr: str, fallback: str) -> str: + detail = (stderr or "").strip().splitlines() + if detail: + first = detail[0].strip() + if first.startswith( + ( + "bwrap:", + "odysseus-seccomp-launcher:", + "odysseus-egress-broker:", + "odysseus-egress-bridge:", + ) + ): + return first[:500] + return fallback[:500] + + +def _probe_command(argv: list[str], workspace: str) -> tuple[bool, str]: + from src.execution_sandbox import environment_for_sandbox_launcher + + try: + completed = subprocess.run( + argv, + cwd=workspace, + env=environment_for_sandbox_launcher(), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + timeout=5, + check=False, + ) + if completed.returncode == 0: + return True, "" + return False, _public_probe_reason( + completed.stderr, + "The process boundary could not be established on this host.", + ) + except subprocess.TimeoutExpired: + return False, "The process capability probe timed out." + except Exception as exc: + return False, str(exc)[:500] + + +def _probe_profile( + workspace: str, + network_profile: object, + *, + full_access: bool, +) -> tuple[bool, str]: + from src.execution_sandbox import full_access_command, sandbox_command + + try: + if full_access: + argv = full_access_command( + ["/bin/true"], + working_directory=workspace, + network_profile=network_profile, + ) + else: + argv = sandbox_command( + ["/bin/true"], + workspace=workspace, + network_profile=network_profile, + ) + except Exception as exc: + return False, str(exc)[:500] + return _probe_command(argv, workspace) + + +def _probe_one_mode(workspace: str, *, full_access: bool) -> ProfileCapability: + from src.execution_sandbox import SandboxNetworkProfile + + networkless, networkless_reason = _probe_profile( + workspace, + SandboxNetworkProfile.NETWORKLESS, + full_access=full_access, + ) + if networkless: + brokered, brokered_reason = _probe_profile( + workspace, + SandboxNetworkProfile.BROKERED_ONLY, + full_access=full_access, + ) + else: + brokered = False + brokered_reason = networkless_reason + return ProfileCapability( + networkless, + networkless_reason, + brokered, + brokered_reason, + ) + + +def _probe_process_capability() -> ProcessCapability: + from src.constants import AGENT_WORKSPACE_DIR + + checked_at = time.time() + probe_parent = Path(AGENT_WORKSPACE_DIR) + try: + probe_parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix=".sandbox-capability-", + dir=str(probe_parent), + ) as workspace: + sandbox = _probe_one_mode(workspace, full_access=False) + full_access = _probe_one_mode(workspace, full_access=True) + except Exception as exc: + reason = str(exc)[:500] + unavailable = ProfileCapability(False, reason, False, reason) + sandbox = unavailable + full_access = unavailable + return ProcessCapability(sandbox, full_access, checked_at) + + +def process_capability(*, refresh: bool = False) -> ProcessCapability: + global _STATUS_CACHE + + now = time.monotonic() + with _STATUS_LOCK: + if ( + not refresh + and _STATUS_CACHE is not None + and now - _STATUS_CACHE[0] < _SANDBOX_STATUS_TTL_SECONDS + ): + return _STATUS_CACHE[1] + status = _probe_process_capability() + _STATUS_CACHE = (now, status) + return status + + +def clear_process_capability_cache() -> None: + global _STATUS_CACHE + + with _STATUS_LOCK: + _STATUS_CACHE = None + + +def blocked_process_result( + tool: str, + mode: ProcessExecutionMode, + reason: str, +) -> dict[str, object]: + return { + "error": ( + f"{tool}: {mode.value.replace('_', ' ').title()} process boundary " + f"unavailable: {reason} Process tools remain blocked; Odysseus " + "never downgrades execution authority automatically." + ), + "exit_code": 1, + "blocked": True, + "execution_mode": mode.value, + } diff --git a/src/sqlite_paths.py b/src/sqlite_paths.py new file mode 100644 index 000000000..b1b4aa1a9 --- /dev/null +++ b/src/sqlite_paths.py @@ -0,0 +1,100 @@ +"""Side-effect-free SQLite URL parsing shared by startup and sandbox policy.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlparse + +from sqlalchemy.engine import make_url + + +def _is_sqlite(parsed_url: Any) -> bool: + try: + return parsed_url.get_backend_name() == "sqlite" + except (AttributeError, TypeError): + return False + + +def _query_value(parsed_url: Any, name: str) -> str: + query = dict(getattr(parsed_url, "query", {}) or {}) + value = query.get(name) + if isinstance(value, (tuple, list)): + value = value[-1] if value else "" + return str(value or "").strip().lower() + + +def normalize_sqlite_url(url: str, *, app_root: str) -> str: + """Resolve ordinary relative SQLite paths while preserving URI filenames.""" + try: + parsed_url = make_url(url) + except Exception: + return url + + if not _is_sqlite(parsed_url): + return url + + database = parsed_url.database + if ( + not database + or str(database) == ":memory:" + or str(database).casefold().startswith("file:") + or os.path.isabs(str(database)) + ): + return url + + absolute_path = (Path(app_root) / str(database)).resolve().as_posix() + return parsed_url.set(database=absolute_path).render_as_string( + hide_password=False + ) + + +def sqlite_db_path(parsed_url: Any) -> str | None: + """Return the path represented by a parsed, file-backed SQLite URL.""" + if not _is_sqlite(parsed_url): + return None + + database = parsed_url.database + if not database or str(database) == ":memory:": + return None + + database = str(database) + is_file_uri = database.casefold().startswith("file:") + uri_enabled = _query_value(parsed_url, "uri") in {"1", "true", "yes", "on"} + if not uri_enabled or not is_file_uri: + return database + + if ( + database.casefold().startswith("file::memory:") + or _query_value(parsed_url, "mode") == "memory" + ): + return None + + parsed_uri = urlparse(database) + filesystem_path = parsed_uri.path or "" + if not filesystem_path or filesystem_path == ":memory:": + return None + + authority = parsed_uri.netloc + if authority and authority.casefold() != "localhost": + filesystem_path = f"//{authority}{filesystem_path}" + + return unquote(filesystem_path) + + +def resolve_sqlite_db_path(url: str, *, app_root: str) -> str | None: + """Resolve a file-backed SQLite URL to a canonical path, even if absent.""" + try: + parsed_url = make_url(url) + except Exception: + return None + + database = sqlite_db_path(parsed_url) + if database is None: + return None + + path = os.path.expanduser(database) + if not os.path.isabs(path): + path = os.path.join(app_root, path) + return os.path.realpath(os.path.abspath(path)) diff --git a/src/teacher_escalation.py b/src/teacher_escalation.py index 59fe85570..31093c188 100644 --- a/src/teacher_escalation.py +++ b/src/teacher_escalation.py @@ -29,6 +29,8 @@ from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse +from src.execution_sandbox import SandboxNetworkProfile + logger = logging.getLogger(__name__) @@ -524,6 +526,7 @@ async def run_teacher_inline( tool_policy: Any = None, active_document: Any = None, active_email: Optional[Dict[str, str]] = None, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, ): """Async generator. Yields SSE event strings. @@ -636,6 +639,7 @@ async def run_teacher_inline( tool_policy=tool_policy, active_document=active_document, active_email=active_email, + network_profile=network_profile, _is_teacher_run=True, ): # Swallow teacher's own [DONE] — outer loop emits the real one diff --git a/src/tool_execution.py b/src/tool_execution.py index 8c0c83032..381abb614 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -29,11 +29,16 @@ ) from src.tool_capabilities import ToolRunSecurityContext, blocked_tool_result from src.tool_approvals import ExactToolApproval +from src.execution_sandbox import SandboxNetworkProfile from src.tool_policy import ToolPolicy -from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR +from src.constants import ( + AGENT_WORKSPACE_DIR, + MAX_OUTPUT_CHARS, + MAX_READ_CHARS, + MAX_DIFF_LINES, +) from src.tool_utils import _truncate, get_mcp_manager - class _MissingToolSecurityContext: pass @@ -45,12 +50,11 @@ class _NoToolSecurityContext: _MISSING_TOOL_SECURITY_CONTEXT = _MissingToolSecurityContext() NO_TOOL_SECURITY_CONTEXT = _NoToolSecurityContext() -# Persistent working directory for agent subprocesses. -# Resolves to /data, which is the bind-mounted volume in Docker -# (/app/data) and the local data directory for manual installs. -# Using this as cwd and HOME prevents the agent from silently creating files -# in ephemeral container layers that are lost on the next rebuild. -_AGENT_WORKDIR = DATA_DIR +# Dedicated persistent workspace for agent subprocesses when the user did not +# select an explicit workspace. Keeping it below (rather than equal to) +# DATA_DIR lets the process sandbox mount this directory without exposing app +# databases, auth state, uploads, logs, or provider credentials. +_AGENT_WORKDIR = AGENT_WORKSPACE_DIR @@ -343,9 +347,8 @@ def _owner_is_admin(owner: Optional[str]) -> bool: # --------------------------------------------------------------------------- # Map legacy tool names -> (MCP server_id, MCP tool_name) +_PROCESS_TOOLS = frozenset({"bash", "python"}) _MCP_TOOL_MAP = { - "bash": ("bash", "bash"), - "python": ("python", "python"), "read_file": ("filesystem", "read_file"), "write_file": ("filesystem", "write_file"), "web_search": ("web_search", "web_search"), @@ -408,8 +411,6 @@ def _parse_write_file(content: str) -> Dict: _MCP_ARG_PARSERS: Dict[str, Callable[[str], Dict[str, str]]] = { - "bash": lambda c: {"command": c}, - "python": lambda c: {"code": c}, "web_search": lambda c: {"query": c.split("\n")[0].strip()}, "web_fetch": lambda c: {"url": c.split("\n")[0].strip()}, "read_file": lambda c: {"path": c.split("\n")[0].strip()}, @@ -463,11 +464,17 @@ async def _call_mcp_tool( tool: str, content: str, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, ) -> Dict: """Route a legacy tool call through the MCP manager, with direct fallbacks.""" mcp = get_mcp_manager() if not mcp: - return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1} + return await _direct_fallback( + tool, + content, + progress_cb=progress_cb, + network_profile=network_profile, + ) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1} server_id, tool_name = _MCP_TOOL_MAP[tool] qualified = f"mcp__{server_id}__{tool_name}" @@ -476,7 +483,12 @@ async def _call_mcp_tool( # If MCP server not connected, try direct fallback if isinstance(result, dict) and result.get("exit_code") == 1 and "not connected" in result.get("error", ""): - fallback = await _direct_fallback(tool, content, progress_cb=progress_cb) + fallback = await _direct_fallback( + tool, + content, + progress_cb=progress_cb, + network_profile=network_profile, + ) if fallback: return fallback @@ -536,21 +548,14 @@ async def _direct_fallback( progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, session_id: Optional[str] = None, owner: Optional[str] = None, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, ) -> Optional[Dict]: - _subproc_env = { - **os.environ, - "TERM": "xterm-256color", - "COLUMNS": "120", - "LINES": "40", - "HOME": _AGENT_WORKDIR, - } - try: ctx = { "progress_cb": progress_cb, - "subproc_env": _subproc_env, "session_id": session_id, "owner": owner, + "network_profile": network_profile, } from src.agent_tools import TOOL_HANDLERS @@ -604,6 +609,7 @@ async def execute_tool_block( | _MissingToolSecurityContext ) = _MISSING_TOOL_SECURITY_CONTEXT, exact_approval: Optional[ExactToolApproval] = None, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, ) -> Tuple[str, Dict]: """Execute a single tool block. Returns (description, result_dict). @@ -718,6 +724,7 @@ async def execute_tool_block( owner=owner, progress_cb=progress_cb, tool_policy=tool_policy, + network_profile=network_profile, approved_document_id=( exact_approval.pending.document_id if approval_claimed @@ -752,6 +759,7 @@ async def _execute_tool_block_impl( owner: Optional[str] = None, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, tool_policy: Optional[Any] = None, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, approved_document_id: Optional[str] = None, approved_document_version: Optional[int] = None, approved_document_digest: Optional[str] = None, @@ -871,7 +879,22 @@ async def _execute_tool_block_impl( _is_bg, _bg_cmd = _split_bg_marker(content) if _is_bg and _bg_cmd: from src import bg_jobs - rec = bg_jobs.launch(_bg_cmd, session_id=session_id, cwd=agent_cwd()) + try: + rec = bg_jobs.launch( + _bg_cmd, + session_id=session_id, + cwd=agent_cwd(), + network_profile=network_profile, + ) + except Exception as exc: + return ( + "bash (background): BLOCKED", + { + "error": f"Unable to launch sandboxed background job: {exc}", + "exit_code": 1, + "blocked": True, + }, + ) short = _bg_cmd.strip().split(chr(10))[0][:80] desc = f"bash (background): {short}" result = { @@ -886,17 +909,40 @@ async def _execute_tool_block_impl( ), "exit_code": 0, "bg_job_id": rec["id"], + "execution_mode": rec.get("execution_mode", "sandbox"), } + if rec.get("warning"): + result["warning"] = rec["warning"] logger.info(f"Tool executed: {desc} -> bg job {rec['id']}") return desc, result - # Route MCP-extracted tools through the MCP manager. Forward - # the progress callback so long-running subprocess tools - # (bash, python) can stream `tool_progress` events to the UI. - if tool in _MCP_TOOL_MAP: + # Process tools have a native sandbox boundary and must never be + # intercepted by a configured MCP server with the same name. + if tool in _PROCESS_TOOLS: + first_line = content.split(chr(10))[0][:80] + desc = f"{tool}: {first_line}" + result = await _direct_fallback( + tool, + content, + progress_cb=progress_cb, + session_id=session_id, + owner=owner, + network_profile=network_profile, + ) or { + "error": f"{tool}: execution failed", + "exit_code": 1, + "blocked": True, + } + # Route remaining MCP-extracted tools through the MCP manager. + elif tool in _MCP_TOOL_MAP: first_line = content.split(chr(10))[0][:80] desc = f"{tool}: {first_line}" - result = await _call_mcp_tool(tool, content, progress_cb=progress_cb) + result = await _call_mcp_tool( + tool, + content, + progress_cb=progress_cb, + network_profile=network_profile, + ) elif tool in ("grep", "glob", "ls", "get_workspace"): # Code-navigation tools — no MCP server; run the direct implementation. first_line = content.split(chr(10))[0][:80] @@ -1108,7 +1154,12 @@ async def _execute_tool_block_impl( elif tool in dynamic_handlers: first_line = content.split(chr(10))[0][:80] desc = f"registry: {tool} {first_line}".strip() - res = await _direct_fallback(tool, content, progress_cb=progress_cb) + res = await _direct_fallback( + tool, + content, + progress_cb=progress_cb, + network_profile=network_profile, + ) if isinstance(res, tuple): desc, result = res diff --git a/static/index.html b/static/index.html index 4dd4c6795..83df358e7 100644 --- a/static/index.html +++ b/static/index.html @@ -2379,6 +2379,27 @@

+
+

+ + Process Execution + + +

+
+ Sandbox is the default. Bash, Python, tmux, and detached jobs are + blocked when their selected process boundary cannot be established. + Full Access expands filesystem authority only; process networking + remains private and Internet-enabled execution stays brokered. +
+
+ +
+
+

Built-in Tools

Enable or disable tools available to the AI agent.
diff --git a/static/js/settings.js b/static/js/settings.js index 09552b26c..9d61bf739 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -1552,6 +1552,171 @@ async function initAgentSettings() { } +function syncProcessExecutionBanner(data) { + const mode = data?.mode || 'sandbox'; + let banner = document.getElementById('process-full-access-banner'); + if (mode !== 'full_access') { + if (banner) banner.remove(); + return; + } + if (!banner) { + banner = document.createElement('div'); + banner.id = 'process-full-access-banner'; + banner.setAttribute('role', 'status'); + Object.assign(banner.style, { + position: 'fixed', top: '10px', left: '50%', transform: 'translateX(-50%)', + zIndex: '99999', padding: '7px 14px', borderRadius: '6px', + background: 'var(--red, #ff5555)', color: '#fff', fontSize: '11px', + fontWeight: '700', letterSpacing: '0.02em', pointerEvents: 'none', + boxShadow: '0 2px 14px rgba(0,0,0,0.45)', + }); + document.body.appendChild(banner); + } + banner.textContent = 'FULL ACCESS PROCESS MODE — FILESYSTEM AUTHORITY EXPANDED; NETWORK PRIVATE, INTERNET BROKERED IF ENABLED'; +} + +async function initProcessExecutionSettings() { + const toggle = el('set-process-full-access-toggle'); + const statusEl = el('set-process-execution-status'); + const warningEl = el('set-process-full-access-warning'); + const msgEl = el('set-process-execution-msg'); + if (!toggle || !statusEl || !warningEl || !msgEl || !window._isAdmin) return; + + let current = null; + + function profileFor(mode) { + const capability = current?.capability || {}; + return mode === 'full_access' + ? (capability.full_access || {}) + : (capability.sandbox || {}); + } + + function render(data) { + current = data || current || {}; + syncProcessExecutionBanner(current); + const mode = current.mode || 'sandbox'; + const profile = profileFor(mode); + toggle.checked = mode === 'full_access'; + warningEl.textContent = current.full_access_warning || ''; + warningEl.classList.toggle('hidden', mode !== 'full_access'); + + if (mode === 'full_access') { + if (!profile.networkless) { + statusEl.textContent = 'Full Access selected, but its retained network boundary is unavailable. Process tools are blocked: ' + + (profile.networkless_reason || 'capability probe failed'); + } else if (!profile.brokered) { + statusEl.textContent = 'Full Access is available for networkless work; brokered process Internet is unavailable: ' + + (profile.brokered_reason || 'broker capability probe failed'); + } else { + statusEl.textContent = 'Full Access enabled temporarily. Filesystem authority is expanded; process Internet remains brokered.'; + } + statusEl.style.color = 'var(--red)'; + return; + } + + if (profile.networkless && profile.brokered) { + statusEl.textContent = 'Sandbox and brokered Internet capabilities verified.'; + statusEl.style.color = 'var(--green,#50fa7b)'; + } else if (profile.networkless) { + statusEl.textContent = 'Sandbox verified, but brokered process Internet is unavailable: ' + + (profile.brokered_reason || 'broker capability probe failed'); + statusEl.style.color = 'var(--red)'; + } else { + statusEl.textContent = 'Sandbox unavailable. Process tools are blocked: ' + + (profile.networkless_reason || 'capability probe failed'); + statusEl.style.color = 'var(--red)'; + } + } + + async function load() { + const response = await fetch('/api/auth/process-execution', { + credentials: 'same-origin', + }); + if (!response.ok) throw new Error('Could not load process execution mode'); + render(await response.json()); + } + + async function save(mode, confirmation = '') { + const response = await fetch('/api/auth/process-execution', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode, confirmation }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.detail || 'Could not change process execution mode'); + render(data); + } + + try { + await load(); + } catch (error) { + toggle.disabled = true; + statusEl.textContent = error.message; + statusEl.style.color = 'var(--red)'; + return; + } + + toggle.addEventListener('change', async () => { + const requestedFullAccess = toggle.checked; + toggle.disabled = true; + msgEl.textContent = ''; + try { + if (requestedFullAccess) { + toggle.checked = false; + const fullCapability = current?.capability?.full_access || {}; + if (!fullCapability.networkless) { + render(current); + msgEl.textContent = 'Full Access cannot retain the required private-network boundary on this host: ' + + (fullCapability.networkless_reason || 'capability probe failed'); + msgEl.style.color = 'var(--red)'; + return; + } + const warning = current?.full_access_warning || + 'Full Access expands filesystem authority while retaining the process network boundary.'; + const approved = await (uiModule?.styledConfirm + ? uiModule.styledConfirm( + warning + '\n\nContinue to the typed confirmation?', + { confirmText: 'Continue', cancelText: 'Cancel' } + ) + : Promise.resolve(window.confirm(warning))); + if (!approved) { + render(current); + return; + } + const phrase = current?.confirmation_phrase || 'ENABLE FULL ACCESS'; + const typed = window.prompt( + 'Type ' + phrase + ' exactly to enable Full Access until Odysseus restarts.' + ); + if (typed !== phrase) { + render(current); + msgEl.textContent = 'Full Access was not enabled.'; + msgEl.style.color = 'var(--red)'; + return; + } + await save('full_access', typed); + msgEl.textContent = 'Full Access enabled until restart.'; + msgEl.style.color = 'var(--red)'; + } else { + await save('sandbox'); + const sandbox = current?.capability?.sandbox || {}; + msgEl.textContent = sandbox.networkless + ? 'Sandbox mode enabled.' + : 'Sandbox mode enabled; process tools remain blocked.'; + msgEl.style.color = sandbox.networkless + ? 'var(--green,#50fa7b)' + : 'var(--red)'; + } + } catch (error) { + render(current); + msgEl.textContent = error.message; + msgEl.style.color = 'var(--red)'; + } finally { + toggle.disabled = false; + } + }); +} + /* ═══════════════════════════════════════════ APPEARANCE TAB ═══════════════════════════════════════════ */ @@ -1949,6 +2114,25 @@ async function initShortcuts() { render(); } +// Full Access is transient server state, not browser state. Read it once on +// page load so a reload cannot hide the high-authority indicator. Non-admins +// receive 403 and simply do not render the banner. +(function loadProcessExecutionIndicator() { + const run = async () => { + try { + const response = await fetch('/api/auth/process-execution', { + credentials: 'same-origin', + }); + if (response.ok) syncProcessExecutionBanner(await response.json()); + } catch (_) {} + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run, { once: true }); + } else { + run(); + } +})(); + /* ═══════════════════════════════════════════ INIT & REFRESH ═══════════════════════════════════════════ */ @@ -2186,6 +2370,7 @@ function initAll() { initResearchSettings(); initResearchSearchSettings(); initAgentSettings(); + initProcessExecutionSettings(); initAppearance(); initShortcuts(); initAccount(); diff --git a/tests/seccomp_probe.c b/tests/seccomp_probe.c new file mode 100644 index 000000000..053efa698 --- /dev/null +++ b/tests/seccomp_probe.c @@ -0,0 +1,184 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int expect_errno(long result, int expected) +{ + if (result == -1 && errno == expected) { + return 0; + } + (void)fprintf( + stderr, + "unexpected syscall result=%ld errno=%d expected=%d\n", + result, + errno, + expected + ); + return 1; +} + +static int expect_socket_denied(int family) +{ + errno = 0; + int descriptor = socket(family, SOCK_RAW, 0); + if (descriptor >= 0) { + (void)close(descriptor); + return 1; + } + return expect_errno(descriptor, EPERM); +} + +int main(int argc, char **argv) +{ + if (argc != 2) { + return 64; + } + const char *probe = argv[1]; + errno = 0; + +#ifdef SYS_bpf + if (strcmp(probe, "bpf") == 0) { + return expect_errno(syscall(SYS_bpf, 0, NULL, 0), EPERM); + } +#endif +#ifdef SYS_perf_event_open + if (strcmp(probe, "perf_event_open") == 0) { + return expect_errno(syscall(SYS_perf_event_open, NULL, 0, -1, -1, 0), EPERM); + } +#endif +#ifdef SYS_clone + if (strcmp(probe, "clone_namespace") == 0) { + return expect_errno( + syscall(SYS_clone, (unsigned long)CLONE_NEWNS | SIGCHLD, NULL, NULL, NULL, 0), + EPERM + ); + } +#endif +#ifdef SYS_clone3 + if (strcmp(probe, "clone3") == 0) { + return expect_errno(syscall(SYS_clone3, NULL, 0), ENOSYS); + } +#endif +#ifdef SYS_unshare + if (strcmp(probe, "unshare") == 0) { + return expect_errno(syscall(SYS_unshare, CLONE_NEWNS), EPERM); + } +#endif +#ifdef SYS_setns + if (strcmp(probe, "setns") == 0) { + return expect_errno(syscall(SYS_setns, -1, CLONE_NEWNS), EPERM); + } +#endif +#ifdef SYS_mount + if (strcmp(probe, "mount") == 0) { + return expect_errno(syscall(SYS_mount, NULL, "/", NULL, 0, NULL), EPERM); + } +#endif +#ifdef SYS_umount2 + if (strcmp(probe, "umount2") == 0) { + return expect_errno(syscall(SYS_umount2, "/", 0), EPERM); + } +#endif +#ifdef SYS_pivot_root + if (strcmp(probe, "pivot_root") == 0) { + return expect_errno(syscall(SYS_pivot_root, "/", "/"), EPERM); + } +#endif +#ifdef SYS_ptrace + if (strcmp(probe, "ptrace") == 0) { + return expect_errno(syscall(SYS_ptrace, PTRACE_PEEKDATA, getpid(), NULL, NULL), EPERM); + } +#endif +#ifdef SYS_process_vm_readv + if (strcmp(probe, "process_vm_readv") == 0) { + return expect_errno( + syscall(SYS_process_vm_readv, getpid(), NULL, 0, NULL, 0, 0), + EPERM + ); + } +#endif +#ifdef SYS_process_vm_writev + if (strcmp(probe, "process_vm_writev") == 0) { + return expect_errno( + syscall(SYS_process_vm_writev, getpid(), NULL, 0, NULL, 0, 0), + EPERM + ); + } +#endif +#ifdef SYS_keyctl + if (strcmp(probe, "keyctl") == 0) { + return expect_errno(syscall(SYS_keyctl, 0, 0, 0, 0, 0), EPERM); + } +#endif +#ifdef SYS_open_by_handle_at + if (strcmp(probe, "open_by_handle_at") == 0) { + return expect_errno(syscall(SYS_open_by_handle_at, -1, NULL, 0), EPERM); + } +#endif + if (strcmp(probe, "af_packet") == 0) { + return expect_socket_denied(AF_PACKET); + } +#ifdef AF_ALG + if (strcmp(probe, "af_alg") == 0) { + return expect_socket_denied(AF_ALG); + } +#endif +#ifdef AF_VSOCK + if (strcmp(probe, "af_vsock") == 0) { + return expect_socket_denied(AF_VSOCK); + } +#endif +#ifdef TIOCSTI + if (strcmp(probe, "tiocsti") == 0) { + return expect_errno(ioctl(STDIN_FILENO, TIOCSTI, "x"), EACCES); + } + if (strcmp(probe, "tiocsti_high_bits") == 0) { + const unsigned long request = (1UL << 32U) | (unsigned long)TIOCSTI; + return expect_errno( + syscall(SYS_ioctl, STDIN_FILENO, request, "x"), + EACCES + ); + } +#endif +#ifdef SYS_userfaultfd + if (strcmp(probe, "userfaultfd") == 0) { + return expect_errno(syscall(SYS_userfaultfd, 0), EPERM); + } +#endif +#ifdef SYS_io_uring_setup + if (strcmp(probe, "io_uring_setup") == 0) { + return expect_errno(syscall(SYS_io_uring_setup, 1, NULL), EPERM); + } +#endif + if (strcmp(probe, "fork") == 0) { + pid_t child = fork(); + if (child < 0) { + return 1; + } + if (child == 0) { + _exit(0); + } + int status = 0; + return waitpid(child, &status, 0) == child && WIFEXITED(status) + && WEXITSTATUS(status) == 0 + ? 0 + : 1; + } + return 77; +} diff --git a/tests/test_agent_bash_windows.py b/tests/test_agent_bash_windows.py index 888c906b3..8de55fef3 100644 --- a/tests/test_agent_bash_windows.py +++ b/tests/test_agent_bash_windows.py @@ -53,7 +53,7 @@ async def fail_spawn(*_args, **_kwargs): @pytest.mark.asyncio -async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypatch): +async def test_bash_tool_fails_closed_without_linux_sandbox(monkeypatch): monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True) monkeypatch.setattr(subprocess_tools, "find_bash", lambda: None) @@ -63,12 +63,12 @@ async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypat ) assert result["exit_code"] == 1 - assert "install Git for Windows" in result["error"] + assert result["blocked"] is True + assert "requires Linux with bubblewrap" in result["error"] @pytest.mark.asyncio async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch): - captured = {} workspace = r"D:\Workspaces\Project with spaces" monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True) @@ -82,26 +82,49 @@ async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch): async def fail_tmux(*_args, **_kwargs): pytest.fail("native Windows must not enter the POSIX tmux path") - async def fake_create(command, **kwargs): - captured["command"] = command - captured["kwargs"] = kwargs - return object() - - async def fake_stream(_process, **_kwargs): - return "ok", "", 0, False + async def fail_create(*_args, **_kwargs): + pytest.fail("Windows execution must fail closed before process creation") monkeypatch.setattr(subprocess_tools, "_run_tmux_bash", fail_tmux) - monkeypatch.setattr(subprocess_tools, "_create_bash_subprocess", fake_create) - monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream) + monkeypatch.setattr(subprocess_tools, "_create_bash_subprocess", fail_create) result = await subprocess_tools.BashTool().execute( "pwd", {"subproc_env": {}, "session_id": "chat-1"}, ) - assert result == {"output": "ok", "exit_code": 0} - assert captured["command"] == "pwd" - assert captured["kwargs"]["cwd"] == workspace + assert result["blocked"] is True + assert "requires Linux with bubblewrap" in result["error"] + + +@pytest.mark.asyncio +async def test_windows_python_fails_closed_without_linux_sandbox(monkeypatch): + monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True) + + async def fail_spawn(*_args, **_kwargs): + pytest.fail("Windows execution must fail closed before process creation") + + monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fail_spawn) + + result = await subprocess_tools.PythonTool().execute("print('no')", {}) + + assert result["blocked"] is True + assert "requires Linux with bubblewrap" in result["error"] + + +def test_windows_background_job_fails_closed_before_writing_files( + tmp_path, + monkeypatch, +): + from src import bg_jobs + + monkeypatch.setattr(bg_jobs, "IS_WINDOWS", True) + monkeypatch.setattr(bg_jobs, "_JOBS_DIR", tmp_path / "jobs") + + with pytest.raises(RuntimeError, match="requires Linux with bubblewrap"): + bg_jobs.launch("echo no", session_id="chat-1", cwd=str(tmp_path)) + + assert not (tmp_path / "jobs").exists() @pytest.mark.asyncio diff --git a/tests/test_app_db_permissions.py b/tests/test_app_db_permissions.py index d6fad8d7e..a3b5d5c48 100644 --- a/tests/test_app_db_permissions.py +++ b/tests/test_app_db_permissions.py @@ -167,6 +167,33 @@ def test_sqlite_db_path_handles_file_uri_forms(tmp_path): ) +def test_shared_sqlite_resolver_canonicalizes_absent_paths_without_core_import( + tmp_path, +): + from src.sqlite_paths import normalize_sqlite_url, resolve_sqlite_db_path + + app_root = tmp_path / "app" + app_root.mkdir() + expected = str(app_root / "relative.db") + + assert normalize_sqlite_url( + "sqlite+pysqlite:///relative.db", + app_root=str(app_root), + ) == f"sqlite+pysqlite:///{expected}" + assert resolve_sqlite_db_path( + "sqlite+pysqlite:///relative.db", + app_root=str(app_root), + ) == expected + assert resolve_sqlite_db_path( + f"sqlite+pysqlite:///file://localhost{expected}?uri=true", + app_root=str(app_root), + ) == expected + assert resolve_sqlite_db_path( + "sqlite+pysqlite:///file:memdb1?mode=memory&uri=true", + app_root=str(app_root), + ) is None + + @pytest.mark.skipif( sys.platform == "win32", reason="POSIX mode bits (0o600) don't exist on Windows; safe_chmod no-ops there.", diff --git a/tests/test_bg_job_launch_cleanup.py b/tests/test_bg_job_launch_cleanup.py new file mode 100644 index 000000000..ef7a5b94d --- /dev/null +++ b/tests/test_bg_job_launch_cleanup.py @@ -0,0 +1,249 @@ +"""Transactional cleanup coverage for detached sandbox launches.""" + +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from src import bg_jobs + + +class _FakeProcess: + pid = 5818 + + def __init__(self): + self.wait_calls = [] + + def wait(self, timeout=None): + self.wait_calls.append(timeout) + return 0 + + +@pytest.fixture +def launch_context(tmp_path, monkeypatch): + jobs_dir = tmp_path / "jobs" + store = tmp_path / "jobs.json" + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr(bg_jobs, "_JOBS_DIR", jobs_dir) + monkeypatch.setattr(bg_jobs, "_STORE", store) + monkeypatch.setattr(bg_jobs, "sandbox_command", lambda *args, **kwargs: ["/trusted/sandbox"]) + monkeypatch.setattr(bg_jobs, "environment_for_sandbox_launcher", lambda: {}) + monkeypatch.setattr( + bg_jobs, + "configured_process_execution_mode", + lambda: bg_jobs.ProcessExecutionMode.SANDBOX, + ) + class AvailableProfile: + def supports(self, _profile): + return True + + def reason_for(self, _profile): + return "" + + class AvailableCapability: + def for_mode(self, _mode): + return AvailableProfile() + + monkeypatch.setattr( + bg_jobs, + "process_capability", + lambda: AvailableCapability(), + ) + monkeypatch.setattr(bg_jobs, "detached_popen_kwargs", lambda: {}) + return jobs_dir, store, workspace + + +def _job_artifacts(jobs_dir: Path): + return sorted(path for path in jobs_dir.glob("*.*") if path.is_file()) if jobs_dir.exists() else [] + + +def test_sandbox_failure_after_command_file_creation_cleans_artifacts( + launch_context, + monkeypatch, +): + jobs_dir, store, workspace = launch_context + monkeypatch.setattr( + bg_jobs, + "sandbox_command", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("sandbox denied")), + ) + + with pytest.raises(RuntimeError, match="sandbox denied"): + bg_jobs.launch("printf secret", session_id="chat-1", cwd=str(workspace)) + + assert _job_artifacts(jobs_dir) == [] + assert not store.exists() + + +def test_popen_failure_cleans_artifacts(launch_context, monkeypatch): + jobs_dir, store, workspace = launch_context + + def fail_popen(*args, **kwargs): + raise OSError("popen denied") + + monkeypatch.setattr(bg_jobs.subprocess, "Popen", fail_popen) + + with pytest.raises(OSError, match="popen denied"): + bg_jobs.launch("printf secret", session_id="chat-1", cwd=str(workspace)) + + assert _job_artifacts(jobs_dir) == [] + assert not store.exists() + + +def test_save_failure_kills_untracked_process_and_cleans_everything( + launch_context, + monkeypatch, +): + jobs_dir, store, workspace = launch_context + process = _FakeProcess() + killed = [] + monkeypatch.setattr(bg_jobs.subprocess, "Popen", lambda *args, **kwargs: process) + monkeypatch.setattr(bg_jobs, "_kill", lambda pid: killed.append(pid)) + + def save_then_fail(jobs): + store.write_text(json.dumps(jobs), encoding="utf-8") + raise OSError("store unavailable") + + monkeypatch.setattr(bg_jobs, "_save", save_then_fail) + + with pytest.raises(OSError, match="store unavailable"): + bg_jobs.launch("printf secret", session_id="chat-1", cwd=str(workspace)) + + assert killed == [process.pid] + assert process.wait_calls == [2] + assert bg_jobs._load() == {} + assert _job_artifacts(jobs_dir) == [] + + +def test_repeated_blocked_launches_do_not_accumulate_artifacts( + launch_context, + monkeypatch, +): + jobs_dir, store, workspace = launch_context + monkeypatch.setattr( + bg_jobs, + "sandbox_command", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("blocked")), + ) + + for _ in range(5): + with pytest.raises(RuntimeError, match="blocked"): + bg_jobs.launch("printf blocked", session_id="chat-1", cwd=str(workspace)) + + assert _job_artifacts(jobs_dir) == [] + assert not store.exists() + + +def test_successful_launch_persists_record_and_private_command_file( + launch_context, + monkeypatch, +): + jobs_dir, store, workspace = launch_context + process = _FakeProcess() + monkeypatch.setattr(bg_jobs.subprocess, "Popen", lambda *args, **kwargs: process) + + record = bg_jobs.launch("printf success", session_id="chat-1", cwd=str(workspace)) + + command_path = Path(record["log_path"]).with_name(f"{record['id']}.cmd.sh") + plan_path = Path(record["log_path"]).with_name(f"{record['id']}.plan.json") + assert record["status"] == "running" + assert bg_jobs._load()[record["id"]]["pid"] == process.pid + assert store.exists() + assert command_path.read_text(encoding="utf-8") == "printf success\n" + assert command_path.stat().st_mode & 0o777 == 0o600 + assert plan_path.stat().st_mode & 0o777 == 0o600 + assert json.loads(plan_path.read_text(encoding="utf-8"))["argv"] == ["/trusted/sandbox"] + assert jobs_dir.stat().st_mode & 0o777 == 0o700 + assert command_path in _job_artifacts(jobs_dir) + assert plan_path in _job_artifacts(jobs_dir) + + +def test_command_file_creation_is_exclusive(launch_context, monkeypatch): + jobs_dir, store, workspace = launch_context + process = _FakeProcess() + monkeypatch.setattr(bg_jobs.uuid, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()) + jobs_dir.mkdir(parents=True) + existing = jobs_dir / ("a" * 12 + ".cmd.sh") + existing.write_text("existing\n", encoding="utf-8") + + with pytest.raises(FileExistsError): + bg_jobs.launch("printf replacement", session_id="chat-1", cwd=str(workspace)) + + assert existing.read_text(encoding="utf-8") == "existing\n" + assert not store.exists() + + +def test_detached_wrapper_writes_success_exit_artifact(tmp_path): + log_path = tmp_path / "job.log" + exit_path = tmp_path / "job.exit" + plan_path = tmp_path / "job.plan.json" + plan_bytes = json.dumps( + { + "version": 1, + "argv": [sys.executable, "-I", "-c", "print('ok')"], + }, + separators=(",", ":"), + ).encode("utf-8") + plan_path.write_bytes(plan_bytes) + completed = subprocess.run( + [ + sys.executable, + "-I", + "-c", + bg_jobs._DETACHED_SANDBOX_WRAPPER, + str(plan_path), + hashlib.sha256(plan_bytes).hexdigest(), + str(log_path), + str(exit_path), + ], + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0 + assert log_path.read_text(encoding="utf-8").strip() == "ok" + assert exit_path.read_text(encoding="utf-8") == "0" + assert log_path.stat().st_mode & 0o777 == 0o600 + assert exit_path.stat().st_mode & 0o777 == 0o600 + + +def test_full_access_launch_uses_full_filesystem_profile_and_retained_network( + launch_context, + monkeypatch, +): + jobs_dir, _store, workspace = launch_context + process = _FakeProcess() + monkeypatch.setattr( + bg_jobs, + "configured_process_execution_mode", + lambda: bg_jobs.ProcessExecutionMode.FULL_ACCESS, + ) + monkeypatch.setattr( + bg_jobs, + "full_access_command", + lambda argv, **_kwargs: ["/trusted/bwrap-full", *argv], + ) + monkeypatch.setattr(bg_jobs.subprocess, "Popen", lambda *args, **kwargs: process) + + record = bg_jobs.launch( + "printf success", + session_id="chat-1", + cwd=str(workspace), + network_profile=bg_jobs.SandboxNetworkProfile.BROKERED_ONLY, + ) + + plan_path = jobs_dir / f"{record['id']}.plan.json" + plan = json.loads(plan_path.read_text(encoding="utf-8")) + assert record["execution_mode"] == "full_access" + assert record["network_enforcement"] == "brokered_http_https" + assert record["warning"] == bg_jobs.FULL_ACCESS_WARNING + assert plan["argv"][:2] == ["/trusted/bwrap-full", "/bin/bash"] + assert "Execution mode: full_access" in bg_jobs.result_text( + {**record, "status": "done", "exit_code": 0} + ) diff --git a/tests/test_egress_broker.py b/tests/test_egress_broker.py new file mode 100644 index 000000000..17b6ab339 --- /dev/null +++ b/tests/test_egress_broker.py @@ -0,0 +1,920 @@ +"""Deterministic coverage for the public-only sandbox HTTP(S) broker.""" + +from __future__ import annotations + +import datetime +import os +import shutil +import socket +import ssl +import subprocess +import sys +import threading +import time +from pathlib import Path + +import pytest + +from security.egress import odysseus_egress_bridge as bridge +from security.egress import odysseus_egress_broker as broker + + +PUBLIC_V4 = "93.184.216.34" +PUBLIC_V6 = "2606:2800:220:1:248:1893:25c8:1946" + + +def _resolver(*addresses: str): + def resolve(_host, port, **_kwargs): + answers = [] + for address in addresses: + if ":" in address: + answers.append( + ( + socket.AF_INET6, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + (address, port, 0, 0), + ) + ) + else: + answers.append( + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + (address, port), + ) + ) + return answers + + return resolve + + +class _PublicPeerSocket: + """Delegate I/O to a socketpair while reporting a validated public peer.""" + + def __init__(self, stream: socket.socket, peer=(PUBLIC_V4, 443)): + self.stream = stream + self.peer = peer + + def getpeername(self): + return self.peer + + def __getattr__(self, name): + return getattr(self.stream, name) + + +def _read_all(stream: socket.socket) -> bytes: + chunks = [] + while True: + data = stream.recv(65536) + if not data: + return b"".join(chunks) + chunks.append(data) + + +class _Transport: + def __init__( + self, + tmp_path, + connector, + *, + resolver=None, + max_connections=broker.MAX_CONNECTIONS, + ): + socket_path = tmp_path / "transport.sock" + self.listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.listener.bind(str(socket_path)) + self.listener.listen(max_connections) + self.server = broker.BrokerServer( + self.listener, + resolver=resolver or _resolver(PUBLIC_V4), + connector=connector, + max_connections=max_connections, + ) + self.server.start() + self.bridge = bridge.LoopbackBridge(str(socket_path), port=0) + self.bridge.start() + + @property + def proxy_url(self): + host, port = self.bridge.address + return f"http://{host}:{port}" + + def close(self): + self.bridge.close() + self.server.close() + + +@pytest.mark.parametrize( + "address", + [ + "127.0.0.1", + "10.0.0.1", + "172.16.0.1", + "192.168.1.1", + "100.64.0.1", + "169.254.169.254", + "192.0.2.1", + "198.18.0.1", + "224.0.0.1", + "::1", + "fe80::1", + "fc00::1", + "ff02::1", + "::ffff:169.254.169.254", + "64:ff9b::a9fe:a9fe", + "64:ff9b::a00:1", + ], +) +def test_non_public_destinations_are_rejected(address): + with pytest.raises(broker.BrokerError, match="non-public"): + broker.resolve_public_targets( + "blocked.example", + 443, + resolver=_resolver(address), + ) + + +def test_public_ipv4_and_ipv6_destinations_are_allowed(): + targets = broker.resolve_public_targets( + "public.example", + 443, + resolver=_resolver(PUBLIC_V4, PUBLIC_V6), + ) + + assert [target.sockaddr[0] for target in targets] == [PUBLIC_V4, PUBLIC_V6] + + +def test_nat64_allows_only_an_embedded_public_ipv4_destination(): + target = "64:ff9b::808:808" + + targets = broker.resolve_public_targets( + "public-via-nat64.example", + 443, + resolver=_resolver(target), + ) + + assert [item.sockaddr[0] for item in targets] == [target] + + +def test_mixed_public_private_dns_answer_fails_closed(): + with pytest.raises(broker.BrokerError, match="non-public"): + broker.resolve_public_targets( + "rebind.example", + 443, + resolver=_resolver(PUBLIC_V4, "127.0.0.1"), + ) + + +def test_excessive_dns_answer_set_fails_closed(): + addresses = [f"8.8.8.{index}" for index in range(1, 18)] + + with pytest.raises(broker.BrokerError, match="too many"): + broker.resolve_public_targets( + "wide.example", + 443, + resolver=_resolver(*addresses), + ) + + +def test_connected_peer_is_revalidated(): + local, remote = socket.socketpair() + + def connector(_family, _sockaddr, _timeout): + return _PublicPeerSocket(local, peer=("127.0.0.1", 443)) + + try: + with pytest.raises(broker.BrokerError, match="connection failed"): + broker.connect_public_target( + "public.example", + 443, + resolver=_resolver(PUBLIC_V4), + connector=connector, + ) + finally: + remote.close() + + +def test_connected_peer_must_match_the_resolved_target(): + local, remote = socket.socketpair() + + def connector(_family, _sockaddr, _timeout): + return _PublicPeerSocket(local, peer=("1.1.1.1", 443)) + + try: + with pytest.raises(broker.BrokerError, match="connection failed"): + broker.connect_public_target( + "public.example", + 443, + resolver=_resolver(PUBLIC_V4), + connector=connector, + ) + finally: + local.close() + remote.close() + + +def test_https_connect_reaches_only_validated_public_target(): + client, broker_side = socket.socketpair() + outbound, origin = socket.socketpair() + resolver_calls = [] + + def resolver(host, port, **kwargs): + resolver_calls.append((host, port, kwargs)) + return _resolver(PUBLIC_V4)(host, port, **kwargs) + + def connector(_family, sockaddr, _timeout): + assert sockaddr == (PUBLIC_V4, 443) + return _PublicPeerSocket(outbound) + + worker = threading.Thread( + target=broker.serve_proxy_client, + args=(broker_side,), + kwargs={"resolver": resolver, "connector": connector}, + ) + worker.start() + client.settimeout(2) + client.sendall( + b"CONNECT public.example:443 HTTP/1.1\r\n" + b"Host: public.example:443\r\n\r\n" + ) + assert client.recv(4096) == b"HTTP/1.1 200 Connection Established\r\n\r\n" + client.sendall(b"encrypted request") + assert origin.recv(4096) == b"encrypted request" + origin.sendall(b"encrypted response") + origin.shutdown(socket.SHUT_WR) + assert client.recv(4096) == b"encrypted response" + client.close() + origin.close() + worker.join(timeout=2) + + assert not worker.is_alive() + assert len(resolver_calls) == 1 + + +def test_plain_http_is_rewritten_and_proxy_credentials_are_stripped(): + client, broker_side = socket.socketpair() + outbound, origin = socket.socketpair() + received = [] + + def connector(_family, sockaddr, _timeout): + assert sockaddr == (PUBLIC_V4, 80) + return _PublicPeerSocket(outbound, peer=(PUBLIC_V4, 80)) + + def origin_server(): + received.append(origin.recv(65536)) + origin.sendall( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok" + ) + origin.shutdown(socket.SHUT_WR) + + upstream = threading.Thread(target=origin_server) + worker = threading.Thread( + target=broker.serve_proxy_client, + args=(broker_side,), + kwargs={ + "resolver": _resolver(PUBLIC_V4), + "connector": connector, + }, + ) + upstream.start() + worker.start() + client.sendall( + b"GET http://public.example/package.tgz?x=1 HTTP/1.1\r\n" + b"Host: public.example\r\n" + b"Proxy-Authorization: Basic must-not-forward\r\n" + b"Connection: keep-alive\r\n\r\n" + ) + client.shutdown(socket.SHUT_WR) + response = _read_all(client) + client.close() + worker.join(timeout=2) + upstream.join(timeout=2) + origin.close() + + assert response.endswith(b"\r\n\r\nok") + request = received[0] + assert request.startswith(b"GET /package.tgz?x=1 HTTP/1.1\r\n") + assert b"Host: public.example:80\r\n" in request + assert b"Proxy-Authorization" not in request + assert b"Connection: close\r\n" in request + + +def test_connection_header_cannot_remove_content_length_framing(): + client, broker_side = socket.socketpair() + outbound, origin = socket.socketpair() + connector_calls = [] + + def connector(_family, _sockaddr, _timeout): + connector_calls.append(True) + return _PublicPeerSocket(outbound, peer=(PUBLIC_V4, 80)) + + worker = threading.Thread( + target=broker.serve_proxy_client, + args=(broker_side,), + kwargs={ + "resolver": _resolver(PUBLIC_V4), + "connector": connector, + }, + ) + worker.start() + client.settimeout(2) + client.sendall( + b"POST http://public.example/upload HTTP/1.1\r\n" + b"Host: public.example\r\n" + b"Content-Length: 4\r\n" + b"Connection: content-length\r\n\r\nbody" + ) + client.shutdown(socket.SHUT_WR) + response = client.recv(4096) + client.close() + origin.close() + worker.join(timeout=2) + + assert b" 400 " in response + assert connector_calls == [] + assert not worker.is_alive() + + +@pytest.mark.parametrize( + "raw_request", + [ + b"CONNECT public.example:80 HTTP/1.1\r\nHost: public.example\r\n\r\n", + b"CONNECT public.example:22 HTTP/1.1\r\nHost: public.example\r\n\r\n", + b"GET http://public.example:8080/ HTTP/1.1\r\nHost: public.example:8080\r\n\r\n", + b"GET https://public.example/ HTTP/1.1\r\nHost: public.example\r\n\r\n", + ], +) +def test_only_http_80_and_https_connect_443_are_allowed(raw_request): + client, broker_side = socket.socketpair() + worker = threading.Thread( + target=broker.serve_proxy_client, + args=(broker_side,), + kwargs={"resolver": lambda *_args, **_kwargs: pytest.fail("DNS must not run")}, + ) + worker.start() + client.sendall(raw_request) + client.shutdown(socket.SHUT_WR) + response = _read_all(client) + client.close() + worker.join(timeout=2) + + assert b" 403 " in response or b" 400 " in response + + +def test_each_new_connection_resolves_again(): + calls = [] + + def resolver(host, port, **kwargs): + calls.append((host, port)) + return _resolver(PUBLIC_V4)(host, port, **kwargs) + + for _index in range(2): + client, broker_side = socket.socketpair() + outbound, origin = socket.socketpair() + + def connector(_family, _sockaddr, _timeout, stream=outbound): + return _PublicPeerSocket(stream) + + worker = threading.Thread( + target=broker.serve_proxy_client, + args=(broker_side,), + kwargs={"resolver": resolver, "connector": connector}, + ) + worker.start() + client.sendall(b"CONNECT public.example:443 HTTP/1.1\r\n\r\n") + assert b" 200 " in client.recv(4096) + client.close() + origin.close() + worker.join(timeout=2) + + assert calls == [("public.example", 443), ("public.example", 443)] + + +def test_broker_wrapper_injects_one_read_only_runtime_mount(): + arguments = [ + broker.TRUSTED_LAUNCHER, + broker.TRUSTED_BWRAP, + "--unshare-net", + "--clearenv", + "--", + "/bin/true", + ] + + child = broker.build_child_argv(arguments, "/tmp/trusted-runtime") + + assert child[:4] == [ + broker.TRUSTED_LAUNCHER, + broker.TRUSTED_BWRAP, + "--unshare-net", + "--clearenv", + ] + assert child.count("--unshare-net") == 1 + assert "--share-net" not in child + separator = child.index("--") + assert child[separator - 5:separator] == [ + "--dir", + "/run", + "--ro-bind", + "/tmp/trusted-runtime", + broker.SANDBOX_RUNTIME_DIR, + ] + + +@pytest.mark.parametrize( + "arguments", + [ + [broker.TRUSTED_LAUNCHER, broker.TRUSTED_BWRAP, "--share-net", "--", "/bin/true"], + [broker.TRUSTED_LAUNCHER, broker.TRUSTED_BWRAP, "--", "/bin/true"], + ["/bin/true", broker.TRUSTED_BWRAP, "--unshare-net", "--", "/bin/true"], + ], +) +def test_broker_wrapper_rejects_raw_or_invalid_launch_chains(arguments): + with pytest.raises(broker.BrokerError): + broker.build_child_argv(arguments, "/tmp/trusted-runtime") + + +def test_broker_failure_does_not_print_commands_or_environment(): + secret = "broker-must-not-log-this-secret" + completed = subprocess.run( + [ + sys.executable, + "-I", + str(Path(broker.__file__)), + "/bin/false", + broker.TRUSTED_BWRAP, + "--unshare-net", + "--", + "/bin/echo", + secret, + ], + env={"API_TOKEN": secret}, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + + assert completed.returncode == 64 + assert secret not in completed.stdout + assert secret not in completed.stderr + + +def test_bridge_refuses_to_launch_without_the_mounted_broker(tmp_path): + missing = tmp_path / "missing.sock" + proxy = bridge.LoopbackBridge(str(missing), port=0) + + with pytest.raises(bridge.BridgeError, match="unavailable"): + proxy.start() + + proxy.close() + + +def test_loopback_bridge_forwards_to_the_mounted_unix_socket(tmp_path): + socket_path = tmp_path / "broker.sock" + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(str(socket_path)) + listener.listen(4) + stop = threading.Event() + + def echo_server(): + while not stop.is_set(): + connection, _address = listener.accept() + data = connection.recv(4096) + if data: + connection.sendall(data.upper()) + connection.close() + if data: + return + + echo = threading.Thread(target=echo_server) + echo.start() + proxy = bridge.LoopbackBridge(str(socket_path), port=0) + proxy.start() + client = socket.create_connection(proxy.address, timeout=2) + client.sendall(b"brokered") + client.shutdown(socket.SHUT_WR) + assert client.recv(4096) == b"BROKERED" + client.close() + proxy.close() + stop.set() + listener.close() + echo.join(timeout=2) + + +def test_loopback_bridge_releases_a_slot_when_the_broker_closes_first(tmp_path): + socket_path = tmp_path / "broker.sock" + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(str(socket_path)) + listener.listen(2) + + def close_connections(): + for _index in range(2): + connection, _address = listener.accept() + connection.close() + + closer = threading.Thread(target=close_connections) + closer.start() + proxy = bridge.LoopbackBridge(str(socket_path), port=0, max_connections=1) + proxy.start() + client = socket.create_connection(proxy.address, timeout=2) + + deadline = time.monotonic() + 2 + connection_thread = None + while connection_thread is None and time.monotonic() < deadline: + with proxy.connections_lock: + if proxy.connections: + connection_thread = proxy.connections[0] + if connection_thread is None: + time.sleep(0.01) + assert connection_thread is not None + connection_thread.join(timeout=1) + released_before_client_close = not connection_thread.is_alive() + + client.close() + proxy.close() + listener.close() + closer.join(timeout=2) + + assert released_before_client_close + assert not closer.is_alive() + + +def test_loopback_bridge_closes_when_its_connection_bound_is_full(tmp_path): + socket_path = tmp_path / "broker.sock" + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(str(socket_path)) + listener.listen(1) + accepted = [] + + def hold_connection(): + probe, _address = listener.accept() + probe.close() + connection, _address = listener.accept() + accepted.append(connection) + connection.recv(1) + + holder = threading.Thread(target=hold_connection) + holder.start() + proxy = bridge.LoopbackBridge(str(socket_path), port=0, max_connections=1) + proxy.start() + first = socket.create_connection(proxy.address, timeout=2) + first.sendall(b"x") + + deadline = time.monotonic() + 2 + while not accepted and time.monotonic() < deadline: + time.sleep(0.01) + assert accepted + rejected = socket.create_connection(proxy.address, timeout=2) + rejected.sendall(b"GET http://public.example/ HTTP/1.1\r\n\r\n") + try: + response = rejected.recv(4096) + except ConnectionResetError: + response = b"" + + rejected.close() + first.close() + accepted[0].close() + proxy.close() + listener.close() + holder.join(timeout=2) + + assert response == b"" + assert not holder.is_alive() + + +def test_broker_and_bridge_limits_are_explicit_and_consistent(): + assert broker.MAX_CONNECTIONS == bridge.MAX_CONNECTIONS == 16 + assert broker.MAX_HEADER_BYTES == 64 * 1024 + assert broker.MAX_HTTP_BODY_BYTES == 16 * 1024 * 1024 + assert broker.MAX_RESOLVED_TARGETS == 16 + assert broker.IDLE_TIMEOUT_SECONDS == bridge.IDLE_TIMEOUT_SECONDS > 0 + assert ( + broker.CONNECTION_LIFETIME_SECONDS + == bridge.CONNECTION_LIFETIME_SECONDS + < broker.BROKER_LIFETIME_SECONDS + ) + + +def test_multiple_simultaneous_connect_tunnels_work_within_bounds(tmp_path): + origins = [] + origin_threads = [] + + def connector(_family, _sockaddr, _timeout): + outbound, origin = socket.socketpair() + origins.append(origin) + + def echo(): + data = origin.recv(4096) + origin.sendall(data.upper()) + origin.shutdown(socket.SHUT_WR) + + thread = threading.Thread(target=echo) + thread.start() + origin_threads.append(thread) + return _PublicPeerSocket(outbound) + + transport = _Transport(tmp_path, connector, max_connections=4) + results = [] + + def request(payload): + client = socket.create_connection(transport.bridge.address, timeout=2) + client.sendall(b"CONNECT public.example:443 HTTP/1.1\r\n\r\n") + assert b" 200 " in client.recv(4096) + client.sendall(payload) + client.shutdown(socket.SHUT_WR) + results.append(_read_all(client)) + client.close() + + clients = [ + threading.Thread(target=request, args=(b"first",)), + threading.Thread(target=request, args=(b"second",)), + ] + for client in clients: + client.start() + for client in clients: + client.join(timeout=3) + transport.close() + for origin in origins: + origin.close() + for thread in origin_threads: + thread.join(timeout=2) + + assert sorted(results) == [b"FIRST", b"SECOND"] + assert all(not client.is_alive() for client in clients) + + +def test_broker_rejects_connections_above_the_per_process_bound(tmp_path): + origins = [] + + def connector(_family, _sockaddr, _timeout): + outbound, origin = socket.socketpair() + origins.append(origin) + return _PublicPeerSocket(outbound) + + transport = _Transport(tmp_path, connector, max_connections=2) + clients = [] + for _index in range(2): + client = socket.create_connection(transport.bridge.address, timeout=2) + client.sendall(b"CONNECT public.example:443 HTTP/1.1\r\n\r\n") + assert b" 200 " in client.recv(4096) + clients.append(client) + + rejected = socket.create_connection(transport.bridge.address, timeout=2) + rejected.sendall(b"CONNECT public.example:443 HTTP/1.1\r\n\r\n") + try: + response = rejected.recv(4096) + except ConnectionResetError: + response = b"" + rejected.close() + for client in clients: + client.close() + for origin in origins: + origin.close() + transport.close() + + # An overloaded broker may close with unread request bytes after its + # best-effort 503, which is observed as either the response or EOF. The + # authority invariant is that no additional outbound connection is made. + assert response == b"" or b" 503 " in response + assert len(origins) == 2 + + +@pytest.mark.skipif(shutil.which("npm") is None, reason="npm is unavailable") +def test_npm_reads_a_package_document_through_standard_proxy_variables(tmp_path): + body = ( + b'{"name":"odysseus-fake","dist-tags":{"latest":"1.0.0"},' + b'"versions":{"1.0.0":{"name":"odysseus-fake","version":"1.0.0"}}}' + ) + origin_threads = [] + + def connector(_family, _sockaddr, _timeout): + outbound, origin = socket.socketpair() + + def serve(): + request = origin.recv(65536) + assert request.startswith(b"GET /odysseus-fake HTTP/1.1\r\n") + origin.sendall( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + + f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n".encode() + + body + ) + origin.shutdown(socket.SHUT_WR) + origin.close() + + thread = threading.Thread(target=serve) + thread.start() + origin_threads.append(thread) + return _PublicPeerSocket(outbound, peer=(PUBLIC_V4, 80)) + + transport = _Transport(tmp_path, connector) + proxy_environment = { + "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"), + "HOME": str(tmp_path / "home"), + "npm_config_cache": str(tmp_path / "npm-cache"), + "HTTP_PROXY": transport.proxy_url, + "HTTPS_PROXY": transport.proxy_url, + "http_proxy": transport.proxy_url, + "https_proxy": transport.proxy_url, + } + completed = subprocess.run( + [ + "npm", + "view", + "odysseus-fake", + "version", + "--registry=http://public.example/", + "--fetch-retries=0", + "--fetch-timeout=5000", + "--json", + ], + env=proxy_environment, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + transport.close() + for thread in origin_threads: + thread.join(timeout=2) + + assert completed.returncode == 0, completed.stderr + assert completed.stdout.strip().strip('"') == "1.0.0" + assert "NO_PROXY" not in proxy_environment + assert "no_proxy" not in proxy_environment + + +@pytest.mark.skipif(shutil.which("curl") is None, reason="curl is unavailable") +def test_redirect_to_loopback_is_revalidated_and_blocked(tmp_path): + connector_calls = [] + + def resolver(host, port, **kwargs): + address = PUBLIC_V4 if host == "public.example" else host + return _resolver(address)(host, port, **kwargs) + + def connector(_family, _sockaddr, _timeout): + connector_calls.append(True) + outbound, origin = socket.socketpair() + + def redirect(): + origin.recv(65536) + origin.sendall( + b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1/\r\n" + b"Content-Length: 0\r\nConnection: close\r\n\r\n" + ) + origin.shutdown(socket.SHUT_WR) + origin.close() + + threading.Thread(target=redirect).start() + return _PublicPeerSocket(outbound, peer=(PUBLIC_V4, 80)) + + transport = _Transport(tmp_path, connector, resolver=resolver) + completed = subprocess.run( + [ + "curl", + "--silent", + "--show-error", + "--fail", + "--location", + "--proxy", + transport.proxy_url, + "--noproxy", + "", + "http://public.example/start", + ], + env={"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")}, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + transport.close() + + assert completed.returncode != 0 + assert len(connector_calls) == 1 + + +@pytest.mark.skipif(shutil.which("curl") is None, reason="curl is unavailable") +def test_https_connect_preserves_end_to_end_ca_validation(tmp_path): + cryptography = pytest.importorskip("cryptography") + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + assert cryptography + now = datetime.datetime.now(datetime.timezone.utc) + ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Odysseus test CA")]) + ca_cert = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .sign(ca_key, hashes.SHA256()) + ) + server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + server_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "public.example")]) + server_cert = ( + x509.CertificateBuilder() + .subject_name(server_name) + .issuer_name(ca_name) + .public_key(server_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("public.example")]), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + ca_path = tmp_path / "ca.pem" + cert_path = tmp_path / "server.pem" + key_path = tmp_path / "server-key.pem" + ca_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM)) + cert_path.write_bytes(server_cert.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes( + server_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + + tls_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tls_listener.bind(("127.0.0.1", 0)) + tls_listener.listen(1) + tls_address = tls_listener.getsockname() + tls_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + tls_context.load_cert_chain(cert_path, key_path) + + def tls_server(): + raw, _address = tls_listener.accept() + with tls_context.wrap_socket(raw, server_side=True) as secured: + assert secured.recv(65536).startswith(b"GET / HTTP/1.1\r\n") + secured.sendall( + b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n" + b"Connection: close\r\n\r\nsecure" + ) + + server_thread = threading.Thread(target=tls_server) + server_thread.start() + + def connector(_family, _sockaddr, timeout): + stream = socket.create_connection(tls_address, timeout=timeout) + return _PublicPeerSocket(stream) + + transport = _Transport(tmp_path, connector) + completed = subprocess.run( + [ + "curl", + "--silent", + "--show-error", + "--fail", + "--proxy", + transport.proxy_url, + "--noproxy", + "", + "--cacert", + str(ca_path), + "https://public.example/", + ], + env={"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")}, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + transport.close() + tls_listener.close() + server_thread.join(timeout=2) + + assert completed.returncode == 0, completed.stderr + assert completed.stdout == "secure" + assert not server_thread.is_alive() + + +def test_trusted_egress_helpers_are_installed_root_owned_by_dockerfile(): + dockerfile = (Path(__file__).resolve().parents[1] / "Dockerfile").read_text( + encoding="utf-8" + ) + makefile = ( + Path(__file__).resolve().parents[1] / "security" / "egress" / "Makefile" + ).read_text(encoding="utf-8") + + assert "make -C security/egress install" in dockerfile + assert "INSTALL_OWNER ?= root" in makefile + assert "INSTALL_GROUP ?= root" in makefile + assert 'realpath "$$(command -v $(PYTHON))"' in makefile + assert "install -o $(INSTALL_OWNER) -g $(INSTALL_GROUP) -m 0755" in makefile + assert "sed -i '1c\\#!$(PYTHON_PATH) -I'" in makefile + assert "$(INSTALL_DIR)/odysseus-egress-broker" in makefile + assert "$(INSTALL_DIR)/odysseus-egress-bridge" in makefile diff --git a/tests/test_execution_sandbox.py b/tests/test_execution_sandbox.py new file mode 100644 index 000000000..5d8a5fa7e --- /dev/null +++ b/tests/test_execution_sandbox.py @@ -0,0 +1,1304 @@ +"""Linux sandbox invariants for model-requested process execution.""" + +import asyncio +import os +import socket +import shutil +import stat +import subprocess +import time +import uuid +from pathlib import Path + +import pytest + +from src.execution_sandbox import ( + SandboxNetworkProfile, + SandboxUnavailable, + environment_for_sandbox_launcher, + full_access_command, + sandbox_command, + sandbox_python_executable, +) + + +@pytest.fixture(autouse=True) +def _stable_bubblewrap_lookup(monkeypatch): + """Keep argv-only tests independent of the CI runner's package set.""" + monkeypatch.setattr( + "src.execution_sandbox._bubblewrap_binary", + lambda: "/usr/bin/bwrap", + ) + monkeypatch.setattr( + "src.execution_sandbox._seccomp_launcher_binary", + lambda: "/usr/local/libexec/odysseus-seccomp-launcher", + ) + monkeypatch.setattr( + "src.execution_sandbox._egress_broker_binary", + lambda: "/usr/local/libexec/odysseus-egress-broker", + ) + monkeypatch.setattr( + "src.execution_sandbox._egress_bridge_binary", + lambda: "/usr/local/libexec/odysseus-egress-bridge", + ) + +requires_bubblewrap = pytest.mark.skipif( + shutil.which("bwrap") is None or shutil.which("make") is None, + reason="bubblewrap and make are required for sandbox runtime assertions", +) + + +@pytest.fixture(scope="session") +def compiled_seccomp_launcher(tmp_path_factory): + build_dir = tmp_path_factory.mktemp("seccomp-launcher") + source_dir = Path(__file__).resolve().parents[1] / "security" / "seccomp" + completed = subprocess.run( + [ + "make", + "-C", + str(source_dir), + f"BUILD_DIR={build_dir}", + "all", + ], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + if completed.returncode != 0: + pytest.skip(f"trusted launcher compilation failed: {completed.stderr}") + return build_dir / "odysseus-seccomp-launcher" + + +@pytest.fixture +def runtime_seccomp_launcher(monkeypatch, compiled_seccomp_launcher, tmp_path): + monkeypatch.setattr( + "src.execution_sandbox._seccomp_launcher_binary", + lambda: str(compiled_seccomp_launcher), + ) + + + preflight_workspace = tmp_path / "sandbox-preflight" + preflight_workspace.mkdir() + completed = subprocess.run( + sandbox_command(["/bin/true"], workspace=str(preflight_workspace)), + cwd=preflight_workspace, + env={}, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + if ( + completed.returncode != 0 + and "Can't mount proc" in completed.stderr + and "Operation not permitted" in completed.stderr + ): + pytest.skip( + "secretless runner outer sandbox blocks fresh procfs; " + "shipped outer OCI profile validation is required" + ) + assert completed.returncode == 0, completed.stderr + return compiled_seccomp_launcher + + +def test_sandbox_argv_is_positive_mount_networkless_by_default_and_clearenv(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + + argv = sandbox_command(["/bin/bash", "-c", "true"], workspace=str(workspace)) + + + + assert argv[:2] == [ + "/usr/local/libexec/odysseus-seccomp-launcher", + "/usr/bin/bwrap", + ] + assert "/usr/local/libexec/odysseus-egress-broker" not in argv + assert "/usr/local/libexec/odysseus-egress-bridge" not in argv + assert "/run/odysseus-egress/broker.sock" not in argv + for option in ( + "--unshare-user", + "--unshare-ipc", + "--unshare-pid", + "--unshare-net", + "--unshare-uts", + "--unshare-cgroup", + ): + assert option in argv + assert "--share-net" not in argv + assert "--seccomp" not in argv + assert ["--proc", "/proc"] in [ + argv[index:index + 2] for index in range(len(argv) - 1) + ] + assert "--clearenv" in argv + assert "/usr/bin/prlimit" in argv + assert "--nproc=256" not in argv + assert "--cpu=3600" in argv + assert "--fsize=4294967296" in argv + assert "--nofile=1024" in argv + assert "--as=4294967296" in argv + assert ["--ro-bind", "/", "/"] not in [ + argv[index:index + 3] for index in range(len(argv) - 2) + ] + bind_index = argv.index("--bind") + assert argv[bind_index + 1:bind_index + 3] == [ + str(workspace), + str(workspace), + ] + assert environment_for_sandbox_launcher() == {} + assert "OPENAI_API_KEY" not in argv + for variable in ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "no_proxy"): + assert variable not in argv + + +def test_trusted_executable_rejects_missing_or_writable_install(monkeypatch): + from src.execution_sandbox import _trusted_executable + + with pytest.raises(SandboxUnavailable, match="requires the trusted"): + _trusted_executable("/definitely/missing/launcher", "seccomp launcher") + + metadata = type( + "Metadata", + (), + {"st_mode": stat.S_IFREG | 0o775, "st_uid": 0}, + )() + monkeypatch.setattr("src.execution_sandbox.os.stat", lambda _path: metadata) + monkeypatch.setattr("src.execution_sandbox.os.path.realpath", lambda path: path) + monkeypatch.setattr("src.execution_sandbox.os.access", lambda *_args: True) + with pytest.raises( + SandboxUnavailable, + match="root-owned, non-setuid, read-only", + ): + _trusted_executable("/trusted/launcher", "seccomp launcher") + + metadata.st_mode = stat.S_IFREG | stat.S_ISUID | 0o755 + with pytest.raises(SandboxUnavailable, match="non-setuid"): + _trusted_executable("/trusted/launcher", "seccomp launcher") + + +def test_trusted_python_helpers_require_fixed_isolated_interpreter( + tmp_path, + monkeypatch, +): + from src.execution_sandbox import _trusted_python_helper + + helper = tmp_path / "broker" + helper.write_text("#!/usr/bin/python3.13 -I\n", encoding="ascii") + metadata = type( + "Metadata", + (), + {"st_mode": stat.S_IFREG | 0o755, "st_uid": 0}, + )() + monkeypatch.setattr("src.execution_sandbox.os.stat", lambda _path: metadata) + monkeypatch.setattr("src.execution_sandbox.os.path.realpath", lambda path: path) + monkeypatch.setattr("src.execution_sandbox.os.access", lambda *_args: True) + + assert _trusted_python_helper(str(helper), "egress broker") == str(helper) + + helper.write_text("#!/usr/bin/env python3\n", encoding="ascii") + with pytest.raises(SandboxUnavailable, match="isolated absolute"): + _trusted_python_helper(str(helper), "egress broker") + + +def test_sandbox_rejects_invalid_network_profile(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + + with pytest.raises(SandboxUnavailable, match="Invalid server-owned"): + sandbox_command( + ["/bin/true"], + workspace=str(workspace), + network_profile="open", # type: ignore[arg-type] + ) + + +def test_sandbox_rejects_launcher_workspace_overlap(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr( + "src.execution_sandbox._seccomp_launcher_binary", + lambda: str(workspace / "odysseus-seccomp-launcher"), + ) + + with pytest.raises(SandboxUnavailable, match="overlaps"): + sandbox_command(["/bin/true"], workspace=str(workspace)) + + +def test_brokered_profile_uses_private_namespace_and_trusted_proxy(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + + argv = sandbox_command( + ["/bin/bash", "-c", "true"], + workspace=str(workspace), + network_profile=SandboxNetworkProfile.BROKERED_ONLY, + ) + + assert argv[:3] == [ + "/usr/local/libexec/odysseus-egress-broker", + "/usr/local/libexec/odysseus-seccomp-launcher", + "/usr/bin/bwrap", + ] + assert "--unshare-net" in argv + assert "--share-net" not in argv + separator = argv.index("--") + assert argv[separator + 1:separator + 4] == [ + "/usr/local/libexec/odysseus-egress-bridge", + "/run/odysseus-egress/broker.sock", + "--", + ] + environment = { + argv[index + 1]: argv[index + 2] + for index, value in enumerate(argv[:-2]) + if value == "--setenv" + } + assert environment["HTTP_PROXY"] == "http://127.0.0.1:3128" + assert environment["HTTPS_PROXY"] == "http://127.0.0.1:3128" + assert environment["http_proxy"] == "http://127.0.0.1:3128" + assert environment["https_proxy"] == "http://127.0.0.1:3128" + assert "NO_PROXY" not in environment + assert "no_proxy" not in environment + assert "ALL_PROXY" not in environment + assert all( + "@" not in environment[name] + for name in environment + if "proxy" in name.lower() + ) + assert "OPENAI_API_KEY" not in argv + + +def test_brokered_profile_fails_closed_when_trusted_broker_is_missing( + tmp_path, + monkeypatch, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + + def missing_broker(): + raise SandboxUnavailable( + "Sandboxed agent execution requires the trusted egress broker." + ) + + monkeypatch.setattr( + "src.execution_sandbox._egress_broker_binary", + missing_broker, + ) + with pytest.raises(SandboxUnavailable, match="trusted egress broker"): + sandbox_command( + ["/bin/true"], + workspace=str(workspace), + network_profile=SandboxNetworkProfile.BROKERED_ONLY, + ) + + +def test_brokered_profile_fails_closed_without_ca_bundle(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr( + "src.execution_sandbox._CA_CERTIFICATE", + "/definitely/missing/ca-certificates.crt", + ) + + with pytest.raises(SandboxUnavailable, match="CA certificate bundle"): + sandbox_command( + ["/bin/true"], + workspace=str(workspace), + network_profile=SandboxNetworkProfile.BROKERED_ONLY, + ) + + +def test_full_access_binds_service_filesystem_but_retains_private_network( + tmp_path, +): + argv = full_access_command( + ["/bin/true"], + working_directory=str(tmp_path), + network_profile=SandboxNetworkProfile.NETWORKLESS, + ) + + triples = [argv[index:index + 3] for index in range(len(argv) - 2)] + assert ["--bind", "/", "/"] in triples + assert "--unshare-net" in argv + assert "--share-net" not in argv + assert ["--proc", "/proc"] in [ + argv[index:index + 2] for index in range(len(argv) - 1) + ] + assert "/usr/bin/prlimit" in argv + + +def test_full_access_brokered_profile_uses_trusted_proxy(tmp_path): + argv = full_access_command( + ["/bin/true"], + working_directory=str(tmp_path), + network_profile=SandboxNetworkProfile.BROKERED_ONLY, + ) + + assert argv[0] == "/usr/local/libexec/odysseus-egress-broker" + assert "--unshare-net" in argv + assert "http://127.0.0.1:3128" in argv + assert "/usr/local/libexec/odysseus-egress-bridge" in argv + + +def test_sandbox_overlays_credentials_and_protects_git(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / ".env").write_text("SECRET=value", encoding="utf-8") + (workspace / ".git").mkdir() + (workspace / ".ssh").mkdir() + (workspace / ".config" / "gh").mkdir(parents=True) + (workspace / ".profile").write_text("persist", encoding="utf-8") + (workspace / ".bash_login").write_text("persist", encoding="utf-8") + + argv = sandbox_command(["/bin/true"], workspace=str(workspace)) + + triples = [argv[index:index + 3] for index in range(len(argv) - 2)] + pairs = [argv[index:index + 2] for index in range(len(argv) - 1)] + assert ["--ro-bind", "/dev/null", str(workspace / ".env")] in triples + assert [ + "--ro-bind", + str(workspace / ".git"), + str(workspace / ".git"), + ] in triples + assert ["--tmpfs", str(workspace / ".ssh")] in pairs + assert ["--tmpfs", str(workspace / ".config" / "gh")] in pairs + assert ["--ro-bind", "/dev/null", str(workspace / ".profile")] in triples + assert ["--ro-bind", "/dev/null", str(workspace / ".bash_login")] in triples + + +def test_sandbox_rejects_preexisting_unix_socket_in_workspace(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + socket_path = workspace / "docker.sock" + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(str(socket_path)) + try: + with pytest.raises(SandboxUnavailable, match="unsupported special file"): + sandbox_command(["/bin/true"], workspace=str(workspace)) + finally: + listener.close() + + +def test_sandbox_rejects_nested_mount_in_workspace(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + nested = workspace / "mounted-host-tree" + nested.mkdir() + mountinfo = ( + "36 25 0:32 / / rw,relatime - overlay overlay rw\n" + f"37 36 0:33 / {nested} rw,relatime - tmpfs tmpfs rw\n" + ) + monkeypatch.setattr( + "src.execution_sandbox._MOUNTINFO_PATH", + str(tmp_path / "mountinfo"), + ) + (tmp_path / "mountinfo").write_text(mountinfo, encoding="utf-8") + + with pytest.raises(SandboxUnavailable, match="nested mount"): + sandbox_command(["/bin/true"], workspace=str(workspace)) + + +def test_sandbox_rejects_bind_mounted_regular_file(tmp_path, monkeypatch): + workspace = tmp_path / "workspace with space" + workspace.mkdir() + mounted_file = workspace / "innocent.txt" + mounted_file.write_text("external", encoding="utf-8") + escaped_mount = str(mounted_file).replace(" ", r"\040") + mountinfo = ( + "36 25 0:32 / / rw,relatime - overlay overlay rw\n" + f"37 36 0:33 / {escaped_mount} rw,relatime - ext4 /dev/root rw\n" + ) + mountinfo_path = tmp_path / "mountinfo" + mountinfo_path.write_text(mountinfo, encoding="utf-8") + monkeypatch.setattr( + "src.execution_sandbox._MOUNTINFO_PATH", + str(mountinfo_path), + ) + + with pytest.raises(SandboxUnavailable, match="nested mount"): + sandbox_command(["/bin/true"], workspace=str(workspace)) + + +def test_sandbox_fails_closed_when_mountinfo_is_unavailable(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr( + "src.execution_sandbox._MOUNTINFO_PATH", + str(tmp_path / "missing-mountinfo"), + ) + + with pytest.raises(SandboxUnavailable, match="mount boundaries"): + sandbox_command(["/bin/true"], workspace=str(workspace)) + + +def test_sandbox_rejects_broad_workspace(): + with pytest.raises(SandboxUnavailable): + sandbox_command(["/bin/true"], workspace="/") + + with pytest.raises(SandboxUnavailable): + sandbox_command(["/bin/true"], workspace="/usr/local/share/agent") + + +def test_sandbox_rejects_the_process_home_as_workspace(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + + with pytest.raises(SandboxUnavailable): + sandbox_command(["/bin/true"], workspace=str(tmp_path)) + + +def test_sandbox_rejects_any_login_home_and_exposing_ancestor(tmp_path, monkeypatch): + login_home = tmp_path / "users" / "alice" + project = login_home / "project" + project.mkdir(parents=True) + monkeypatch.setattr( + "src.execution_sandbox._login_home_roots", + lambda: {str(login_home.resolve())}, + ) + monkeypatch.setattr( + "src.constants.AGENT_WORKSPACE_DIR", + str(tmp_path / "managed-agent-workspace"), + ) + + with pytest.raises(SandboxUnavailable, match="login-profile"): + sandbox_command(["/bin/true"], workspace=str(login_home)) + with pytest.raises(SandboxUnavailable, match="login-profile"): + sandbox_command(["/bin/true"], workspace=str(login_home.parent)) + + assert sandbox_command(["/bin/true"], workspace=str(project)) + + +def test_sandbox_allows_managed_workspace_inside_login_home(tmp_path, monkeypatch): + login_home = tmp_path / "users" / "alice" + managed = login_home / "odysseus-agent" + managed.mkdir(parents=True) + monkeypatch.setattr( + "src.execution_sandbox._login_home_roots", + lambda: {str(login_home.resolve())}, + ) + monkeypatch.setattr("src.constants.AGENT_WORKSPACE_DIR", str(managed)) + + assert sandbox_command(["/bin/true"], workspace=str(managed)) + + +def test_sandbox_rejects_sensitive_root_selection(tmp_path): + sensitive = tmp_path / ".ssh" + sensitive.mkdir() + + with pytest.raises(SandboxUnavailable, match="sensitive"): + sandbox_command(["/bin/true"], workspace=str(sensitive)) + + +def test_sandbox_rejects_hard_linked_workspace_file(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + outside = tmp_path / "outside-secret" + outside.write_text("secret", encoding="utf-8") + os.link(outside, workspace / "innocent.txt") + + with pytest.raises(SandboxUnavailable, match="hard-linked"): + sandbox_command(["/bin/true"], workspace=str(workspace)) + + +def test_sandbox_protects_worktree_git_file(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + git_file = workspace / ".git" + git_file.write_text("gitdir: /outside", encoding="utf-8") + + argv = sandbox_command(["/bin/true"], workspace=str(workspace)) + + triples = [argv[index:index + 3] for index in range(len(argv) - 2)] + assert ["--ro-bind", str(git_file), str(git_file)] in triples + + +def test_sandbox_rejects_symlinked_sensitive_mounts(tmp_path): + workspace = tmp_path / "workspace" + outside = tmp_path / "outside" + workspace.mkdir() + outside.mkdir() + (workspace / ".git").symlink_to(outside, target_is_directory=True) + + with pytest.raises(SandboxUnavailable): + sandbox_command(["/bin/true"], workspace=str(workspace)) + + +@requires_bubblewrap +def test_sandbox_hides_odysseus_data_inside_broader_workspace( + tmp_path, + monkeypatch, + runtime_seccomp_launcher, +): + import src.constants as constants + + workspace = tmp_path / "app" + data_dir = workspace / "data" + logs_dir = workspace / "logs" + agent_dir = data_dir / "agent_workspace" + data_dir.mkdir(parents=True) + logs_dir.mkdir() + agent_dir.mkdir() + (workspace / "allowed.txt").write_text("workspace", encoding="utf-8") + (data_dir / "app.db").write_text("private", encoding="utf-8") + (data_dir / ".env").write_text("PRIVATE=value", encoding="utf-8") + monkeypatch.setattr(constants, "DATA_DIR", str(data_dir)) + monkeypatch.setattr(constants, "LOGS_DIR", str(logs_dir)) + monkeypatch.setattr(constants, "AGENT_WORKSPACE_DIR", str(agent_dir)) + monkeypatch.setattr(constants, "MAIL_ATTACHMENTS_DIR", str(data_dir / "mail")) + monkeypatch.setattr(constants, "APP_DB", str(data_dir / "app.db")) + + argv = sandbox_command( + [ + "/bin/bash", + "-c", + "test -s allowed.txt && test ! -e data/app.db && test ! -e logs/private.log", + ], + workspace=str(workspace), + ) + + pairs = [argv[index:index + 2] for index in range(len(argv) - 1)] + triples = [argv[index:index + 3] for index in range(len(argv) - 2)] + assert ["--tmpfs", str(data_dir)] in pairs + assert ["--tmpfs", str(logs_dir)] in pairs + assert ["--ro-bind", "/dev/null", str(data_dir / "app.db")] not in triples + completed = subprocess.run( + argv, + cwd=str(workspace), + env={}, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + assert completed.returncode == 0, completed.stderr + + +def test_sandbox_rejects_configured_sqlite_database_inside_workspace( + tmp_path, + monkeypatch, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + database = workspace / "custom.db" + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{database}") + + with pytest.raises( + SandboxUnavailable, + match="selected workspace contains an Odysseus SQLite database", + ): + sandbox_command(["/bin/true"], workspace=str(workspace)) + + + +def test_sandbox_rejects_relative_configured_sqlite_database( + tmp_path, + monkeypatch, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setenv("DATABASE_URL", "sqlite:///relative.db") + monkeypatch.setattr("src.runtime_paths.get_app_root", lambda: str(workspace)) + + with pytest.raises( + SandboxUnavailable, + match="selected workspace contains an Odysseus SQLite database", + ): + sandbox_command(["/bin/true"], workspace=str(workspace)) + + +@pytest.mark.parametrize( + "url_builder", + [ + pytest.param(lambda db: f"sqlite:///{db}", id="sqlite-absolute"), + pytest.param(lambda db: f"sqlite+pysqlite:///{db}", id="driver-absolute"), + pytest.param(lambda _db: "sqlite:///relative.db", id="sqlite-relative"), + pytest.param(lambda _db: "sqlite+pysqlite:///relative.db", id="driver-relative"), + pytest.param( + lambda db: f"sqlite:///file:{db}?uri=true", + id="sqlite-file-uri", + ), + pytest.param( + lambda db: f"sqlite+pysqlite:///file:{db}?mode=rwc&uri=true", + id="driver-file-uri", + ), + pytest.param( + lambda db: f"sqlite+pysqlite:///file://localhost{db}?uri=true", + id="localhost-file-uri", + ), + ], +) +def test_sandbox_rejects_every_file_backed_sqlite_url_shape( + tmp_path, + monkeypatch, + url_builder, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + database = workspace / "future.db" + monkeypatch.setenv("DATABASE_URL", url_builder(str(database))) + monkeypatch.setattr("src.runtime_paths.get_app_root", lambda: str(workspace)) + + assert not database.exists() + with pytest.raises( + SandboxUnavailable, + match="selected workspace contains an Odysseus SQLite database", + ): + sandbox_command(["/bin/true"], workspace=str(workspace)) + + +def test_sandbox_allows_in_memory_and_postgresql_databases(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + + for database_url in ( + "sqlite:///:memory:", + "sqlite+pysqlite:///file:memdb1?mode=memory&cache=shared&uri=true", + "postgresql+psycopg2://user:pass@example.invalid/app", + ): + monkeypatch.setenv("DATABASE_URL", database_url) + assert sandbox_command(["/bin/true"], workspace=str(workspace)) + + +def test_default_agent_workspace_remains_usable(monkeypatch, tmp_path): + import src.constants as constants + + data_dir = tmp_path / "data" + agent_workspace = data_dir / "agent_workspace" + data_dir.mkdir() + agent_workspace.mkdir() + monkeypatch.setattr(constants, "DATA_DIR", str(data_dir)) + monkeypatch.setattr(constants, "AGENT_WORKSPACE_DIR", str(agent_workspace)) + monkeypatch.setattr(constants, "LOGS_DIR", str(tmp_path / "logs")) + monkeypatch.setattr(constants, "MAIL_ATTACHMENTS_DIR", str(data_dir / "mail")) + monkeypatch.setattr(constants, "APP_DB", str(data_dir / "app.db")) + monkeypatch.delenv("DATABASE_URL", raising=False) + + assert sandbox_command(["/bin/true"], workspace=str(agent_workspace)) + + +def test_sandbox_allows_only_dedicated_workspace_below_data( + tmp_path, + monkeypatch, +): + import src.constants as constants + + data_dir = tmp_path / "data" + agent_dir = data_dir / "agent_workspace" + private_dir = data_dir / "personal_docs" + agent_dir.mkdir(parents=True) + private_dir.mkdir() + monkeypatch.setattr(constants, "DATA_DIR", str(data_dir)) + monkeypatch.setattr(constants, "LOGS_DIR", str(tmp_path / "logs")) + monkeypatch.setattr(constants, "AGENT_WORKSPACE_DIR", str(agent_dir)) + monkeypatch.setattr(constants, "MAIL_ATTACHMENTS_DIR", str(data_dir / "mail")) + + assert sandbox_command(["/bin/true"], workspace=str(agent_dir)) + with pytest.raises(SandboxUnavailable): + sandbox_command(["/bin/true"], workspace=str(private_dir)) + + +@requires_bubblewrap +def test_sandbox_hides_host_and_environment_at_runtime( + tmp_path, + runtime_seccomp_launcher, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + outside = tmp_path / "outside-secret" + outside.write_text("outside", encoding="utf-8") + (workspace / ".env").write_text("INSIDE_SECRET=value", encoding="utf-8") + (workspace / ".git").mkdir() + command = ( + "set -eu; " + "test ! -e \"$1\"; " + "test -z \"${OPENAI_API_KEY:-}\"; " + "test ! -s .env; " + "test ! -e /home; " + "test -r /proc/self/status; " + "test \"$$\" -eq 1; " + "touch allowed.txt; " + "if touch .git/blocked 2>/dev/null; then exit 91; fi" + ) + argv = sandbox_command( + ["/bin/bash", "-c", command, "sandbox", str(outside)], + workspace=str(workspace), + ) + env = {"OPENAI_API_KEY": "must-not-cross"} + + completed = subprocess.run( + argv, + cwd=str(workspace), + env=env, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + assert (workspace / "allowed.txt").exists() + assert not (workspace / ".git" / "blocked").exists() + + +@requires_bubblewrap +def test_sandbox_network_namespace_has_no_external_route( + tmp_path, + runtime_seccomp_launcher, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + port = listener.getsockname()[1] + code = ( + "import socket; " + "s=socket.socket(); s.settimeout(0.2); " + f"\ntry: s.connect(('127.0.0.1', {port}))" + "\nexcept OSError: raise SystemExit(0)" + "\nraise SystemExit(1)" + ) + argv = sandbox_command( + [sandbox_python_executable(), "-I", "-c", code], + workspace=str(workspace), + ) + + completed = subprocess.run( + argv, + cwd=str(workspace), + env={}, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + + +@requires_bubblewrap +def test_sandbox_status_has_one_additional_inner_filter( + tmp_path, + runtime_seccomp_launcher, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + + def status_value(text, name): + for line in text.splitlines(): + if line.startswith(f"{name}:"): + return int(line.split(":", 1)[1].strip()) + raise AssertionError(f"missing {name} in process status") + + parent_status = Path("/proc/self/status").read_text(encoding="utf-8") + parent_filters = status_value(parent_status, "Seccomp_filters") + argv = sandbox_command( + [ + "/bin/bash", + "-c", + "grep -E '^(NoNewPrivs|Seccomp|Seccomp_filters):' /proc/self/status", + ], + workspace=str(workspace), + ) + completed = subprocess.run( + argv, + cwd=workspace, + env={}, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + assert status_value(completed.stdout, "NoNewPrivs") == 1 + assert status_value(completed.stdout, "Seccomp") == 2 + assert status_value(completed.stdout, "Seccomp_filters") == parent_filters + 1 + + +@requires_bubblewrap +@pytest.mark.parametrize( + "probe", + [ + "bpf", + "perf_event_open", + "clone_namespace", + "clone3", + "unshare", + "setns", + "mount", + "umount2", + "pivot_root", + "ptrace", + "process_vm_readv", + "process_vm_writev", + "keyctl", + "open_by_handle_at", + "af_packet", + "af_alg", + "af_vsock", + "tiocsti", + "tiocsti_high_bits", + "userfaultfd", + "io_uring_setup", + "fork", + ], +) +def test_inner_seccomp_syscall_policy( + tmp_path, + runtime_seccomp_launcher, + probe, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + probe_source = Path(__file__).with_name("seccomp_probe.c") + probe_binary = workspace / "seccomp-probe" + compiled = subprocess.run( + [ + "cc", + "-std=c11", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + str(probe_source), + "-o", + str(probe_binary), + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert compiled.returncode == 0, compiled.stderr + + completed = subprocess.run( + sandbox_command([str(probe_binary), probe], workspace=str(workspace)), + cwd=workspace, + env={}, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + + if completed.returncode == 77: + pytest.skip(f"{probe} is unavailable on this architecture") + assert completed.returncode == 0, completed.stderr + + +@requires_bubblewrap +def test_common_development_workloads_remain_compatible( + tmp_path, + runtime_seccomp_launcher, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + command = r""" +set -eu +printf 'alpha\n' > ordinary.txt +cp ordinary.txt renamed.txt +rm ordinary.txt +python3 - <<'PY' +import multiprocessing +import pathlib +import subprocess +import threading + +seen = [] +thread = threading.Thread(target=lambda: seen.append("thread")) +thread.start() +thread.join() +assert seen == ["thread"] +assert subprocess.check_output(["/bin/sh", "-c", "printf child"]) == b"child" +proc = multiprocessing.get_context("fork").Process(target=lambda: None) +proc.start() +proc.join() +assert proc.exitcode == 0 +pathlib.Path("python-output.txt").write_text("python", encoding="utf-8") +PY +if command -v node >/dev/null 2>&1; then + node -e 'require("fs").writeFileSync("node-output.txt", "node")' +fi +if command -v cc >/dev/null 2>&1; then + printf 'int main(void) { return 0; }\n' > probe.c + cc probe.c -o compiled-probe + ./compiled-probe +fi +git status --short >/dev/null +git diff --no-ext-diff >/dev/null +git log -1 --oneline >/dev/null +""" + subprocess.run( + ["git", "init", "-q", str(workspace)], + capture_output=True, + text=True, + check=True, + ) + (workspace / "tracked.txt").write_text("tracked\n", encoding="utf-8") + subprocess.run( + [ + "git", + "-C", + str(workspace), + "-c", + "user.name=Sandbox Test", + "-c", + "user.email=sandbox@example.invalid", + "add", + "tracked.txt", + ], + capture_output=True, + text=True, + check=True, + ) + subprocess.run( + [ + "git", + "-C", + str(workspace), + "-c", + "user.name=Sandbox Test", + "-c", + "user.email=sandbox@example.invalid", + "commit", + "-qm", + "fixture", + ], + capture_output=True, + text=True, + check=True, + ) + completed = subprocess.run( + sandbox_command(["/bin/bash", "-c", command], workspace=str(workspace)), + cwd=workspace, + env={}, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + assert (workspace / "renamed.txt").read_text(encoding="utf-8") == "alpha\n" + assert (workspace / "python-output.txt").read_text(encoding="utf-8") == "python" + if shutil.which("node"): + assert (workspace / "node-output.txt").read_text(encoding="utf-8") == "node" + + +def test_tmux_session_identity_includes_network_policy(tmp_path): + from src.agent_tools.subprocess_tools import _tmux_session_name + + workspace = tmp_path / "workspace" + workspace.mkdir() + + policy_key = "a" * 16 + isolated = _tmux_session_name( + "session-1", + str(workspace), + policy_key=policy_key, + ) + isolated_again = _tmux_session_name( + "session-1", + str(workspace), + policy_key=policy_key, + ) + brokered = _tmux_session_name( + "session-1", + str(workspace), + network_profile=SandboxNetworkProfile.BROKERED_ONLY, + policy_key=policy_key, + ) + + assert isolated == isolated_again + assert isolated != brokered + assert isolated.startswith("ody-agent-sbx-v2-") + assert isolated.endswith(f"-networkless-{policy_key}") + assert brokered.endswith(f"-brokered-only-{policy_key}") + + +def test_tmux_policy_fingerprint_changes_for_workspace_protection(tmp_path): + from src.agent_tools.subprocess_tools import ( + _tmux_policy_key, + _tmux_session_name, + ) + + workspace = tmp_path / "workspace" + workspace.mkdir() + + def session_name(): + shell_argv = sandbox_command( + ["/bin/bash", "--noprofile", "--norc"], + workspace=str(workspace), + ) + return _tmux_session_name( + "session-1", + str(workspace), + policy_key=_tmux_policy_key(os.stat(workspace), shell_argv), + ) + + baseline = session_name() + (workspace / ".env").write_text("SECRET=value", encoding="utf-8") + with_env = session_name() + (workspace / ".ssh").mkdir() + with_ssh = session_name() + (workspace / ".git").mkdir() + with_git = session_name() + + assert baseline != with_env + assert with_env != with_ssh + assert with_ssh != with_git + + +def test_tmux_policy_fingerprint_changes_when_workspace_inode_changes(tmp_path): + from src.agent_tools.subprocess_tools import ( + _tmux_policy_key, + _tmux_session_name, + ) + + workspace = tmp_path / "workspace" + replacement = tmp_path / "replacement" + workspace.mkdir() + replacement.mkdir() + shell_argv = ["/bin/bash", "--noprofile", "--norc"] + old_stat = os.stat(workspace) + old_name = _tmux_session_name( + "session-1", + str(workspace), + policy_key=_tmux_policy_key(old_stat, shell_argv), + ) + + old_workspace = tmp_path / "workspace-old" + workspace.rename(old_workspace) + replacement.rename(workspace) + new_stat = os.stat(workspace) + new_name = _tmux_session_name( + "session-1", + str(workspace), + policy_key=_tmux_policy_key(new_stat, shell_argv), + ) + + assert (old_stat.st_dev, old_stat.st_ino) != (new_stat.st_dev, new_stat.st_ino) + assert old_name != new_name + + +@pytest.mark.asyncio +async def test_tmux_cleanup_kills_only_stale_logical_sessions(monkeypatch, tmp_path): + from src.agent_tools import subprocess_tools + + workspace = tmp_path / "workspace" + workspace.mkdir() + prefix = subprocess_tools._tmux_session_prefix("session-1", str(workspace)) + current = f"{prefix}-current" + stale = f"{prefix}-old" + legacy_v1 = subprocess_tools._tmux_legacy_session_name( + "session-1", + str(workspace), + network_profile=SandboxNetworkProfile.NETWORKLESS, + ) + legacy_pre_sandbox = subprocess_tools._tmux_pre_sandbox_session_name( + "session-1" + ) + killed = [] + + async def fake_names(): + return [ + current, + stale, + legacy_v1, + legacy_pre_sandbox, + "ody-agent-sbx-v2-other-workspace-networkless-old", + ] + + async def fake_kill(name): + killed.append(name) + + monkeypatch.setattr(subprocess_tools, "_tmux_session_names", fake_names) + monkeypatch.setattr(subprocess_tools, "_tmux_kill_session", fake_kill) + + await subprocess_tools._cleanup_stale_tmux_sessions( + prefix, + (legacy_v1, legacy_pre_sandbox), + current, + ) + + assert killed == sorted([stale, legacy_v1, legacy_pre_sandbox]) + + +@pytest.mark.asyncio +async def test_tmux_policy_failure_terminates_existing_logical_sessions( + monkeypatch, + tmp_path, +): + from src.agent_tools import subprocess_tools + + workspace = tmp_path / "workspace" + workspace.mkdir() + session_id = "session-1" + prefix = subprocess_tools._tmux_session_prefix( + session_id, + str(workspace), + ) + stale_v2 = f"{prefix}-old-policy" + legacy_v1 = subprocess_tools._tmux_legacy_session_name( + session_id, + str(workspace), + network_profile=SandboxNetworkProfile.NETWORKLESS, + ) + legacy_pre_sandbox = subprocess_tools._tmux_pre_sandbox_session_name( + session_id + ) + killed = [] + + def reject_policy(*_args, **_kwargs): + raise SandboxUnavailable("workspace policy changed") + + async def fake_names(): + return [stale_v2, legacy_v1, legacy_pre_sandbox, "unrelated-session"] + + async def fake_kill(name): + killed.append(name) + + monkeypatch.setattr(subprocess_tools, "sandbox_command", reject_policy) + monkeypatch.setattr(subprocess_tools, "_tmux_session_names", fake_names) + monkeypatch.setattr(subprocess_tools, "_tmux_kill_session", fake_kill) + + with pytest.raises(SandboxUnavailable, match="workspace policy changed"): + await subprocess_tools._run_tmux_bash( + "printf blocked", + session_id=session_id, + cwd=str(workspace), + timeout=1, + ) + + assert killed == sorted([stale_v2, legacy_v1, legacy_pre_sandbox]) + + +@requires_bubblewrap +def test_tmux_bash_shell_runs_inside_same_sandbox( + tmp_path, + runtime_seccomp_launcher, +): + from src.agent_tools.subprocess_tools import ( + _run_exec, + _run_tmux_bash, + _tmux_session_name, + ) + + workspace = tmp_path / "workspace" + workspace.mkdir() + outside = tmp_path / "outside-secret" + outside.write_text("secret", encoding="utf-8") + session_id = f"sandbox-test-{uuid.uuid4().hex}" + session_name = None + + async def run(): + nonlocal session_name + try: + result = await _run_tmux_bash( + f"test ! -e {outside!s} && pwd && touch tmux-write.txt", + session_id=session_id, + cwd=str(workspace), + timeout=10, + ) + session_name = result[4] + return result + finally: + if session_name: + await _run_exec("tmux", "kill-session", "-t", session_name, timeout=3) + + stdout, stderr, returncode, timed_out, session_name = asyncio.run(run()) + + assert timed_out is False + assert returncode == 0, stderr + assert str(workspace) in stdout + assert (workspace / "tmux-write.txt").exists() + + +@requires_bubblewrap +def test_tmux_bash_rotates_when_env_appears( + tmp_path, + runtime_seccomp_launcher, +): + from src.agent_tools.subprocess_tools import _run_exec, _run_tmux_bash + + workspace = tmp_path / "workspace" + workspace.mkdir() + session_id = f"sandbox-rotation-{uuid.uuid4().hex}" + session_names = [] + + async def run(): + first = await _run_tmux_bash( + "test ! -s .env && printf first", + session_id=session_id, + cwd=str(workspace), + timeout=10, + ) + session_names.append(first[4]) + (workspace / ".env").write_text("marker-secret", encoding="utf-8") + second = await _run_tmux_bash( + "test ! -s .env && printf second", + session_id=session_id, + cwd=str(workspace), + timeout=10, + ) + session_names.append(second[4]) + sessions, _, _ = await _run_exec( + "tmux", + "list-sessions", + "-F", + "#{session_name}", + timeout=3, + ) + return first, second, sessions.splitlines() + + try: + first, second, sessions = asyncio.run(run()) + finally: + async def cleanup(): + for name in session_names: + await _run_exec("tmux", "kill-session", "-t", name, timeout=3) + + asyncio.run(cleanup()) + + assert first[3] is False + assert second[3] is False + assert first[0].endswith("first") + assert second[0].endswith("second") + assert session_names[0] != session_names[1] + assert session_names[0] not in sessions + assert session_names[1] in sessions + + +@requires_bubblewrap +def test_detached_background_job_uses_sandbox( + tmp_path, + monkeypatch, + runtime_seccomp_launcher, +): + from src import bg_jobs + + jobs_dir = tmp_path / "jobs" + jobs_dir.mkdir() + workspace = tmp_path / "workspace" + workspace.mkdir() + outside = tmp_path / "outside-secret" + outside.write_text("secret", encoding="utf-8") + monkeypatch.setattr(bg_jobs, "_JOBS_DIR", jobs_dir) + monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "jobs.json") + + record = bg_jobs.launch( + f"test ! -e {outside!s} && printf background-ok && touch bg-write.txt", + session_id="sandbox-session", + cwd=str(workspace), + max_runtime_s=10, + ) + deadline = time.time() + 10 + current = record + while current.get("status") == "running" and time.time() < deadline: + time.sleep(0.05) + current = bg_jobs.get(record["id"]) or current + + assert current["status"] == "done", current + assert current["exit_code"] == 0 + assert "background-ok" in current["output"] + assert (workspace / "bg-write.txt").exists() diff --git a/tests/test_foreground_model_routing.py b/tests/test_foreground_model_routing.py index e2ceb8762..cdd9342f0 100644 --- a/tests/test_foreground_model_routing.py +++ b/tests/test_foreground_model_routing.py @@ -95,6 +95,7 @@ def _chat_stream_endpoint( chat_chunks=None, capture_completion=False, capture_context=False, + capture_network=False, endpoint_url="https://selected.example/v1", ): def add_message(message): @@ -158,6 +159,8 @@ async def fake_agent_stream(endpoint_url, model, messages, **kwargs): "primary": (endpoint_url, model, kwargs.get("headers")), "fallbacks": kwargs.get("fallbacks"), } + if capture_network: + captured["agent_network_profile"] = kwargs.get("network_profile") if kwargs.get("external_untrusted_context_seen"): captured["agent_external_untrusted_context_seen"] = True if kwargs.get("exact_approval") is not None: @@ -263,6 +266,47 @@ async def test_chat_stream_route_keeps_selected_model_strict_with_legacy_data(mo assert captured == {"agent": {"primary": selected, "fallbacks": []}} +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("allow_web_search", "allow_bash", "expected"), + [ + ("true", "false", True), + ("false", "true", False), + (None, "true", False), + ], +) +async def test_chat_stream_maps_only_web_toggle_to_sandbox_network( + monkeypatch, + allow_web_search, + allow_bash, + expected, +): + captured = {} + endpoint = _chat_stream_endpoint( + monkeypatch, + "agent", + captured, + capture_network=True, + ) + request = _RouteRequest("agent") + request._form["allow_bash"] = allow_bash + if allow_web_search is not None: + request._form["allow_web_search"] = allow_web_search + + response = await endpoint(request) + async for _ in response.body_iterator: + pass + + from src.execution_sandbox import SandboxNetworkProfile + + expected_profile = ( + SandboxNetworkProfile.BROKERED_ONLY + if expected + else SandboxNetworkProfile.NETWORKLESS + ) + assert captured["agent_network_profile"] is expected_profile + + @pytest.mark.asyncio async def test_chat_stream_consumes_exact_tool_approval_for_own_session(monkeypatch): from src.tool_capabilities import capabilities_for_action diff --git a/tests/test_process_execution_mode.py b/tests/test_process_execution_mode.py new file mode 100644 index 000000000..d40789dd8 --- /dev/null +++ b/tests/test_process_execution_mode.py @@ -0,0 +1,275 @@ +"""Process authority, capability, and explicit Full Access regressions.""" + +import asyncio +import importlib + +import pytest + + +def _process_execution(): + return importlib.import_module("src.process_execution") + + +def _subprocess_tools(): + return importlib.import_module("src.agent_tools.subprocess_tools") + + +def _capability(pe, *, sandbox=True, sandbox_broker=True, full=True, full_broker=True): + return pe.ProcessCapability( + pe.ProfileCapability( + sandbox, + "" if sandbox else "sandbox unavailable", + sandbox_broker, + "" if sandbox_broker else "sandbox broker unavailable", + ), + pe.ProfileCapability( + full, + "" if full else "full access unavailable", + full_broker, + "" if full_broker else "full access broker unavailable", + ), + 1.0, + ) + + +def test_invalid_mode_value_fails_safe_to_sandbox(): + pe = _process_execution() + assert ( + pe.process_execution_mode_from_value("unexpected") + is pe.ProcessExecutionMode.SANDBOX + ) + + +def test_full_access_is_temporary_and_retains_network_policy(): + pe = _process_execution() + warning = pe.FULL_ACCESS_WARNING + assert "mounted volumes" in warning + assert "networkless by default" in warning + assert "HTTP(S) broker" in warning + assert "reset to Sandbox" in warning + assert "already-running Full Access process retains" in warning + + +def test_full_access_requires_exact_confirmation_and_resets(): + pe = _process_execution() + pe.reset_process_execution_mode() + + with pytest.raises(ValueError, match="confirmation"): + pe.set_process_execution_mode( + pe.ProcessExecutionMode.FULL_ACCESS, + confirmation="yes", + ) + + pe.set_process_execution_mode( + pe.ProcessExecutionMode.FULL_ACCESS, + confirmation=pe.FULL_ACCESS_CONFIRMATION, + ) + assert pe.configured_process_execution_mode() is pe.ProcessExecutionMode.FULL_ACCESS + + pe.reset_process_execution_mode() + assert pe.configured_process_execution_mode() is pe.ProcessExecutionMode.SANDBOX + + +def test_process_capability_is_cached(monkeypatch): + pe = _process_execution() + calls = [] + status = _capability(pe) + monkeypatch.setattr( + pe, + "_probe_process_capability", + lambda: calls.append(True) or status, + ) + pe.clear_process_capability_cache() + + assert pe.process_capability() is status + assert pe.process_capability() is status + assert calls == [True] + + +def test_capability_distinguishes_modes_and_brokered_profile(monkeypatch, tmp_path): + pe = _process_execution() + calls = [] + monkeypatch.setattr( + "src.constants.AGENT_WORKSPACE_DIR", + str(tmp_path / "agent-workspace"), + ) + + def fake_probe_one_mode(_workspace, *, full_access): + calls.append(full_access) + if full_access: + return pe.ProfileCapability(True, "", False, "full broker unavailable") + return pe.ProfileCapability(True, "", True, "") + + monkeypatch.setattr(pe, "_probe_one_mode", fake_probe_one_mode) + status = pe._probe_process_capability() + + assert status.sandbox.networkless is True + assert status.sandbox.brokered is True + assert status.full_access.networkless is True + assert status.full_access.brokered is False + assert status.full_access.brokered_reason == "full broker unavailable" + assert calls == [False, True] + + +class _FakeProcess: + returncode = 0 + stdout = None + stderr = None + + async def wait(self): + return 0 + + def kill(self): + return None + + +@pytest.mark.asyncio +async def test_bash_blocks_when_sandbox_probe_fails(monkeypatch, tmp_path): + pe = _process_execution() + st = _subprocess_tools() + monkeypatch.setattr( + st, + "configured_process_execution_mode", + lambda: pe.ProcessExecutionMode.SANDBOX, + ) + monkeypatch.setattr( + st, + "process_capability", + lambda: _capability(pe, sandbox=False, sandbox_broker=False), + ) + monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: str(tmp_path)) + + async def unexpected_spawn(*_args, **_kwargs): + raise AssertionError("blocked Sandbox mode must not spawn a process") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", unexpected_spawn) + + result = await st.BashTool().execute("echo blocked", {}) + + assert result["blocked"] is True + assert result["execution_mode"] == "sandbox" + assert "sandbox unavailable" in result["error"] + + +@pytest.mark.asyncio +async def test_brokered_process_blocks_when_only_networkless_probe_passes( + monkeypatch, + tmp_path, +): + pe = _process_execution() + st = _subprocess_tools() + from src.execution_sandbox import SandboxNetworkProfile + + monkeypatch.setattr( + st, + "configured_process_execution_mode", + lambda: pe.ProcessExecutionMode.SANDBOX, + ) + monkeypatch.setattr( + st, + "process_capability", + lambda: _capability(pe, sandbox=True, sandbox_broker=False), + ) + monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: str(tmp_path)) + + result = await st.BashTool().execute( + "echo blocked", + {"network_profile": SandboxNetworkProfile.BROKERED_ONLY}, + ) + + assert result["blocked"] is True + assert "sandbox broker unavailable" in result["error"] + + +@pytest.mark.asyncio +async def test_full_access_bash_is_one_shot_but_retains_brokered_network( + monkeypatch, + tmp_path, +): + pe = _process_execution() + st = _subprocess_tools() + from src.execution_sandbox import SandboxNetworkProfile + + spawned = {} + monkeypatch.setattr( + st, + "configured_process_execution_mode", + lambda: pe.ProcessExecutionMode.FULL_ACCESS, + ) + monkeypatch.setattr(st, "process_capability", lambda: _capability(pe)) + monkeypatch.setattr( + st, + "full_access_command", + lambda argv, **_kwargs: ["/trusted/bwrap-full", *argv], + ) + monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: str(tmp_path)) + monkeypatch.setattr(st.shutil, "which", lambda _name: "/usr/bin/tmux") + + async def fake_spawn(*argv, **kwargs): + spawned["argv"] = argv + spawned["kwargs"] = kwargs + return _FakeProcess() + + async def fake_stream(*_args, **_kwargs): + return "ok", "", 0, False + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_spawn) + monkeypatch.setattr(st, "_run_subprocess_streaming", fake_stream) + monkeypatch.setattr( + st, + "_run_tmux_bash", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("Full Access must not create a persistent tmux shell") + ), + ) + + result = await st.BashTool().execute( + "echo ok", + { + "session_id": "chat-1", + "network_profile": SandboxNetworkProfile.BROKERED_ONLY, + }, + ) + + assert spawned["argv"][:2] == ("/trusted/bwrap-full", "/bin/bash") + assert spawned["kwargs"]["env"] == {} + assert result["execution_mode"] == "full_access" + assert result["network_enforcement"] == "brokered_http_https" + assert result["warning"] == pe.FULL_ACCESS_WARNING + + +@pytest.mark.asyncio +async def test_unverifiable_preexisting_tmux_session_is_recreated(monkeypatch, tmp_path): + st = _subprocess_tools() + name = "ody-agent-sbx-v2-chat-workspace-network-policy" + killed = [] + created = [] + has_session_results = iter([True, True]) + st._TMUX_OWNED_SESSIONS.clear() + + async def fake_has_session(_name): + return next(has_session_results) + + async def fake_kill(session_name): + killed.append(session_name) + st._TMUX_OWNED_SESSIONS.discard(session_name) + + async def fake_run(*args, **_kwargs): + if args[:2] == ("tmux", "new-session"): + created.append(args) + return "", "", 0 + + monkeypatch.setattr(st, "_tmux_has_session", fake_has_session) + monkeypatch.setattr(st, "_tmux_kill_session", fake_kill) + monkeypatch.setattr(st, "_run_exec", fake_run) + monkeypatch.setattr(st.os.path, "isfile", lambda _path: True) + + await st._ensure_tmux_session( + name, + str(tmp_path), + ["/trusted/sandbox-shell"], + ) + + assert killed == [name] + assert created + assert name in st._TMUX_OWNED_SESSIONS diff --git a/tests/test_process_execution_routes.py b/tests/test_process_execution_routes.py new file mode 100644 index 000000000..b04629fa3 --- /dev/null +++ b/tests/test_process_execution_routes.py @@ -0,0 +1,129 @@ +"""Admin-only transient Full Access confirmation routes.""" + +import asyncio +from types import SimpleNamespace + +import pytest + +from src import process_execution + + +class _AuthManager: + def get_username_for_token(self, token): + return "admin" if token == "session-token" else None + + def is_admin(self, user): + return user == "admin" + + +class _Request(SimpleNamespace): + def __init__(self, *, token="session-token"): + super().__init__(cookies={"odysseus_session": token}) + + +def _endpoint(router, path, method): + for route in router.routes: + if ( + getattr(route, "path", "") == path + and method in getattr(route, "methods", set()) + ): + return route.endpoint + raise AssertionError(f"{method} {path} route not registered") + + +@pytest.fixture +def process_routes(monkeypatch): + from routes import auth_routes + + monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None) + process_execution.reset_process_execution_mode() + capability = process_execution.ProcessCapability( + process_execution.ProfileCapability(True, "", True, ""), + process_execution.ProfileCapability(True, "", True, ""), + 1.0, + ) + monkeypatch.setattr( + process_execution, + "process_capability", + lambda **_kwargs: capability, + ) + router = auth_routes.setup_auth_routes(_AuthManager()) + yield router, auth_routes + process_execution.reset_process_execution_mode() + + +def test_full_access_requires_exact_typed_confirmation(process_routes): + router, auth_routes = process_routes + handler = _endpoint(router, "/api/auth/process-execution", "POST") + + with pytest.raises(auth_routes.HTTPException) as exc: + asyncio.run( + handler( + auth_routes.SetProcessExecutionModeRequest( + mode="full_access", + confirmation="yes", + ), + _Request(), + ) + ) + + assert exc.value.status_code == 400 + assert ( + process_execution.configured_process_execution_mode() + is process_execution.ProcessExecutionMode.SANDBOX + ) + + +def test_explicit_confirmation_enables_transient_full_access(process_routes): + router, auth_routes = process_routes + handler = _endpoint(router, "/api/auth/process-execution", "POST") + + result = asyncio.run( + handler( + auth_routes.SetProcessExecutionModeRequest( + mode="full_access", + confirmation=process_execution.FULL_ACCESS_CONFIRMATION, + ), + _Request(), + ) + ) + + assert result["mode"] == "full_access" + assert result["transient"] is True + assert result["mode_available"] is True + assert ( + process_execution.configured_process_execution_mode() + is process_execution.ProcessExecutionMode.FULL_ACCESS + ) + + +def test_sandbox_switch_does_not_require_confirmation(process_routes): + router, auth_routes = process_routes + handler = _endpoint(router, "/api/auth/process-execution", "POST") + process_execution.set_process_execution_mode( + process_execution.ProcessExecutionMode.FULL_ACCESS, + confirmation=process_execution.FULL_ACCESS_CONFIRMATION, + ) + + result = asyncio.run( + handler( + auth_routes.SetProcessExecutionModeRequest(mode="sandbox"), + _Request(), + ) + ) + + assert result["mode"] == "sandbox" + assert ( + process_execution.configured_process_execution_mode() + is process_execution.ProcessExecutionMode.SANDBOX + ) + + +def test_process_execution_routes_are_admin_only(process_routes): + router, auth_routes = process_routes + handler = _endpoint(router, "/api/auth/process-execution", "GET") + + with pytest.raises(auth_routes.HTTPException) as exc: + asyncio.run(handler(_Request(token="invalid"))) + + assert exc.value.status_code == 403 diff --git a/tests/test_process_sandbox_dispatch.py b/tests/test_process_sandbox_dispatch.py new file mode 100644 index 000000000..c6d590e23 --- /dev/null +++ b/tests/test_process_sandbox_dispatch.py @@ -0,0 +1,152 @@ +"""Regression coverage for native process-tool dispatch.""" + +from types import SimpleNamespace + +import pytest + + +class _FailingMcpManager: + def __init__(self): + self.calls = [] + + async def call_tool(self, name, args): + self.calls.append((name, args)) + raise AssertionError("process tools must not reach MCP") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tool_name", ["bash", "python"]) +async def test_process_tools_use_native_handler_context(monkeypatch, tool_name): + import src.agent_tools as agent_tools + import src.tool_execution as tool_execution + + manager = _FailingMcpManager() + seen = {} + + async def fake_handler(content, ctx): + seen["content"] = content + seen["ctx"] = ctx + return {"output": "native", "exit_code": 0} + + progress_cb = object() + network_profile = tool_execution.SandboxNetworkProfile.BROKERED_ONLY + monkeypatch.setattr(tool_execution, "get_mcp_manager", lambda: manager) + monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True) + monkeypatch.setattr(tool_execution, "is_public_blocked_tool", lambda _tool: False) + monkeypatch.setitem(agent_tools.TOOL_HANDLERS, tool_name, fake_handler) + + _, result = await tool_execution.execute_tool_block( + SimpleNamespace(tool_type=tool_name, content="printf native"), + session_id="chat-5818", + owner="alice", + progress_cb=progress_cb, + network_profile=network_profile, + security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT, + ) + + assert result == {"output": "native", "exit_code": 0} + assert seen == { + "content": "printf native", + "ctx": { + "progress_cb": progress_cb, + "session_id": "chat-5818", + "owner": "alice", + "network_profile": network_profile, + }, + } + assert manager.calls == [] + + +def test_process_tools_are_not_mcp_registered(): + import src.tool_execution as tool_execution + + assert tool_execution._PROCESS_TOOLS == frozenset({"bash", "python"}) + assert "bash" not in tool_execution._MCP_TOOL_MAP + assert "python" not in tool_execution._MCP_TOOL_MAP + assert "bash" not in tool_execution._MCP_ARG_PARSERS + assert "python" not in tool_execution._MCP_ARG_PARSERS + + +@pytest.mark.asyncio +async def test_foreground_bash_with_session_reaches_tmux(monkeypatch, tmp_path): + import src.agent_tools as agent_tools + import src.agent_tools.subprocess_tools as subprocess_tools + import src.tool_execution as tool_execution + + seen = {} + + async def fake_run_tmux_bash(content, **kwargs): + seen["content"] = content + seen["kwargs"] = kwargs + return "tmux output", "", 0, False, "ody-agent-sbx-v2-test" + + monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda _owner: True) + monkeypatch.setattr(tool_execution, "is_public_blocked_tool", lambda _tool: False) + monkeypatch.setattr(subprocess_tools.shutil, "which", lambda _name: "/usr/bin/tmux") + monkeypatch.setattr(subprocess_tools, "_run_tmux_bash", fake_run_tmux_bash) + monkeypatch.setitem( + agent_tools.TOOL_HANDLERS, + "bash", + subprocess_tools.BashTool().execute, + ) + + workspace = tmp_path / "workspace" + workspace.mkdir() + _, result = await tool_execution.execute_tool_block( + SimpleNamespace(tool_type="bash", content="printf tmux"), + session_id="chat-5818", + owner="alice", + workspace=str(workspace), + security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT, + ) + + assert result["exit_code"] == 0 + assert result["output"] == "tmux output" + assert result["tmux_session"] == "ody-agent-sbx-v2-test" + assert seen == { + "content": "printf tmux", + "kwargs": { + "session_id": "chat-5818", + "cwd": str(workspace), + "timeout": subprocess_tools.DEFAULT_BASH_TIMEOUT, + "progress_cb": None, + "network_profile": tool_execution.SandboxNetworkProfile.NETWORKLESS, + }, + } + + +@pytest.mark.asyncio +async def test_background_bash_launches_before_foreground_dispatch(monkeypatch): + import src.bg_jobs as bg_jobs + import src.tool_execution as tool_execution + + seen = {} + + def fake_launch(command, **kwargs): + seen["launch"] = (command, kwargs) + return {"id": "job-5818"} + + async def forbidden_fallback(*_args, **_kwargs): + raise AssertionError("background bash must return before foreground dispatch") + + monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda _owner: True) + monkeypatch.setattr(bg_jobs, "launch", fake_launch) + monkeypatch.setattr(tool_execution, "_direct_fallback", forbidden_fallback) + + _, result = await tool_execution.execute_tool_block( + SimpleNamespace(tool_type="bash", content="#!bg\nprintf background"), + session_id="chat-5818", + owner="alice", + workspace="/tmp/workspace", + security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT, + ) + + assert result["bg_job_id"] == "job-5818" + assert seen["launch"] == ( + "printf background", + { + "session_id": "chat-5818", + "cwd": "/tmp/workspace", + "network_profile": tool_execution.SandboxNetworkProfile.NETWORKLESS, + }, + ) diff --git a/tests/test_sandbox_network_policy.py b/tests/test_sandbox_network_policy.py new file mode 100644 index 000000000..9041f2973 --- /dev/null +++ b/tests/test_sandbox_network_policy.py @@ -0,0 +1,292 @@ +"""Propagation tests for the per-turn Bubblewrap network policy.""" + +import asyncio +import json +from types import SimpleNamespace + +import pytest + +import src.agent_loop as agent_loop +import src.tool_execution as tool_execution +from src.agent_tools import ToolBlock +from src.execution_sandbox import ( + SandboxNetworkProfile, + network_profile_from_snapshot, +) +from src.tool_execution import NO_TOOL_SECURITY_CONTEXT + + +def _collect(gen): + async def _run(): + return [chunk async for chunk in gen] + + return asyncio.run(_run()) + + +@pytest.mark.asyncio +async def test_tool_executor_forwards_network_policy_to_subprocess_fallback(monkeypatch): + seen = [] + + async def fake_direct_fallback(tool, content, **kwargs): + seen.append((tool, content, kwargs.get("network_profile"))) + return {"output": "ok", "exit_code": 0} + + monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True) + monkeypatch.setattr(tool_execution, "get_mcp_manager", lambda: None) + monkeypatch.setattr(tool_execution, "_direct_fallback", fake_direct_fallback) + + _, result = await tool_execution.execute_tool_block( + ToolBlock("bash", "printf ok"), + owner="admin", + network_profile=SandboxNetworkProfile.BROKERED_ONLY, + security_context=NO_TOOL_SECURITY_CONTEXT, + ) + + assert result["exit_code"] == 0 + assert seen == [ + ("bash", "printf ok", SandboxNetworkProfile.BROKERED_ONLY) + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tool_name", ["bash", "python"]) +async def test_subprocess_handlers_apply_network_policy(monkeypatch, tool_name): + import src.agent_tools.subprocess_tools as subprocess_tools + + sandbox_calls = [] + + def fake_sandbox_command(command, **kwargs): + sandbox_calls.append((command, kwargs)) + return ["/usr/bin/true"] + + async def fake_create_subprocess_exec(*args, **kwargs): + return object() + + async def fake_run_subprocess_streaming(*args, **kwargs): + return "", "", 0, False + + monkeypatch.setattr(subprocess_tools, "sandbox_command", fake_sandbox_command) + monkeypatch.setattr( + subprocess_tools.asyncio, + "create_subprocess_exec", + fake_create_subprocess_exec, + ) + monkeypatch.setattr( + subprocess_tools, + "_run_subprocess_streaming", + fake_run_subprocess_streaming, + ) + + handler = ( + subprocess_tools.BashTool() + if tool_name == "bash" + else subprocess_tools.PythonTool() + ) + result = await handler.execute( + "printf ok", + {"network_profile": SandboxNetworkProfile.BROKERED_ONLY}, + ) + + assert result["exit_code"] == 0 + assert ( + sandbox_calls[0][1]["network_profile"] + is SandboxNetworkProfile.BROKERED_ONLY + ) + + +@pytest.mark.asyncio +async def test_background_bash_inherits_network_policy(monkeypatch): + seen = [] + + def fake_launch(command, **kwargs): + seen.append((command, kwargs)) + return {"id": "job-1"} + + from src import bg_jobs + + monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True) + monkeypatch.setattr(bg_jobs, "launch", fake_launch) + + _, result = await tool_execution.execute_tool_block( + ToolBlock("bash", "#!bg\nprintf networked"), + session_id="session-1", + owner="admin", + workspace="/tmp/workspace", + network_profile=SandboxNetworkProfile.BROKERED_ONLY, + security_context=NO_TOOL_SECURITY_CONTEXT, + ) + + assert result["bg_job_id"] == "job-1" + assert seen == [ + ( + "printf networked", + { + "session_id": "session-1", + "cwd": "/tmp/workspace", + "network_profile": SandboxNetworkProfile.BROKERED_ONLY, + }, + ) + ] + + +def test_agent_loop_forwards_network_policy_to_every_tool_call(monkeypatch): + calls = [] + round_number = 0 + + monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default) + monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None) + monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10) + monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set()) + + async def fake_stream(_candidates, messages, **kwargs): + nonlocal round_number + round_number += 1 + if round_number == 1: + call = { + "name": "bash", + "arguments": json.dumps({"command": "printf ok"}), + } + yield "data: " + json.dumps({"type": "tool_calls", "calls": [call]}) + "\n\n" + else: + yield "data: " + json.dumps({"delta": "done"}) + "\n\n" + yield "data: [DONE]\n\n" + + async def fake_execute(block, **kwargs): + calls.append((block.tool_type, kwargs.get("network_profile"))) + return "bash", {"output": "ok", "exit_code": 0} + + monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream) + monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute) + + _collect( + agent_loop.stream_agent_loop( + "https://model.example/v1", + "test-model", + [{"role": "user", "content": "Run the command."}], + owner="admin", + max_rounds=2, + relevant_tools={"bash"}, + network_profile=SandboxNetworkProfile.BROKERED_ONLY, + _is_teacher_run=True, + ) + ) + + assert calls == [("bash", SandboxNetworkProfile.BROKERED_ONLY)] + + +def test_background_followup_preserves_originating_network_policy(monkeypatch): + from src import bg_monitor + + seen = [] + + async def fake_stream_agent_loop(*args, **kwargs): + seen.append(kwargs.get("network_profile")) + yield "data: [DONE]" + + monkeypatch.setattr(agent_loop, "stream_agent_loop", fake_stream_agent_loop) + session = SimpleNamespace( + endpoint_url="https://model.example/v1", + model="test-model", + headers=None, + context_length=0, + id="session-1", + owner="admin", + ) + + asyncio.run( + bg_monitor._drain_agent( + session, + [], + network_profile=SandboxNetworkProfile.BROKERED_ONLY, + ) + ) + + assert seen == [SandboxNetworkProfile.BROKERED_ONLY] + + +def test_invalid_persisted_profile_fails_back_to_networkless(): + assert ( + network_profile_from_snapshot("open") + is SandboxNetworkProfile.NETWORKLESS + ) + + +def test_network_profile_has_no_raw_open_mode(): + assert {profile.value for profile in SandboxNetworkProfile} == { + "networkless", + "brokered_only", + } + + +def test_launch_snapshot_does_not_follow_a_later_toggle_change(): + launched_record = { + "network_profile": SandboxNetworkProfile.BROKERED_ONLY.value, + } + current_selection = SandboxNetworkProfile.NETWORKLESS + + assert current_selection is SandboxNetworkProfile.NETWORKLESS + assert ( + network_profile_from_snapshot(launched_record["network_profile"]) + is SandboxNetworkProfile.BROKERED_ONLY + ) + + +@pytest.mark.asyncio +async def test_tmux_launch_scrubs_the_server_environment(monkeypatch): + import src.agent_tools.subprocess_tools as subprocess_tools + + session_checks = iter((False, True)) + calls = [] + + async def fake_has_session(_name): + return next(session_checks) + + async def fake_run_exec(*args, **kwargs): + calls.append((args, kwargs)) + return "", "", 0 + + monkeypatch.setattr(subprocess_tools, "_tmux_has_session", fake_has_session) + monkeypatch.setattr(subprocess_tools, "_run_exec", fake_run_exec) + shell_argv = [ + "/usr/local/libexec/odysseus-seccomp-launcher", + "/usr/bin/bwrap", + "--", + "/bin/bash", + ] + + await subprocess_tools._ensure_tmux_session( + "session", + "/workspace", + shell_argv, + ) + + launch = calls[0][0] + scrubber = launch.index("/usr/bin/env") + assert launch[scrubber:scrubber + 2] == ("/usr/bin/env", "-i") + assert launch[scrubber + 2:] == tuple(shell_argv) + + +@pytest.mark.asyncio +async def test_tmux_client_does_not_update_server_from_application_env(monkeypatch): + import src.agent_tools.subprocess_tools as subprocess_tools + + captured = {} + + class Process: + returncode = 0 + + async def communicate(self): + return b"", b"" + + async def fake_create_subprocess_exec(*args, **kwargs): + captured.update(kwargs) + return Process() + + monkeypatch.setattr( + subprocess_tools.asyncio, + "create_subprocess_exec", + fake_create_subprocess_exec, + ) + + assert await subprocess_tools._run_exec("tmux", "-V") == ("", "", 0) + assert captured["env"] == {} diff --git a/tests/test_seccomp_generator.py b/tests/test_seccomp_generator.py new file mode 100644 index 000000000..c44daa80d --- /dev/null +++ b/tests/test_seccomp_generator.py @@ -0,0 +1,82 @@ +"""Unit tests for seccomp policy architecture verification.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +GENERATOR_PATH = ROOT / "security" / "seccomp" / "generate.py" +NEWER_ALLOWLIST_SYSCALLS = { + "getxattrat", + "listmount", + "listxattrat", + "mseal", + "removexattrat", + "riscv_hwprobe", + "setxattrat", + "statmount", + "uretprobe", +} + + +def _load_generator() -> ModuleType: + spec = importlib.util.spec_from_file_location("seccomp_generator", GENERATOR_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class _FakeLibseccomp: + def __init__(self, unsupported: set[str]) -> None: + self.unsupported = unsupported + + def seccomp_arch_resolve_name(self, name: bytes) -> int: + return 1 if name in {b"x86_64", b"aarch64"} else 0 + + def seccomp_syscall_resolve_name_arch(self, token: int, name: bytes) -> int: + assert token == 1 + return -1 if name.decode("ascii") in self.unsupported else 1 + + +def _policy(generator: ModuleType) -> dict: + return generator._load_json(generator.POLICY) + + +def test_arch_verification_allows_newer_pinned_allowlist_names(monkeypatch): + generator = _load_generator() + policy = _policy(generator) + source = generator._load_json(generator.SOURCE) + for moby_arch in policy["target_arches"].values(): + assert NEWER_ALLOWLIST_SYSCALLS <= set( + generator._allowlist_for_arch(source, policy, moby_arch) + ) + monkeypatch.setattr( + generator, + "_load_libseccomp", + lambda: _FakeLibseccomp(NEWER_ALLOWLIST_SYSCALLS), + ) + + generator._verify_arches(policy) + + +def test_arch_verification_rejects_missing_required_syscalls(monkeypatch): + generator = _load_generator() + policy = _policy(generator) + monkeypatch.setattr( + generator, + "_load_libseccomp", + lambda: _FakeLibseccomp({"clone3", "socketpair"}), + ) + + with pytest.raises( + RuntimeError, + match=r"libseccomp cannot resolve x86_64 syscalls: clone3, socketpair", + ): + generator._verify_arches(policy) diff --git a/tests/test_seccomp_launcher.py b/tests/test_seccomp_launcher.py new file mode 100644 index 000000000..fa978497a --- /dev/null +++ b/tests/test_seccomp_launcher.py @@ -0,0 +1,249 @@ +"""Focused tests for the fixed-purpose native seccomp launcher.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SECCOMP_DIR = ROOT / "security" / "seccomp" +BWRAP = Path("/usr/bin/bwrap") + + +@pytest.fixture(scope="module") +def test_launcher(tmp_path_factory): + if shutil.which("make") is None: + pytest.skip("make is required for launcher tests") + build_dir = tmp_path_factory.mktemp("launcher-fault-tests") + completed = subprocess.run( + [ + "make", + "-C", + str(SECCOMP_DIR), + f"BUILD_DIR={build_dir}", + "test-launcher", + ], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert completed.returncode == 0, completed.stderr + return build_dir / "odysseus-seccomp-launcher-test" + + +@pytest.fixture(scope="module") +def seccomp_probe(tmp_path_factory): + if shutil.which("cc") is None: + pytest.skip("a C compiler is required for seccomp probe tests") + build_dir = tmp_path_factory.mktemp("seccomp-probe") + probe = build_dir / "seccomp-probe" + completed = subprocess.run( + [ + "cc", + "-std=c11", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + str(ROOT / "tests" / "seccomp_probe.c"), + "-o", + str(probe), + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert completed.returncode == 0, completed.stderr + return probe + + +def _run(launcher: Path, *, stage: str | None = None, arguments=None): + environment = {} + if stage is not None: + environment["ODYSSEUS_TEST_FAIL"] = stage + bwrap_arguments = [ + str(launcher), + str(BWRAP), + "--ro-bind", + "/usr", + "/usr", + "--symlink", + "usr/bin", + "/bin", + "--symlink", + "usr/lib", + "/lib", + ] + if Path("/usr/lib64").exists(): + bwrap_arguments.extend(["--symlink", "usr/lib64", "/lib64"]) + command = [*bwrap_arguments, "--", "/bin/true"] + if arguments is not None: + command = [str(launcher), *arguments] + return subprocess.run( + command, + env=environment, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + + +def test_launcher_rejects_wrong_bubblewrap_path(test_launcher): + completed = _run( + test_launcher, + arguments=["/bin/true", "--", "/bin/true"], + ) + + assert completed.returncode == 64 + assert completed.stderr.strip().endswith("invalid trusted Bubblewrap path") + + +@pytest.mark.skipif(not BWRAP.is_file(), reason="canonical Bubblewrap is unavailable") +@pytest.mark.parametrize( + "option", + ["--seccomp", "--add-seccomp-fd", "--args"], +) +def test_launcher_rejects_caller_seccomp_options(test_launcher, option): + completed = _run( + test_launcher, + arguments=[str(BWRAP), option, "9", "--", "/bin/true"], + ) + + assert completed.returncode == 65 + assert completed.stderr.strip().endswith("invalid Bubblewrap arguments") + + +@pytest.mark.skipif(not BWRAP.is_file(), reason="canonical Bubblewrap is unavailable") +@pytest.mark.parametrize( + ("stage", "exit_code", "message"), + [ + ("libseccomp", 66, "libseccomp is unavailable or incompatible"), + ("filter", 67, "inner seccomp filter creation failed"), + ("memfd", 68, "anonymous filter storage creation failed"), + ("export", 69, "inner seccomp filter export failed"), + ("seal", 70, "inner seccomp filter sealing failed"), + ("exec", 71, "trusted Bubblewrap execution failed"), + ], +) +def test_launcher_failure_stages_are_distinct_and_concise( + test_launcher, + stage, + exit_code, + message, +): + completed = _run(test_launcher, stage=stage) + + assert completed.returncode == exit_code + assert completed.stdout == "" + assert completed.stderr.strip() == f"odysseus-seccomp-launcher: {message}" + + +@pytest.mark.skipif(not BWRAP.is_file(), reason="canonical Bubblewrap is unavailable") +def test_filter_fd_is_sealed_anonymous_memfd(test_launcher): + completed = _run(test_launcher, stage="inspect") + + assert completed.returncode == 0, completed.stderr + assert completed.stdout == "" + assert completed.stderr == "" + + +@pytest.mark.skipif(not BWRAP.is_file(), reason="canonical Bubblewrap is unavailable") +def test_launcher_injects_filter_and_executes_payload(test_launcher): + completed = _run(test_launcher) + + assert completed.returncode == 0, completed.stderr + assert completed.stdout == "" + assert completed.stderr == "" + + +@pytest.mark.skipif(not BWRAP.is_file(), reason="canonical Bubblewrap is unavailable") +@pytest.mark.parametrize("probe_name", ["tiocsti", "tiocsti_high_bits"]) +def test_tiocsti_low_bits_are_denied_after_filter_load( + test_launcher, + seccomp_probe, + probe_name, +): + arguments = [ + str(BWRAP), + "--ro-bind", + "/usr", + "/usr", + "--symlink", + "usr/bin", + "/bin", + "--symlink", + "usr/lib", + "/lib", + ] + if Path("/usr/lib64").exists(): + arguments.extend(["--symlink", "usr/lib64", "/lib64"]) + arguments.extend( + [ + "--ro-bind", + str(seccomp_probe), + "/seccomp-probe", + "--", + "/seccomp-probe", + probe_name, + ] + ) + completed = _run( + test_launcher, + arguments=arguments, + ) + + assert completed.returncode == 0, completed.stderr + + +@pytest.mark.skipif(not BWRAP.is_file(), reason="canonical Bubblewrap is unavailable") +def test_exec_failure_does_not_print_model_command_or_environment(test_launcher): + secret = "model-command-must-not-be-logged" + completed = subprocess.run( + [str(test_launcher), str(BWRAP), "--", "/bin/echo", secret], + env={"ODYSSEUS_TEST_FAIL": "exec", "API_TOKEN": secret}, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + + assert completed.returncode == 71 + assert secret not in completed.stdout + assert secret not in completed.stderr + + +def test_launcher_elf_has_expected_hardening(test_launcher): + if shutil.which("readelf") is None: + pytest.skip("readelf is required for ELF hardening assertions") + header = subprocess.run( + ["readelf", "-h", str(test_launcher)], + capture_output=True, + text=True, + check=True, + ).stdout + program = subprocess.run( + ["readelf", "-W", "-l", str(test_launcher)], + capture_output=True, + text=True, + check=True, + ).stdout + dynamic = subprocess.run( + ["readelf", "-d", str(test_launcher)], + capture_output=True, + text=True, + check=True, + ).stdout + + assert "DYN (Position-Independent Executable file)" in header + assert "GNU_RELRO" in program + assert "GNU_STACK" in program + assert "BIND_NOW" in dynamic + assert os.access(test_launcher, os.X_OK) diff --git a/tests/test_seccomp_policy.py b/tests/test_seccomp_policy.py new file mode 100644 index 000000000..761b6a3a4 --- /dev/null +++ b/tests/test_seccomp_policy.py @@ -0,0 +1,192 @@ +"""Determinism and deployment invariants for the two seccomp layers.""" + +from __future__ import annotations + +import copy +import json +import re +import subprocess +from pathlib import Path + +import pytest +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +SECCOMP_DIR = ROOT / "security" / "seccomp" +POLICY = json.loads((SECCOMP_DIR / "policy.json").read_text(encoding="utf-8")) +MOBY = json.loads((SECCOMP_DIR / "moby-default.json").read_text(encoding="utf-8")) +OUTER = json.loads( + (ROOT / "docker" / "seccomp" / "odysseus-bubblewrap.json").read_text( + encoding="utf-8" + ) +) + + +def test_generated_policy_matches_pinned_source_for_both_architectures(): + completed = subprocess.run( + ["python", "generate.py", "--check", "--verify-arches"], + cwd=SECCOMP_DIR, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + assert POLICY["moby"] == { + "repository": "https://github.com/moby/moby", + "commit": "35797366d7cdae8d1d84eac06fbb314ccaf3ccaf", + "path": "vendor/github.com/moby/profiles/seccomp/default.json", + "upstream_sha256": "536529b665dd0972c37bfb569f5d4ac8a53592e7b00752bc39ff063ca9864c74", + } + assert POLICY["target_arches"] == {"x86_64": "amd64", "aarch64": "arm64"} + + +def test_outer_profile_is_exact_moby_profile_plus_bwrap_bootstrap_rules(): + expected = copy.deepcopy(MOBY) + expected["syscalls"].extend(OUTER["syscalls"][-2:]) + + assert OUTER == expected + clone_rule, mount_rule = OUTER["syscalls"][-2:] + assert clone_rule == { + "names": ["clone"], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2114060305, + "op": "SCMP_CMP_EQ", + } + ], + "comment": "Odysseus trusted Bubblewrap namespace bootstrap only", + } + assert mount_rule["names"] == ["mount", "pivot_root", "umount2"] + assert mount_rule["action"] == "SCMP_ACT_ALLOW" + + +def test_inner_policy_records_required_default_deny_and_conditional_rules(): + assert POLICY["default_errno"] == "EPERM" + assert POLICY["clone3_errno"] == "ENOSYS" + assert POLICY["tiocsti_errno"] == "EACCES" + assert POLICY["clone_namespace_mask"] == 2114060288 + assert POLICY["socket_families"] == ["AF_UNIX", "AF_INET", "AF_INET6"] + for denied in ( + "bpf", + "perf_event_open", + "mount", + "umount2", + "pivot_root", + "unshare", + "setns", + "ptrace", + "process_vm_readv", + "process_vm_writev", + "keyctl", + "open_by_handle_at", + "userfaultfd", + "io_uring_setup", + ): + assert denied in POLICY["denied_syscalls"] + + +def test_tiocsti_allow_rule_rejects_truncation_bypass_values(): + launcher = (SECCOMP_DIR / "odysseus-seccomp-launcher.c").read_text( + encoding="utf-8" + ) + + assert "SCMP_CMP_MASKED_EQ" in launcher + assert ".datum_a = UINT32_MAX" in launcher + assert ".datum_b = TIOCSTI" in launcher + assert "SCMP_ACT_ERRNO(EACCES)" in launcher + + +@pytest.mark.parametrize( + "compose_path", + [ + "docker-compose.yml", + "docker-compose.gpu-nvidia.yml", + "docker-compose.gpu-amd.yml", + ], +) +def test_compose_applies_outer_profile_only_to_odysseus(compose_path): + compose = yaml.safe_load((ROOT / compose_path).read_text(encoding="utf-8")) + expected = ["seccomp=./docker/seccomp/odysseus-bubblewrap.json"] + + assert compose["services"]["odysseus"]["security_opt"] == expected + for name, service in compose["services"].items(): + if name != "odysseus": + assert "security_opt" not in service + + +def test_deployment_never_uses_privileged_sys_admin_or_unconfined_seccomp(): + compose_text = "\n".join( + (ROOT / name).read_text(encoding="utf-8") + for name in ( + "docker-compose.yml", + "docker-compose.gpu-nvidia.yml", + "docker-compose.gpu-amd.yml", + ) + ) + sandbox_source = (ROOT / "src" / "execution_sandbox.py").read_text( + encoding="utf-8" + ) + + assert "privileged:" not in compose_text + assert "SYS_ADMIN" not in compose_text + assert "seccomp=unconfined" not in compose_text + assert '"--share-net"' not in sandbox_source + + +def test_docker_image_installs_root_owned_launcher_and_libseccomp(): + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + + assert "libseccomp2" in dockerfile + assert "make -C security/seccomp install" in dockerfile + assert "/usr/local/libexec" in (SECCOMP_DIR / "Makefile").read_text( + encoding="utf-8" + ) + launcher = (SECCOMP_DIR / "odysseus-seccomp-launcher.c").read_text( + encoding="utf-8" + ) + assert "S_ISUID | S_ISGID" in launcher + + +def test_docker_image_pins_the_traced_bubblewrap_version(): + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + version = re.search( + r"^\s*&& BWRAP_POLICY_VERSION=(.+) \\$", + dockerfile, + re.MULTILINE, + ) + + assert version is not None + expected_version = POLICY["outer_bubblewrap"][ + "bubblewrap_version_basis" + ].removeprefix("v") + assert version.group(1) == expected_version + package = re.search( + r"^\s*&& BWRAP_POLICY_PACKAGE=(.+) \\$", + dockerfile, + re.MULTILINE, + ) + assert package is not None + assert package.group(1) == POLICY["outer_bubblewrap"]["shipped_package_basis"] + assert "ARG BUBBLEWRAP_VERSION" not in dockerfile + assert 'BWRAP_ACTUAL="$(bwrap --version)"' in dockerfile + assert '"bubblewrap ${BWRAP_POLICY_VERSION}"' in dockerfile + assert "dpkg-query -W -f='${Version}' bubblewrap" in dockerfile + + +def test_bubblewrap_provenance_records_the_release_commit_not_only_the_tag(): + assert POLICY["outer_bubblewrap"] == { + "clone_flags": 2114060305, + "bootstrap_syscalls": ["mount", "pivot_root", "umount2"], + "bubblewrap_version_basis": "v0.11.0", + "bubblewrap_tag_object": "a871b148b7bc0571f50b917cd5fd03b427f54ed1", + "bubblewrap_commit": "9ca3b05ec787acfb4b17bed37db5719fa777834f", + "bubblewrap_release_sha256": ( + "988fd6b232dafa04b8b8198723efeaccdb3c6aa9c1c7936219d5791a8b7a8646" + ), + "shipped_package_basis": "0.11.0-2+deb13u1", + } diff --git a/tests/test_workspace_confine.py b/tests/test_workspace_confine.py index 701c6c5de..e3b38ca1e 100644 --- a/tests/test_workspace_confine.py +++ b/tests/test_workspace_confine.py @@ -12,6 +12,7 @@ """ import json import os +import shutil import tempfile from types import SimpleNamespace @@ -267,6 +268,11 @@ async def test_glob_skips_sensitive_files_in_workspace(ws, admin): @pytest.mark.asyncio +@pytest.mark.skipif( + shutil.which("bwrap") is None + or not os.path.isfile("/usr/local/libexec/odysseus-seccomp-launcher"), + reason="the shipped Bubblewrap and trusted launcher are required", +) async def test_subprocess_cwd_is_workspace_e2e(ws, admin): """python tool runs with cwd = workspace (OS-agnostic probe).""" _, r = await execute_tool_block(_block("python", "import os; print(os.getcwd())"), owner="a", workspace=ws)