From 17c5433545f79b66ee6ccc7aae0c4991885bbca4 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:21:57 +0000 Subject: [PATCH 01/18] fix(agent): sandbox process execution Route foreground Bash, Python, tmux sessions, and detached jobs through one positive-mount bubblewrap profile. Clear inherited environment and network access, protect credentials and repository metadata, hide Odysseus data roots, and apply bounded resources while preserving one writable workspace. --- Dockerfile | 2 + THREAT_MODEL.md | 2 +- src/agent_tools/subprocess_tools.py | 82 ++++--- src/bg_jobs.py | 103 +++++---- src/constants.py | 2 + src/execution_sandbox.py | 334 ++++++++++++++++++++++++++++ src/tool_execution.py | 44 ++-- tests/test_execution_sandbox.py | 264 ++++++++++++++++++++++ 8 files changed, 733 insertions(+), 100 deletions(-) create mode 100644 src/execution_sandbox.py create mode 100644 tests/test_execution_sandbox.py diff --git a/Dockerfile b/Dockerfile index 3732d20a6c..545de93989 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ nodejs \ npm \ chromium \ + bubblewrap \ + util-linux \ tmux \ openssh-client \ gosu \ diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index ee656087cb..33928db01f 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.** Agent `bash`, Python, tmux, and detached background commands run through a networkless bubblewrap profile with a cleared environment, private temp/home, a single writable workspace, credential-path overlays, read-only `.git` metadata, resource limits, and explicit hiding of Odysseus data/log roots even when they sit below a broader selected workspace. The Docker image includes bubblewrap. Sandboxed process execution fails closed when that profile is unavailable; a portable equivalent for non-Linux hosts is not implemented yet. The sandbox intentionally omits `/proc`, so commands that require process inspection degrade rather than gaining access to the app process namespace. 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/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index 1c407b1121..500f3853f8 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -1,13 +1,18 @@ import asyncio +import hashlib import os import re import shutil -import sys import time import collections 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 ( + environment_for_sandbox_launcher, + sandbox_command, + sandbox_python_executable, +) DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour DEFAULT_PYTHON_TIMEOUT = 60 * 60 @@ -38,9 +43,12 @@ 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_name(session_id: Optional[str], workspace: str = "") -> str: raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-") - return f"ody-agent-{raw[:80] or 'default'}" + workspace_key = hashlib.sha256( + os.path.realpath(workspace or ".").encode("utf-8", errors="replace") + ).hexdigest()[:10] + return f"ody-agent-sbx-v1-{raw[:60] or 'default'}-{workspace_key}" async def _run_exec(*args: str, timeout: float = 10) -> Tuple[str, str, int]: @@ -83,19 +91,17 @@ 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( "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", + *shell_argv, timeout=10, ) if not await _tmux_has_session(name): @@ -135,12 +141,15 @@ 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) + name = _tmux_session_name(session_id, cwd) + shell_argv = sandbox_command( + ["/bin/bash", "--noprofile", "--norc"], + workspace=cwd, + ) + await _ensure_tmux_session(name, cwd, shell_argv) stamp = f"{int(time.time() * 1000)}-{abs(hash(content)) % 1000000}" start_marker = f"__ODYSSEUS_CMD_START_{stamp}__" @@ -300,17 +309,15 @@ async def execute(self, content: str, ctx: dict) -> dict: if isinstance(content, dict): 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") + workspace = agent_cwd() # 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. + # native Windows must not bypass the platform-specific launcher. 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, + cwd=workspace, timeout=DEFAULT_BASH_TIMEOUT, progress_cb=progress_cb, ) @@ -320,7 +327,7 @@ async def execute(self, content: str, ctx: dict) -> dict: "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), - "tmux_session": _tmux_session_name(str(session_id)), + "tmux_session": _tmux_session_name(str(session_id), workspace), } output = stdout.rstrip() err = stderr.rstrip() @@ -329,19 +336,20 @@ async def execute(self, content: str, ctx: dict) -> dict: return { "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)", "exit_code": rc or 0, - "tmux_session": _tmux_session_name(str(session_id)), + "tmux_session": _tmux_session_name(str(session_id), workspace), } - try: - proc = await _create_bash_subprocess( - content, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=_subproc_env, - cwd=agent_cwd(), - ) - except RuntimeError as e: - return {"error": f"bash: {e}", "exit_code": 1} + argv = sandbox_command( + ["/bin/bash", "--noprofile", "--norc", "-c", content], + workspace=workspace, + ) + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=environment_for_sandbox_launcher(), + cwd=workspace, + ) stdout, stderr, rc, timed_out = await _run_subprocess_streaming( proc, timeout=DEFAULT_BASH_TIMEOUT, @@ -360,13 +368,17 @@ class PythonTool: async def execute(self, content: str, ctx: dict) -> dict: from src.tool_execution import agent_cwd, _truncate progress_cb = ctx.get("progress_cb") - _subproc_env = ctx.get("subproc_env") + workspace = agent_cwd() + argv = sandbox_command( + [sandbox_python_executable(), "-I", "-c", content], + workspace=workspace, + ) proc = await asyncio.create_subprocess_exec( - (sys.executable or "python"), "-I", "-c", content, + *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - env=_subproc_env, - cwd=agent_cwd(), + env=environment_for_sandbox_launcher(), + cwd=workspace, ) stdout, stderr, rc, timed_out = await _run_subprocess_streaming( proc, diff --git a/src/bg_jobs.py b/src/bg_jobs.py index f864f8ef16..349219a905 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,17 @@ * 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 inside the same +Linux bubblewrap profile as foreground Bash; a tiny isolated Python wrapper +outside the sandbox 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 json -import os -import shlex import subprocess +import sys import time import uuid from pathlib import Path @@ -32,13 +33,15 @@ from core.atomic_io import atomic_write_json from core.platform_compat import ( 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 ( + environment_for_sandbox_launcher, + sandbox_command, +) _JOBS_DIR = Path(BG_JOBS_DIR) _STORE = Path(BG_JOBS_FILE) @@ -53,6 +56,35 @@ # without bound. The agent has already consumed the result by then. _RETENTION_S = 3600 # 1 hour after follow-up +_DETACHED_SANDBOX_WRAPPER = """ +import json +import subprocess +import sys +from pathlib import Path + +argv = json.loads(sys.argv[1]) +log_path = Path(sys.argv[2]) +exit_path = Path(sys.argv[3]) +code = 1 +try: + with log_path.open("wb") as output: + completed = subprocess.run( + argv, + stdin=subprocess.DEVNULL, + stdout=output, + stderr=subprocess.STDOUT, + env={}, + check=False, + ) + code = int(completed.returncode) +except Exception as exc: + try: + log_path.write_text(f"sandbox launch failed: {exc}\\n", encoding="utf-8") + except Exception: + pass +exit_path.write_text(str(code), encoding="utf-8") +""".strip() + def _load() -> Dict[str, Dict[str, Any]]: try: @@ -91,51 +123,30 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None, log_path = _JOBS_DIR / f"{job_id}.log" exit_path = _JOBS_DIR / f"{job_id}.exit" - # 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", - ) - argv = [os.environ.get("ComSpec", "cmd.exe"), "/c", str(script_path)] + cmd_path = _JOBS_DIR / f"{job_id}.cmd.sh" + cmd_path.write_text(command + "\n", encoding="utf-8") + sandbox_argv = sandbox_command( + ["/bin/bash", "--noprofile", "--norc", "/run/odysseus/command.sh"], + workspace=cwd or "", + readonly_files={str(cmd_path): "/run/odysseus/command.sh"}, + ) + argv = [ + sys.executable, + "-I", + "-c", + _DETACHED_SANDBOX_WRAPPER, + json.dumps(sandbox_argv), + str(log_path), + str(exit_path), + ] proc = subprocess.Popen( argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, - cwd=cwd or None, + cwd=None, + env=environment_for_sandbox_launcher(), **detached_popen_kwargs(), # detach from the request lifecycle (setsid / DETACHED_PROCESS) ) diff --git a/src/constants.py b/src/constants.py index 584494290b..87818943c8 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 0000000000..5b4c9970cf --- /dev/null +++ b/src/execution_sandbox.py @@ -0,0 +1,334 @@ +"""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 shutil +import sys +from pathlib import Path +from typing import Mapping, Sequence + + +class SandboxUnavailable(RuntimeError): + """Raised when the requested sandbox cannot be established safely.""" + + +_BROAD_WORKSPACE_ROOTS = frozenset( + { + "/", + "/bin", + "/boot", + "/dev", + "/etc", + "/home", + "/lib", + "/lib64", + "/opt", + "/proc", + "/root", + "/run", + "/srv", + "/sys", + "/tmp", + "/usr", + "/var", + } +) +_SENSITIVE_DIR_NAMES = frozenset( + { + ".agents", + ".aws", + ".azure", + ".codex", + ".docker", + ".gnupg", + ".kube", + ".ssh", + } +) +_SENSITIVE_FILE_NAMES = frozenset( + { + ".bash_profile", + ".bashrc", + ".git-credentials", + ".gitconfig", + ".netrc", + ".npmrc", + ".pypirc", + ".zprofile", + ".zshenv", + ".zshrc", + "authorized_keys", + "id_ecdsa", + "id_ed25519", + "id_rsa", + } +) +_MAX_WORKSPACE_SCAN_ENTRIES = 100_000 +_SANDBOX_LIMITS = ( + "--as=4294967296", + "--core=0", + "--cpu=900", + "--fsize=1073741824", + "--nofile=256", + "--nproc=256", +) + + +def _bubblewrap_binary() -> str: + if not sys.platform.startswith("linux"): + raise SandboxUnavailable( + "Sandboxed agent execution requires Linux with bubblewrap." + ) + binary = shutil.which("bwrap") + if not binary: + raise SandboxUnavailable( + "Sandboxed agent execution is unavailable because bubblewrap " + "(`bwrap`) is not installed." + ) + return os.path.realpath(binary) + + +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)) + if resolved in _BROAD_WORKSPACE_ROOTS or os.path.dirname(resolved) == resolved: + raise SandboxUnavailable( + f"Refusing broad 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 _workspace_overlays( + workspace: str, + *, + excluded_roots: Sequence[str] = (), +) -> list[str]: + """Return mounts that protect repository metadata and credential paths.""" + args: list[str] = [] + scanned = 0 + for root, dirs, files in os.walk(workspace, followlinks=False): + 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) + resolved_path = os.path.realpath(path) + if any( + resolved_path == excluded or _is_within(resolved_path, excluded) + for excluded in excluded_roots + ): + continue + folded = name.casefold() + if folded == ".git": + args.extend(("--ro-bind", path, path)) + elif folded in _SENSITIVE_DIR_NAMES: + args.extend(("--tmpfs", path)) + else: + retained_dirs.append(name) + dirs[:] = retained_dirs + + for name in files: + if _is_sensitive_file(name): + path = os.path.join(root, 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, + 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) + 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, +) -> list[str]: + """Build a positive-mount, networkless 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. + """ + if not command or not all(isinstance(part, str) for part in command): + raise SandboxUnavailable("Sandbox command must be a non-empty argv list.") + + binary = _bubblewrap_binary() + root = _normalized_workspace(workspace) + if not os.path.isfile("/usr/bin/prlimit"): + raise SandboxUnavailable( + "Sandboxed agent execution requires `/usr/bin/prlimit`." + ) + args = [ + binary, + "--unshare-all", + "--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", + "--tmpfs", + "/tmp", + "--dir", + "/tmp/odysseus-home", + ) + ) + + args.extend(_directory_creation_args(root)) + args.extend(("--bind", root, root)) + 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", + "TERM": "xterm-256color", + "TMPDIR": "/tmp", + } + 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, "--", "/usr/bin/prlimit")) + args.extend(_SANDBOX_LIMITS) + args.extend(("--",)) + args.extend(command) + return args + + +def environment_for_sandbox_launcher() -> dict[str, str]: + """Minimal environment for the trusted bubblewrap launcher itself.""" + return {} diff --git a/src/tool_execution.py b/src/tool_execution.py index 8c0c83032b..86cc28f9f7 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -30,10 +30,14 @@ from src.tool_capabilities import ToolRunSecurityContext, blocked_tool_result from src.tool_approvals import ExactToolApproval 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 +49,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 @@ -537,18 +540,9 @@ async def _direct_fallback( session_id: Optional[str] = None, owner: Optional[str] = None, ) -> 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, } @@ -871,7 +865,21 @@ 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(), + ) + 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 = { diff --git a/tests/test_execution_sandbox.py b/tests/test_execution_sandbox.py new file mode 100644 index 0000000000..96a45c17dc --- /dev/null +++ b/tests/test_execution_sandbox.py @@ -0,0 +1,264 @@ +"""Linux sandbox invariants for model-requested process execution.""" + +import asyncio +import os +import subprocess +import time +import uuid +from pathlib import Path + +import pytest + +from src.execution_sandbox import ( + SandboxUnavailable, + environment_for_sandbox_launcher, + sandbox_command, +) + + +def test_sandbox_argv_is_positive_mount_networkless_and_clearenv(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + + argv = sandbox_command(["/bin/bash", "-c", "true"], workspace=str(workspace)) + + assert "--unshare-all" in argv + assert "--clearenv" in argv + assert "/usr/bin/prlimit" in argv + assert "--nproc=256" 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 + + +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() + + 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 + + +def test_sandbox_rejects_broad_workspace(): + with pytest.raises(SandboxUnavailable): + sandbox_command(["/bin/true"], workspace="/") + + +def test_sandbox_hides_odysseus_data_inside_broader_workspace( + tmp_path, + monkeypatch, +): + 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() + (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")) + + argv = sandbox_command( + [ + "/bin/bash", + "-c", + "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)] + assert ["--tmpfs", str(data_dir)] in pairs + assert ["--tmpfs", str(logs_dir)] in pairs + 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_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)) + + +def test_sandbox_hides_host_and_environment_at_runtime(tmp_path): + 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 ! -e /proc; " + "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() + + +def test_sandbox_network_namespace_has_no_external_route(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + code = ( + "import socket; " + "s=socket.socket(); s.settimeout(0.2); " + "\ntry: s.connect(('127.0.0.1', 9))" + "\nexcept OSError: raise SystemExit(0)" + "\nraise SystemExit(1)" + ) + argv = sandbox_command( + ["/usr/bin/python3", "-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 + + +def test_tmux_bash_shell_runs_inside_same_sandbox(tmp_path): + 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 = _tmux_session_name(session_id, str(workspace)) + + async def run(): + try: + return await _run_tmux_bash( + f"test ! -e {outside!s} && pwd && touch tmux-write.txt", + session_id=session_id, + cwd=str(workspace), + timeout=10, + ) + finally: + await _run_exec( + "tmux", + "kill-session", + "-t", + session_name, + timeout=3, + ) + + stdout, stderr, returncode, timed_out = asyncio.run(run()) + + assert timed_out is False + assert returncode == 0, stderr + assert str(workspace) in stdout + assert (workspace / "tmux-write.txt").exists() + + +def test_detached_background_job_uses_sandbox(tmp_path, monkeypatch): + 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() From aa6b7c14f41b607c8c74ebe9e7b56ace2bc1e4dc Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:12:34 +0000 Subject: [PATCH 02/18] fix(agent): preserve portable sandbox behavior --- src/agent_tools/subprocess_tools.py | 56 +++++++++++++++------- src/bg_jobs.py | 73 ++++++++++++++++++++++------- tests/test_execution_sandbox.py | 22 +++++++++ tests/test_workspace_confine.py | 5 ++ 4 files changed, 122 insertions(+), 34 deletions(-) diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index 500f3853f8..43f464edc7 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -3,12 +3,14 @@ import os import re import shutil +import sys import time import collections 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 ( + SandboxUnavailable, environment_for_sandbox_launcher, sandbox_command, sandbox_python_executable, @@ -309,6 +311,7 @@ async def execute(self, content: str, ctx: dict) -> dict: if isinstance(content, dict): 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") workspace = agent_cwd() # tmux is a POSIX persistence path. A stray MSYS/Cygwin tmux.exe on @@ -339,17 +342,29 @@ async def execute(self, content: str, ctx: dict) -> dict: "tmux_session": _tmux_session_name(str(session_id), workspace), } - argv = sandbox_command( - ["/bin/bash", "--noprofile", "--norc", "-c", content], - workspace=workspace, - ) - proc = await asyncio.create_subprocess_exec( - *argv, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=environment_for_sandbox_launcher(), - cwd=workspace, - ) + try: + if IS_WINDOWS: + proc = await _create_bash_subprocess( + content, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=subproc_env, + cwd=workspace, + ) + else: + argv = sandbox_command( + ["/bin/bash", "--noprofile", "--norc", "-c", content], + workspace=workspace, + ) + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=environment_for_sandbox_launcher(), + cwd=workspace, + ) + except (RuntimeError, SandboxUnavailable) as exc: + return {"error": f"bash: {exc}", "exit_code": 1, "blocked": True} stdout, stderr, rc, timed_out = await _run_subprocess_streaming( proc, timeout=DEFAULT_BASH_TIMEOUT, @@ -368,16 +383,25 @@ class PythonTool: async def execute(self, content: str, ctx: dict) -> dict: from src.tool_execution import agent_cwd, _truncate progress_cb = ctx.get("progress_cb") + subproc_env = ctx.get("subproc_env") workspace = agent_cwd() - argv = sandbox_command( - [sandbox_python_executable(), "-I", "-c", content], - workspace=workspace, - ) + try: + if IS_WINDOWS: + argv = [sys.executable, "-I", "-c", content] + process_env = subproc_env + else: + argv = sandbox_command( + [sandbox_python_executable(), "-I", "-c", content], + workspace=workspace, + ) + process_env = environment_for_sandbox_launcher() + except SandboxUnavailable as exc: + return {"error": f"python: {exc}", "exit_code": 1, "blocked": True} proc = await asyncio.create_subprocess_exec( *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - env=environment_for_sandbox_launcher(), + env=process_env, cwd=workspace, ) stdout, stderr, rc, timed_out = await _run_subprocess_streaming( diff --git a/src/bg_jobs.py b/src/bg_jobs.py index 349219a905..208c12422d 100644 --- a/src/bg_jobs.py +++ b/src/bg_jobs.py @@ -23,6 +23,8 @@ from __future__ import annotations import json +import os +import shlex import subprocess import sys import time @@ -32,7 +34,10 @@ 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, ) @@ -123,30 +128,62 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None, 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" - cmd_path.write_text(command + "\n", encoding="utf-8") - sandbox_argv = sandbox_command( - ["/bin/bash", "--noprofile", "--norc", "/run/odysseus/command.sh"], - workspace=cwd or "", - readonly_files={str(cmd_path): "/run/odysseus/command.sh"}, - ) - argv = [ - sys.executable, - "-I", - "-c", - _DETACHED_SANDBOX_WRAPPER, - json.dumps(sandbox_argv), - str(log_path), - str(exit_path), - ] + if IS_WINDOWS: + bash = find_bash() + if bash: + 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(path)) + for path 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: + 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", + ) + argv = [os.environ.get("ComSpec", "cmd.exe"), "/c", str(script_path)] + process_cwd = cwd or None + process_env = None + else: + cmd_path = _JOBS_DIR / f"{job_id}.cmd.sh" + cmd_path.write_text(command + "\n", encoding="utf-8") + sandbox_argv = sandbox_command( + ["/bin/bash", "--noprofile", "--norc", "/run/odysseus/command.sh"], + workspace=cwd or "", + readonly_files={str(cmd_path): "/run/odysseus/command.sh"}, + ) + argv = [ + sys.executable, + "-I", + "-c", + _DETACHED_SANDBOX_WRAPPER, + json.dumps(sandbox_argv), + str(log_path), + str(exit_path), + ] + process_cwd = None + process_env = environment_for_sandbox_launcher() proc = subprocess.Popen( argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, - cwd=None, - env=environment_for_sandbox_launcher(), + cwd=process_cwd, + env=process_env, **detached_popen_kwargs(), # detach from the request lifecycle (setsid / DETACHED_PROCESS) ) diff --git a/tests/test_execution_sandbox.py b/tests/test_execution_sandbox.py index 96a45c17dc..ec7e2667e4 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -2,6 +2,7 @@ import asyncio import os +import shutil import subprocess import time import uuid @@ -16,6 +17,22 @@ ) +@pytest.fixture(autouse=True) +def _stable_bubblewrap_lookup(monkeypatch): + """Keep argv-only tests independent of the CI runner's package set.""" + if shutil.which("bwrap") is None: + monkeypatch.setattr( + "src.execution_sandbox._bubblewrap_binary", + lambda: "/usr/bin/bwrap", + ) + + +requires_bubblewrap = pytest.mark.skipif( + shutil.which("bwrap") is None, + reason="bubblewrap is required for sandbox runtime assertions", +) + + def test_sandbox_argv_is_positive_mount_networkless_and_clearenv(tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() @@ -64,6 +81,7 @@ def test_sandbox_rejects_broad_workspace(): sandbox_command(["/bin/true"], workspace="/") +@requires_bubblewrap def test_sandbox_hides_odysseus_data_inside_broader_workspace( tmp_path, monkeypatch, @@ -129,6 +147,7 @@ def test_sandbox_allows_only_dedicated_workspace_below_data( sandbox_command(["/bin/true"], workspace=str(private_dir)) +@requires_bubblewrap def test_sandbox_hides_host_and_environment_at_runtime(tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() @@ -167,6 +186,7 @@ def test_sandbox_hides_host_and_environment_at_runtime(tmp_path): assert not (workspace / ".git" / "blocked").exists() +@requires_bubblewrap def test_sandbox_network_namespace_has_no_external_route(tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() @@ -195,6 +215,7 @@ def test_sandbox_network_namespace_has_no_external_route(tmp_path): assert completed.returncode == 0, completed.stderr +@requires_bubblewrap def test_tmux_bash_shell_runs_inside_same_sandbox(tmp_path): from src.agent_tools.subprocess_tools import ( _run_exec, @@ -234,6 +255,7 @@ async def run(): assert (workspace / "tmux-write.txt").exists() +@requires_bubblewrap def test_detached_background_job_uses_sandbox(tmp_path, monkeypatch): from src import bg_jobs diff --git a/tests/test_workspace_confine.py b/tests/test_workspace_confine.py index 701c6c5de5..b76a1e2fee 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,10 @@ async def test_glob_skips_sensitive_files_in_workspace(ws, admin): @pytest.mark.asyncio +@pytest.mark.skipif( + shutil.which("bwrap") is None, + reason="bubblewrap is required for subprocess sandbox execution", +) 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) From abbf56d025f445b42c1fbc19f0343969adf9bb08 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:55:57 +0000 Subject: [PATCH 03/18] fix(agent): harden sandbox workspace isolation --- src/execution_sandbox.py | 43 ++++++++++++++++++- tests/test_execution_sandbox.py | 75 ++++++++++++++++++++++++--------- 2 files changed, 95 insertions(+), 23 deletions(-) diff --git a/src/execution_sandbox.py b/src/execution_sandbox.py index 5b4c9970cf..f5cc2d12de 100644 --- a/src/execution_sandbox.py +++ b/src/execution_sandbox.py @@ -12,6 +12,7 @@ import sys from pathlib import Path from typing import Mapping, Sequence +from urllib.parse import unquote, urlsplit class SandboxUnavailable(RuntimeError): @@ -45,6 +46,7 @@ class SandboxUnavailable(RuntimeError): ".aws", ".azure", ".codex", + ".cargo", ".docker", ".gnupg", ".kube", @@ -54,12 +56,17 @@ class SandboxUnavailable(RuntimeError): _SENSITIVE_FILE_NAMES = frozenset( { ".bash_profile", + ".bash_logout", ".bashrc", + ".cshrc", ".git-credentials", ".gitconfig", ".netrc", ".npmrc", + ".pgpass", + ".profile", ".pypirc", + ".tcshrc", ".zprofile", ".zshenv", ".zshrc", @@ -98,7 +105,16 @@ 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)) - if resolved in _BROAD_WORKSPACE_ROOTS or os.path.dirname(resolved) == resolved: + home_roots = { + os.path.realpath(path) + for path in (os.path.expanduser("~"), os.environ.get("HOME", "")) + if path + } + if ( + resolved in _BROAD_WORKSPACE_ROOTS + or resolved in home_roots + or os.path.dirname(resolved) == resolved + ): raise SandboxUnavailable( f"Refusing broad sandbox workspace: {resolved}" ) @@ -162,9 +178,10 @@ def _workspace_overlays( ): continue folded = name.casefold() + relative = os.path.relpath(path, workspace).replace(os.sep, "/").casefold() if folded == ".git": args.extend(("--ro-bind", path, path)) - elif folded in _SENSITIVE_DIR_NAMES: + elif folded in _SENSITIVE_DIR_NAMES or relative == ".config/gh": args.extend(("--tmpfs", path)) else: retained_dirs.append(name) @@ -188,6 +205,7 @@ 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, @@ -220,6 +238,27 @@ def _odysseus_data_overlays(workspace: str) -> tuple[list[str], list[str]]: if _is_within(protected, workspace) and os.path.isdir(protected): args.extend(("--tmpfs", protected)) hidden_roots.append(protected) + + protected_files = {os.path.realpath(APP_DB)} + configured_database = os.environ.get("DATABASE_URL", "").strip() + if configured_database: + try: + parsed = urlsplit(configured_database) + if parsed.scheme == "sqlite" and parsed.path not in {"", "/:memory:"}: + database_path = unquote(parsed.path) + if not os.path.isabs(database_path): + from src.runtime_paths import get_app_root + + database_path = os.path.join(get_app_root(), database_path) + protected_files.add(os.path.realpath(database_path)) + except (TypeError, ValueError): + pass + for protected in sorted(protected_files): + for candidate in (protected, f"{protected}-journal", f"{protected}-shm", f"{protected}-wal"): + if any(_is_within(candidate, hidden) for hidden in hidden_roots): + continue + if _is_within(candidate, workspace) and os.path.isfile(candidate): + args.extend(("--ro-bind", "/dev/null", candidate)) return args, hidden_roots diff --git a/tests/test_execution_sandbox.py b/tests/test_execution_sandbox.py index ec7e2667e4..437f99d2a1 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -2,6 +2,7 @@ import asyncio import os +import socket import shutil import subprocess import time @@ -62,6 +63,8 @@ def test_sandbox_overlays_credentials_and_protects_git(tmp_path): (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") argv = sandbox_command(["/bin/true"], workspace=str(workspace)) @@ -74,6 +77,8 @@ def test_sandbox_overlays_credentials_and_protects_git(tmp_path): 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 def test_sandbox_rejects_broad_workspace(): @@ -81,6 +86,13 @@ def test_sandbox_rejects_broad_workspace(): sandbox_command(["/bin/true"], workspace="/") +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)) + + @requires_bubblewrap def test_sandbox_hides_odysseus_data_inside_broader_workspace( tmp_path, @@ -95,6 +107,7 @@ def test_sandbox_hides_odysseus_data_inside_broader_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)) @@ -106,7 +119,7 @@ def test_sandbox_hides_odysseus_data_inside_broader_workspace( [ "/bin/bash", "-c", - "test ! -e data/app.db && test ! -e logs/private.log", + "test -s allowed.txt && test ! -e data/app.db && test ! -e logs/private.log", ], workspace=str(workspace), ) @@ -126,6 +139,22 @@ def test_sandbox_hides_odysseus_data_inside_broader_workspace( assert completed.returncode == 0, completed.stderr +def test_sandbox_masks_configured_sqlite_database_inside_workspace( + tmp_path, + monkeypatch, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + database = workspace / "custom.db" + database.write_text("private", encoding="utf-8") + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{database}") + + argv = sandbox_command(["/bin/true"], workspace=str(workspace)) + + triples = [argv[index:index + 3] for index in range(len(argv) - 2)] + assert ["--ro-bind", "/dev/null", str(database)] in triples + + def test_sandbox_allows_only_dedicated_workspace_below_data( tmp_path, monkeypatch, @@ -190,27 +219,31 @@ def test_sandbox_hides_host_and_environment_at_runtime(tmp_path): def test_sandbox_network_namespace_has_no_external_route(tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() - code = ( - "import socket; " - "s=socket.socket(); s.settimeout(0.2); " - "\ntry: s.connect(('127.0.0.1', 9))" - "\nexcept OSError: raise SystemExit(0)" - "\nraise SystemExit(1)" - ) - argv = sandbox_command( - ["/usr/bin/python3", "-I", "-c", code], - workspace=str(workspace), - ) + 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( + ["/usr/bin/python3", "-I", "-c", code], + workspace=str(workspace), + ) - completed = subprocess.run( - argv, - cwd=str(workspace), - env={}, - capture_output=True, - text=True, - timeout=15, - check=False, - ) + completed = subprocess.run( + argv, + cwd=str(workspace), + env={}, + capture_output=True, + text=True, + timeout=15, + check=False, + ) assert completed.returncode == 0, completed.stderr From b9ebed3a6bb3667aec7a1e2f85dadbc7d523af8f Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:58:29 +0000 Subject: [PATCH 04/18] fix(agent): confine sandbox metadata mounts --- src/execution_sandbox.py | 38 +++++++++++++++++++++++++++++---- tests/test_execution_sandbox.py | 26 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/execution_sandbox.py b/src/execution_sandbox.py index f5cc2d12de..7c31f67917 100644 --- a/src/execution_sandbox.py +++ b/src/execution_sandbox.py @@ -40,6 +40,21 @@ class SandboxUnavailable(RuntimeError): "/var", } ) +_SYSTEM_WORKSPACE_ROOTS = frozenset( + { + "/bin", + "/boot", + "/dev", + "/etc", + "/lib", + "/lib64", + "/proc", + "/root", + "/run", + "/sys", + "/usr", + } +) _SENSITIVE_DIR_NAMES = frozenset( { ".agents", @@ -114,6 +129,7 @@ def _normalized_workspace(workspace: str) -> str: resolved in _BROAD_WORKSPACE_ROOTS or resolved in home_roots or os.path.dirname(resolved) == resolved + or any(_is_within(resolved, root) for root in _SYSTEM_WORKSPACE_ROOTS) ): raise SandboxUnavailable( f"Refusing broad sandbox workspace: {resolved}" @@ -171,14 +187,22 @@ def _workspace_overlays( 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() + if os.path.islink(path) 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 - folded = name.casefold() - relative = os.path.relpath(path, workspace).replace(os.sep, "/").casefold() if folded == ".git": args.extend(("--ro-bind", path, path)) elif folded in _SENSITIVE_DIR_NAMES or relative == ".config/gh": @@ -188,8 +212,14 @@ def _workspace_overlays( dirs[:] = retained_dirs for name in files: - if _is_sensitive_file(name): - path = os.path.join(root, name) + path = os.path.join(root, name) + 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 diff --git a/tests/test_execution_sandbox.py b/tests/test_execution_sandbox.py index 437f99d2a1..151887f0ba 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -85,6 +85,9 @@ 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)) @@ -93,6 +96,29 @@ def test_sandbox_rejects_the_process_home_as_workspace(tmp_path, monkeypatch): sandbox_command(["/bin/true"], workspace=str(tmp_path)) +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, From 1ef79fd0eddf9fa053bab6a7b8a1f87fb4eed430 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:00:16 +0000 Subject: [PATCH 05/18] fix(agent): fail closed without process sandbox --- src/agent_tools/subprocess_tools.py | 18 ++++++++++ src/bg_jobs.py | 4 +++ tests/test_agent_bash_windows.py | 53 +++++++++++++++++++++-------- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index 43f464edc7..c092c3729a 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -314,6 +314,15 @@ async def execute(self, content: str, ctx: dict) -> dict: subproc_env = ctx.get("subproc_env") session_id = ctx.get("session_id") workspace = agent_cwd() + if IS_WINDOWS: + return { + "error": ( + "bash: Sandboxed agent execution requires Linux with " + "bubblewrap." + ), + "exit_code": 1, + "blocked": True, + } # tmux is a POSIX persistence path. A stray MSYS/Cygwin tmux.exe on # native Windows must not bypass the platform-specific launcher. if session_id and not IS_WINDOWS and shutil.which("tmux"): @@ -385,6 +394,15 @@ async def execute(self, content: str, ctx: dict) -> dict: progress_cb = ctx.get("progress_cb") subproc_env = ctx.get("subproc_env") workspace = agent_cwd() + if IS_WINDOWS: + return { + "error": ( + "python: Sandboxed agent execution requires Linux with " + "bubblewrap." + ), + "exit_code": 1, + "blocked": True, + } try: if IS_WINDOWS: argv = [sys.executable, "-I", "-c", content] diff --git a/src/bg_jobs.py b/src/bg_jobs.py index 208c12422d..82cfe42413 100644 --- a/src/bg_jobs.py +++ b/src/bg_jobs.py @@ -123,6 +123,10 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None, server restart. The process is put in its own session (setsid) so it outlives the request/stream that started it. """ + if IS_WINDOWS: + raise RuntimeError( + "Sandboxed agent execution requires Linux with bubblewrap." + ) _JOBS_DIR.mkdir(parents=True, exist_ok=True) job_id = uuid.uuid4().hex[:12] log_path = _JOBS_DIR / f"{job_id}.log" diff --git a/tests/test_agent_bash_windows.py b/tests/test_agent_bash_windows.py index 888c906b3c..8de55fef31 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 From 9cce2d2ebc0e995e2191b8780e2b403a0898276e Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:02:19 +0000 Subject: [PATCH 06/18] fix(agent): resolve configured sandbox database paths --- src/execution_sandbox.py | 16 +++++++++++----- tests/test_execution_sandbox.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/execution_sandbox.py b/src/execution_sandbox.py index 7c31f67917..f5e8b3ed97 100644 --- a/src/execution_sandbox.py +++ b/src/execution_sandbox.py @@ -12,7 +12,7 @@ import sys from pathlib import Path from typing import Mapping, Sequence -from urllib.parse import unquote, urlsplit +from urllib.parse import unquote class SandboxUnavailable(RuntimeError): @@ -271,11 +271,17 @@ def _odysseus_data_overlays(workspace: str) -> tuple[list[str], list[str]]: protected_files = {os.path.realpath(APP_DB)} configured_database = os.environ.get("DATABASE_URL", "").strip() - if configured_database: + sqlite_prefix = "sqlite:///" + if configured_database.startswith(sqlite_prefix): try: - parsed = urlsplit(configured_database) - if parsed.scheme == "sqlite" and parsed.path not in {"", "/:memory:"}: - database_path = unquote(parsed.path) + database_path = unquote( + configured_database[len(sqlite_prefix):].split("?", 1)[0] + ) + if ( + database_path + and database_path != ":memory:" + and not database_path.casefold().startswith("file:") + ): if not os.path.isabs(database_path): from src.runtime_paths import get_app_root diff --git a/tests/test_execution_sandbox.py b/tests/test_execution_sandbox.py index 151887f0ba..0247cfb805 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -181,6 +181,23 @@ def test_sandbox_masks_configured_sqlite_database_inside_workspace( assert ["--ro-bind", "/dev/null", str(database)] in triples +def test_sandbox_resolves_relative_configured_sqlite_database( + tmp_path, + monkeypatch, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + database = workspace / "relative.db" + database.write_text("private", encoding="utf-8") + monkeypatch.setenv("DATABASE_URL", "sqlite:///relative.db") + monkeypatch.setattr("src.runtime_paths.get_app_root", lambda: str(workspace)) + + argv = sandbox_command(["/bin/true"], workspace=str(workspace)) + + triples = [argv[index:index + 3] for index in range(len(argv) - 2)] + assert ["--ro-bind", "/dev/null", str(database)] in triples + + def test_sandbox_allows_only_dedicated_workspace_below_data( tmp_path, monkeypatch, From f8621dfdaf1ac6152286e48885424ab3248c99d3 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:40:20 +0000 Subject: [PATCH 07/18] fix(agent): honor web toggle for sandbox networking --- routes/chat_routes.py | 5 + src/agent_loop.py | 4 + src/agent_tools/subprocess_tools.py | 33 ++++- src/bg_jobs.py | 11 +- src/bg_monitor.py | 9 +- src/execution_sandbox.py | 10 +- src/teacher_escalation.py | 2 + src/tool_execution.py | 35 ++++- tests/test_execution_sandbox.py | 68 ++++++++- tests/test_foreground_model_routing.py | 37 +++++ tests/test_sandbox_network_policy.py | 186 +++++++++++++++++++++++++ 11 files changed, 384 insertions(+), 16 deletions(-) create mode 100644 tests/test_sandbox_network_policy.py diff --git a/routes/chat_routes.py b/routes/chat_routes.py index 0b181796ff..41874f1619 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -2198,6 +2198,10 @@ 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. + _allow_sandbox_network = _search_enabled async for chunk in stream_agent_loop( sess.endpoint_url, sess.model, @@ -2235,6 +2239,7 @@ def _commit_chat_compaction(candidate_index: int) -> bool: ) ), exact_approval=exact_tool_approval, + allow_network=_allow_sandbox_network, ): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: diff --git a/src/agent_loop.py b/src/agent_loop.py index eb1ebe65ea..ddf31f98aa 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -3440,6 +3440,7 @@ async def stream_agent_loop( _is_teacher_run: bool = False, history_session=None, defer_context_shaping: bool = False, + allow_network: bool = False, ) -> AsyncGenerator[str, None]: """Streaming agent loop generator. @@ -4547,6 +4548,7 @@ async def _run_approved_tool(): workspace=workspace, security_context=run_security, exact_approval=exact_approval, + allow_network=allow_network, ) finally: await approved_progress_q.put(None) @@ -5768,6 +5770,7 @@ async def _run_tool(): progress_cb=_push_progress, workspace=workspace, security_context=run_security, + allow_network=allow_network, ) finally: # Sentinel so the drainer knows to stop. @@ -6412,6 +6415,7 @@ async def _run_tool(): tool_policy=tool_policy, active_document=active_document, active_email=active_email, + allow_network=allow_network, ): yield evt except Exception as _esc_err: diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index c092c3729a..b24da07a9d 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -45,12 +45,18 @@ async def _create_bash_subprocess(command: str, **kwargs): return await asyncio.create_subprocess_shell(command, **kwargs) -def _tmux_session_name(session_id: Optional[str], workspace: str = "") -> str: +def _tmux_session_name( + session_id: Optional[str], + workspace: str = "", + *, + allow_network: bool = False, +) -> 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] - return f"ody-agent-sbx-v1-{raw[:60] or 'default'}-{workspace_key}" + network_key = "net" if allow_network else "nonet" + return f"ody-agent-sbx-v1-{raw[:60] or 'default'}-{workspace_key}-{network_key}" async def _run_exec(*args: str, timeout: float = 10) -> Tuple[str, str, int]: @@ -145,11 +151,15 @@ async def _run_tmux_bash( cwd: str, timeout: float, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, + allow_network: bool = False, ) -> Tuple[str, str, Optional[int], bool]: - name = _tmux_session_name(session_id, cwd) + # Network policy is part of the persistent session identity so a tmux shell + # created with networking cannot be reused after the user disables it. + name = _tmux_session_name(session_id, cwd, allow_network=allow_network) shell_argv = sandbox_command( ["/bin/bash", "--noprofile", "--norc"], workspace=cwd, + allow_network=allow_network, ) await _ensure_tmux_session(name, cwd, shell_argv) @@ -313,6 +323,7 @@ async def execute(self, content: str, ctx: dict) -> dict: progress_cb = ctx.get("progress_cb") subproc_env = ctx.get("subproc_env") session_id = ctx.get("session_id") + allow_network = bool(ctx.get("allow_network", False)) workspace = agent_cwd() if IS_WINDOWS: return { @@ -332,6 +343,7 @@ async def execute(self, content: str, ctx: dict) -> dict: cwd=workspace, timeout=DEFAULT_BASH_TIMEOUT, progress_cb=progress_cb, + allow_network=allow_network, ) if timed_out: return { @@ -339,7 +351,11 @@ async def execute(self, content: str, ctx: dict) -> dict: "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), - "tmux_session": _tmux_session_name(str(session_id), workspace), + "tmux_session": _tmux_session_name( + str(session_id), + workspace, + allow_network=allow_network, + ), } output = stdout.rstrip() err = stderr.rstrip() @@ -348,7 +364,11 @@ async def execute(self, content: str, ctx: dict) -> dict: return { "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)", "exit_code": rc or 0, - "tmux_session": _tmux_session_name(str(session_id), workspace), + "tmux_session": _tmux_session_name( + str(session_id), + workspace, + allow_network=allow_network, + ), } try: @@ -364,6 +384,7 @@ async def execute(self, content: str, ctx: dict) -> dict: argv = sandbox_command( ["/bin/bash", "--noprofile", "--norc", "-c", content], workspace=workspace, + allow_network=allow_network, ) proc = await asyncio.create_subprocess_exec( *argv, @@ -393,6 +414,7 @@ async def execute(self, content: str, ctx: dict) -> dict: from src.tool_execution import agent_cwd, _truncate progress_cb = ctx.get("progress_cb") subproc_env = ctx.get("subproc_env") + allow_network = bool(ctx.get("allow_network", False)) workspace = agent_cwd() if IS_WINDOWS: return { @@ -411,6 +433,7 @@ async def execute(self, content: str, ctx: dict) -> dict: argv = sandbox_command( [sandbox_python_executable(), "-I", "-c", content], workspace=workspace, + allow_network=allow_network, ) process_env = environment_for_sandbox_launcher() except SandboxUnavailable as exc: diff --git a/src/bg_jobs.py b/src/bg_jobs.py index 82cfe42413..aaeae5e719 100644 --- a/src/bg_jobs.py +++ b/src/bg_jobs.py @@ -115,8 +115,13 @@ 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 launch( + command: str, + session_id: str, + cwd: Optional[str] = None, + max_runtime_s: int = DEFAULT_MAX_RUNTIME_S, + allow_network: bool = False, +) -> 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 @@ -168,6 +173,7 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None, ["/bin/bash", "--noprofile", "--norc", "/run/odysseus/command.sh"], workspace=cwd or "", readonly_files={str(cmd_path): "/run/odysseus/command.sh"}, + allow_network=allow_network, ) argv = [ sys.executable, @@ -201,6 +207,7 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None, "ended_at": None, "exit_code": None, "max_runtime_s": max_runtime_s, + "allow_network": bool(allow_network), "followed_up": False, # has the agent been re-invoked with the result? "log_path": str(log_path), "exit_path": str(exit_path), diff --git a/src/bg_monitor.py b/src/bg_monitor.py index c45066e3d5..a7f6f11149 100644 --- a/src/bg_monitor.py +++ b/src/bg_monitor.py @@ -36,7 +36,7 @@ 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, *, allow_network: bool = False): """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 +51,7 @@ async def _drain_agent(sess, messages): session_id=sess.id, max_rounds=_FOLLOWUP_MAX_ROUNDS, owner=getattr(sess, "owner", None), + allow_network=allow_network, ): if not chunk.startswith("data: "): continue @@ -121,7 +122,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, + allow_network=bool(rec.get("allow_network", False)), + ) # Persist ONLY the assistant continuation so it renders as a normal agent # turn — a standard chat bubble plus `tool_events` that the frontend diff --git a/src/execution_sandbox.py b/src/execution_sandbox.py index f5e8b3ed97..8693173bc9 100644 --- a/src/execution_sandbox.py +++ b/src/execution_sandbox.py @@ -315,12 +315,14 @@ def sandbox_command( workspace: str, readonly_files: Mapping[str, str] | None = None, extra_environment: Mapping[str, str] | None = None, + allow_network: bool = False, ) -> list[str]: - """Build a positive-mount, networkless bubblewrap command. + """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. + directories. Network access remains isolated unless the caller explicitly + enables it. """ if not command or not all(isinstance(part, str) for part in command): raise SandboxUnavailable("Sandbox command must be a non-empty argv list.") @@ -349,6 +351,10 @@ def sandbox_command( "usr/lib", "/lib", ] + if allow_network: + # Retain only the network namespace; every other namespace requested by + # --unshare-all stays isolated. + args.append("--share-net") if os.path.exists("/usr/lib64"): args.extend(("--symlink", "usr/lib64", "/lib64")) args.extend( diff --git a/src/teacher_escalation.py b/src/teacher_escalation.py index 59fe85570a..4497324382 100644 --- a/src/teacher_escalation.py +++ b/src/teacher_escalation.py @@ -524,6 +524,7 @@ async def run_teacher_inline( tool_policy: Any = None, active_document: Any = None, active_email: Optional[Dict[str, str]] = None, + allow_network: bool = False, ): """Async generator. Yields SSE event strings. @@ -636,6 +637,7 @@ async def run_teacher_inline( tool_policy=tool_policy, active_document=active_document, active_email=active_email, + allow_network=allow_network, _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 86cc28f9f7..2a3eadb59b 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -466,11 +466,17 @@ async def _call_mcp_tool( tool: str, content: str, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, + allow_network: bool = False, ) -> 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, + allow_network=allow_network, + ) 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}" @@ -479,7 +485,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, + allow_network=allow_network, + ) if fallback: return fallback @@ -539,12 +550,14 @@ async def _direct_fallback( progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, session_id: Optional[str] = None, owner: Optional[str] = None, + allow_network: bool = False, ) -> Optional[Dict]: try: ctx = { "progress_cb": progress_cb, "session_id": session_id, "owner": owner, + "allow_network": allow_network, } from src.agent_tools import TOOL_HANDLERS @@ -598,6 +611,7 @@ async def execute_tool_block( | _MissingToolSecurityContext ) = _MISSING_TOOL_SECURITY_CONTEXT, exact_approval: Optional[ExactToolApproval] = None, + allow_network: bool = False, ) -> Tuple[str, Dict]: """Execute a single tool block. Returns (description, result_dict). @@ -712,6 +726,7 @@ async def execute_tool_block( owner=owner, progress_cb=progress_cb, tool_policy=tool_policy, + allow_network=allow_network, approved_document_id=( exact_approval.pending.document_id if approval_claimed @@ -746,6 +761,7 @@ async def _execute_tool_block_impl( owner: Optional[str] = None, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, tool_policy: Optional[Any] = None, + allow_network: bool = False, approved_document_id: Optional[str] = None, approved_document_version: Optional[int] = None, approved_document_digest: Optional[str] = None, @@ -870,6 +886,7 @@ async def _execute_tool_block_impl( _bg_cmd, session_id=session_id, cwd=agent_cwd(), + allow_network=allow_network, ) except Exception as exc: return ( @@ -904,7 +921,12 @@ async def _execute_tool_block_impl( if 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, + allow_network=allow_network, + ) 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] @@ -1116,7 +1138,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, + allow_network=allow_network, + ) if isinstance(res, tuple): desc, result = res diff --git a/tests/test_execution_sandbox.py b/tests/test_execution_sandbox.py index 0247cfb805..5d7b808f89 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -34,13 +34,14 @@ def _stable_bubblewrap_lookup(monkeypatch): ) -def test_sandbox_argv_is_positive_mount_networkless_and_clearenv(tmp_path): +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 "--unshare-all" in argv + assert "--share-net" not in argv assert "--clearenv" in argv assert "/usr/bin/prlimit" in argv assert "--nproc=256" in argv @@ -57,6 +58,21 @@ def test_sandbox_argv_is_positive_mount_networkless_and_clearenv(tmp_path): assert "OPENAI_API_KEY" not in argv +def test_sandbox_argv_shares_only_network_when_explicitly_enabled(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + + argv = sandbox_command( + ["/bin/bash", "-c", "true"], + workspace=str(workspace), + allow_network=True, + ) + + assert "--unshare-all" in argv + assert "--share-net" in argv + assert "--clearenv" in argv + + def test_sandbox_overlays_credentials_and_protects_git(tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() @@ -291,6 +307,56 @@ def test_sandbox_network_namespace_has_no_external_route(tmp_path): assert completed.returncode == 0, completed.stderr +@requires_bubblewrap +def test_sandbox_can_share_network_namespace_when_enabled(tmp_path): + 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(1); " + f"s.connect(('127.0.0.1', {port}))" + ) + argv = sandbox_command( + ["/usr/bin/python3", "-I", "-c", code], + workspace=str(workspace), + allow_network=True, + ) + + 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_tmux_session_identity_includes_network_policy(tmp_path): + from src.agent_tools.subprocess_tools import _tmux_session_name + + workspace = tmp_path / "workspace" + workspace.mkdir() + + isolated = _tmux_session_name("session-1", str(workspace)) + networked = _tmux_session_name( + "session-1", + str(workspace), + allow_network=True, + ) + + assert isolated != networked + assert isolated.endswith("-nonet") + assert networked.endswith("-net") + + @requires_bubblewrap def test_tmux_bash_shell_runs_inside_same_sandbox(tmp_path): from src.agent_tools.subprocess_tools import ( diff --git a/tests/test_foreground_model_routing.py b/tests/test_foreground_model_routing.py index e2ceb8762b..6a7521b2c4 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_allow_network"] = kwargs.get("allow_network") 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,40 @@ 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 + + assert captured["agent_allow_network"] is expected + + @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_sandbox_network_policy.py b/tests/test_sandbox_network_policy.py new file mode 100644 index 0000000000..e434ab97e4 --- /dev/null +++ b/tests/test_sandbox_network_policy.py @@ -0,0 +1,186 @@ +"""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.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("allow_network"))) + 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", + allow_network=True, + security_context=NO_TOOL_SECURITY_CONTEXT, + ) + + assert result["exit_code"] == 0 + assert seen == [("bash", "printf ok", True)] + + +@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", {"allow_network": True}) + + assert result["exit_code"] == 0 + assert sandbox_calls[0][1]["allow_network"] is True + + +@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", + allow_network=True, + security_context=NO_TOOL_SECURITY_CONTEXT, + ) + + assert result["bg_job_id"] == "job-1" + assert seen == [ + ( + "printf networked", + { + "session_id": "session-1", + "cwd": "/tmp/workspace", + "allow_network": True, + }, + ) + ] + + +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("allow_network"))) + 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"}, + allow_network=True, + _is_teacher_run=True, + ) + ) + + assert calls == [("bash", True)] + + +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("allow_network")) + 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, [], allow_network=True)) + + assert seen == [True] From 7cd42b065cb6ec61c810843bc1c7348df95626f4 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:38:01 +0000 Subject: [PATCH 08/18] fix(agent): harden bubblewrap payload execution --- Dockerfile | 7 + docker-compose.gpu-amd.yml | 2 + docker-compose.gpu-nvidia.yml | 2 + docker-compose.yml | 2 + docker/seccomp/odysseus-bubblewrap.json | 898 +++++++++++++++++++ routes/chat_routes.py | 11 +- security/seccomp/Makefile | 35 + security/seccomp/README.md | 7 + security/seccomp/generate.py | 233 +++++ security/seccomp/generated_inner_policy.h | 734 +++++++++++++++ security/seccomp/moby-default.json | 875 ++++++++++++++++++ security/seccomp/odysseus-seccomp-launcher.c | 494 ++++++++++ security/seccomp/policy.json | 107 +++ src/agent_loop.py | 9 +- src/agent_tools/subprocess_tools.py | 97 +- src/bg_jobs.py | 7 +- src/bg_monitor.py | 15 +- src/execution_sandbox.py | 100 ++- src/teacher_escalation.py | 6 +- src/tool_execution.py | 23 +- tests/seccomp_probe.c | 177 ++++ tests/test_execution_sandbox.py | 405 +++++++-- tests/test_foreground_model_routing.py | 11 +- tests/test_sandbox_network_policy.py | 71 +- tests/test_seccomp_launcher.py | 183 ++++ tests/test_seccomp_policy.py | 135 +++ tests/test_workspace_confine.py | 5 +- 27 files changed, 4522 insertions(+), 129 deletions(-) create mode 100644 docker/seccomp/odysseus-bubblewrap.json create mode 100644 security/seccomp/Makefile create mode 100644 security/seccomp/README.md create mode 100644 security/seccomp/generate.py create mode 100644 security/seccomp/generated_inner_policy.h create mode 100644 security/seccomp/moby-default.json create mode 100644 security/seccomp/odysseus-seccomp-launcher.c create mode 100644 security/seccomp/policy.json create mode 100644 tests/seccomp_probe.c create mode 100644 tests/test_seccomp_launcher.py create mode 100644 tests/test_seccomp_policy.py diff --git a/Dockerfile b/Dockerfile index 545de93989..fc12538d30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ npm \ chromium \ bubblewrap \ + libseccomp2 \ util-linux \ tmux \ openssh-client \ @@ -97,6 +98,12 @@ 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 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 \ + && 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/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 8d0cf16531..c548fa816c 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 69331ffb67..9eca51a87c 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 708e5df828..5a2ec8b39d 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 0000000000..09d7124ff2 --- /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/chat_routes.py b/routes/chat_routes.py index 41874f1619..ee658a08cc 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 @@ -2200,8 +2201,12 @@ def _commit_chat_compaction(candidate_index: int) -> bool: # 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. - _allow_sandbox_network = _search_enabled + # 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, @@ -2239,7 +2244,7 @@ def _commit_chat_compaction(candidate_index: int) -> bool: ) ), exact_approval=exact_tool_approval, - allow_network=_allow_sandbox_network, + network_profile=_sandbox_network_profile, ): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: diff --git a/security/seccomp/Makefile b/security/seccomp/Makefile new file mode 100644 index 0000000000..ba5f37dce7 --- /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 0000000000..f341983d4d --- /dev/null +++ b/security/seccomp/README.md @@ -0,0 +1,7 @@ +# 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 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 0000000000..2d231faa6f --- /dev/null +++ b/security/seccomp/generate.py @@ -0,0 +1,233 @@ +#!/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(source: dict[str, Any], policy: dict[str, Any]) -> None: + lib = _load_libseccomp() + required = { + "clone", + "clone3", + "execve", + "ioctl", + "openat", + "seccomp", + "socket", + } + for generated_arch, moby_arch in policy["target_arches"].items(): + token = lib.seccomp_arch_resolve_name(generated_arch.encode("ascii")) + if token == 0: + raise RuntimeError(f"libseccomp does not recognize {generated_arch}") + # Portable Moby lists legitimately contain negative pseudo syscall + # numbers for calls absent on one architecture. The launcher skips + # those under default-deny. Every argument-sensitive rule that it must + # actively install has to resolve to a native nonnegative number. + names = set(_allowlist_for_arch(source, policy, moby_arch)) + unrecognized = { + name + for name in names + if lib.seccomp_syscall_resolve_name_arch( + token, name.encode("ascii") + ) == -1 + } + missing_required = { + name + for name in required + if lib.seccomp_syscall_resolve_name_arch( + token, name.encode("ascii") + ) < 0 + } + unknown = sorted(unrecognized | missing_required) + if unknown: + raise RuntimeError( + f"libseccomp cannot resolve {generated_arch} syscalls: " + + ", ".join(unknown) + ) + + +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(source, 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 0000000000..86cf64c600 --- /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 0000000000..3b35e8d841 --- /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 0000000000..86b35a01dd --- /dev/null +++ b/security/seccomp/odysseus-seccomp-launcher.c @@ -0,0 +1,494 @@ +#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 de2bf463. */ +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, + }; + + 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 + || 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_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 0000000000..3d3b91878d --- /dev/null +++ b/security/seccomp/policy.json @@ -0,0 +1,107 @@ +{ + "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", + "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_commit": "a871b148b7bc0571f50b917cd5fd03b427f54ed1" + } +} diff --git a/src/agent_loop.py b/src/agent_loop.py index ddf31f98aa..fe5725a588 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,7 +3441,7 @@ async def stream_agent_loop( _is_teacher_run: bool = False, history_session=None, defer_context_shaping: bool = False, - allow_network: bool = False, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, ) -> AsyncGenerator[str, None]: """Streaming agent loop generator. @@ -4548,7 +4549,7 @@ async def _run_approved_tool(): workspace=workspace, security_context=run_security, exact_approval=exact_approval, - allow_network=allow_network, + network_profile=network_profile, ) finally: await approved_progress_q.put(None) @@ -5770,7 +5771,7 @@ async def _run_tool(): progress_cb=_push_progress, workspace=workspace, security_context=run_security, - allow_network=allow_network, + network_profile=network_profile, ) finally: # Sentinel so the drainer knows to stop. @@ -6415,7 +6416,7 @@ async def _run_tool(): tool_policy=tool_policy, active_document=active_document, active_email=active_email, - allow_network=allow_network, + 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 b24da07a9d..97ba954572 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -10,6 +10,7 @@ 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, sandbox_command, @@ -49,13 +50,13 @@ def _tmux_session_name( session_id: Optional[str], workspace: str = "", *, - allow_network: bool = False, + 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 = "net" if allow_network else "nonet" + network_key = network_profile.value.replace("_", "-") return f"ody-agent-sbx-v1-{raw[:60] or 'default'}-{workspace_key}-{network_key}" @@ -107,12 +108,20 @@ async def _ensure_tmux_session( if await _tmux_has_session(name): await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5) return - await _run_exec( + _, launch_error, _ = await _run_exec( "tmux", "new-session", "-d", "-s", name, "-c", cwd, *shell_argv, timeout=10, ) if not await _tmux_has_session(name): + if ( + launch_error.startswith("odysseus-seccomp-launcher:") + 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}") await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5) @@ -151,15 +160,15 @@ async def _run_tmux_bash( cwd: str, timeout: float, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, - allow_network: bool = False, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, ) -> Tuple[str, str, Optional[int], bool]: - # Network policy is part of the persistent session identity so a tmux shell - # created with networking cannot be reused after the user disables it. - name = _tmux_session_name(session_id, cwd, allow_network=allow_network) + # The launch snapshot is part of the persistent session identity, so a + # tmux shell can never be reused under a different network profile. + name = _tmux_session_name(session_id, cwd, network_profile=network_profile) shell_argv = sandbox_command( ["/bin/bash", "--noprofile", "--norc"], workspace=cwd, - allow_network=allow_network, + network_profile=network_profile, ) await _ensure_tmux_session(name, cwd, shell_argv) @@ -315,6 +324,37 @@ 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 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 @@ -323,7 +363,9 @@ async def execute(self, content: str, ctx: dict) -> dict: progress_cb = ctx.get("progress_cb") subproc_env = ctx.get("subproc_env") session_id = ctx.get("session_id") - allow_network = bool(ctx.get("allow_network", False)) + network_profile = ctx.get( + "network_profile", SandboxNetworkProfile.NETWORKLESS + ) workspace = agent_cwd() if IS_WINDOWS: return { @@ -343,7 +385,7 @@ async def execute(self, content: str, ctx: dict) -> dict: cwd=workspace, timeout=DEFAULT_BASH_TIMEOUT, progress_cb=progress_cb, - allow_network=allow_network, + network_profile=network_profile, ) if timed_out: return { @@ -354,7 +396,7 @@ async def execute(self, content: str, ctx: dict) -> dict: "tmux_session": _tmux_session_name( str(session_id), workspace, - allow_network=allow_network, + network_profile=network_profile, ), } output = stdout.rstrip() @@ -367,7 +409,7 @@ async def execute(self, content: str, ctx: dict) -> dict: "tmux_session": _tmux_session_name( str(session_id), workspace, - allow_network=allow_network, + network_profile=network_profile, ), } @@ -384,7 +426,7 @@ async def execute(self, content: str, ctx: dict) -> dict: argv = sandbox_command( ["/bin/bash", "--noprofile", "--norc", "-c", content], workspace=workspace, - allow_network=allow_network, + network_profile=network_profile, ) proc = await asyncio.create_subprocess_exec( *argv, @@ -402,6 +444,9 @@ async def execute(self, content: str, ctx: dict) -> dict: ) 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)} + setup_failure = _sandbox_setup_failure("bash", stderr, rc) + if setup_failure: + return setup_failure output = stdout.rstrip() err = stderr.rstrip() if err: @@ -414,7 +459,9 @@ async def execute(self, content: str, ctx: dict) -> dict: from src.tool_execution import agent_cwd, _truncate progress_cb = ctx.get("progress_cb") subproc_env = ctx.get("subproc_env") - allow_network = bool(ctx.get("allow_network", False)) + network_profile = ctx.get( + "network_profile", SandboxNetworkProfile.NETWORKLESS + ) workspace = agent_cwd() if IS_WINDOWS: return { @@ -433,18 +480,21 @@ async def execute(self, content: str, ctx: dict) -> dict: argv = sandbox_command( [sandbox_python_executable(), "-I", "-c", content], workspace=workspace, - allow_network=allow_network, + network_profile=network_profile, ) process_env = environment_for_sandbox_launcher() except SandboxUnavailable as exc: return {"error": f"python: {exc}", "exit_code": 1, "blocked": True} - proc = await asyncio.create_subprocess_exec( - *argv, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=process_env, - cwd=workspace, - ) + try: + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=process_env, + cwd=workspace, + ) + except (OSError, RuntimeError) as exc: + return {"error": f"python: {exc}", "exit_code": 1, "blocked": True} stdout, stderr, rc, timed_out = await _run_subprocess_streaming( proc, timeout=DEFAULT_PYTHON_TIMEOUT, @@ -452,6 +502,9 @@ async def execute(self, content: str, ctx: dict) -> dict: ) 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)} + setup_failure = _sandbox_setup_failure("python", stderr, rc) + if setup_failure: + return setup_failure output = stdout.rstrip() err = stderr.rstrip() if err: diff --git a/src/bg_jobs.py b/src/bg_jobs.py index aaeae5e719..cca201f33f 100644 --- a/src/bg_jobs.py +++ b/src/bg_jobs.py @@ -44,6 +44,7 @@ from src.constants import BG_JOBS_DIR, BG_JOBS_FILE from src.execution_sandbox import ( + SandboxNetworkProfile, environment_for_sandbox_launcher, sandbox_command, ) @@ -120,7 +121,7 @@ def launch( session_id: str, cwd: Optional[str] = None, max_runtime_s: int = DEFAULT_MAX_RUNTIME_S, - allow_network: bool = False, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, ) -> Dict[str, Any]: """Launch `command` detached. Returns the job record (status='running'). @@ -173,7 +174,7 @@ def launch( ["/bin/bash", "--noprofile", "--norc", "/run/odysseus/command.sh"], workspace=cwd or "", readonly_files={str(cmd_path): "/run/odysseus/command.sh"}, - allow_network=allow_network, + network_profile=network_profile, ) argv = [ sys.executable, @@ -207,7 +208,7 @@ def launch( "ended_at": None, "exit_code": None, "max_runtime_s": max_runtime_s, - "allow_network": bool(allow_network), + "network_profile": network_profile.value, "followed_up": False, # has the agent been re-invoked with the result? "log_path": str(log_path), "exit_path": str(exit_path), diff --git a/src/bg_monitor.py b/src/bg_monitor.py index a7f6f11149..fd2e42db75 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, *, allow_network: bool = False): +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,7 +60,7 @@ async def _drain_agent(sess, messages, *, allow_network: bool = False): session_id=sess.id, max_rounds=_FOLLOWUP_MAX_ROUNDS, owner=getattr(sess, "owner", None), - allow_network=allow_network, + network_profile=network_profile, ): if not chunk.startswith("data: "): continue @@ -125,7 +134,7 @@ async def _run_followup(rec: dict) -> bool: full, tool_events = await _drain_agent( sess, context, - allow_network=bool(rec.get("allow_network", False)), + network_profile=network_profile_from_snapshot(rec.get("network_profile")), ) # Persist ONLY the assistant continuation so it renders as a normal agent diff --git a/src/execution_sandbox.py b/src/execution_sandbox.py index 8693173bc9..91149a4ad6 100644 --- a/src/execution_sandbox.py +++ b/src/execution_sandbox.py @@ -8,8 +8,9 @@ from __future__ import annotations import os -import shutil +import stat import sys +from enum import Enum from pathlib import Path from typing import Mapping, Sequence from urllib.parse import unquote @@ -19,6 +20,30 @@ 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( { "/", @@ -92,6 +117,9 @@ class SandboxUnavailable(RuntimeError): } ) _MAX_WORKSPACE_SCAN_ENTRIES = 100_000 +_TRUSTED_BWRAP = "/usr/bin/bwrap" +_TRUSTED_SECCOMP_LAUNCHER = "/usr/local/libexec/odysseus-seccomp-launcher" +_CA_CERTIFICATE = "/etc/ssl/certs/ca-certificates.crt" _SANDBOX_LIMITS = ( "--as=4294967296", "--core=0", @@ -102,18 +130,41 @@ class SandboxUnavailable(RuntimeError): ) +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_IWGRP | stat.S_IWOTH) + or not os.access(path, os.X_OK) + ): + raise SandboxUnavailable( + f"Trusted {description} is not a root-owned, read-only executable at {path}." + ) + return path + + def _bubblewrap_binary() -> str: if not sys.platform.startswith("linux"): raise SandboxUnavailable( "Sandboxed agent execution requires Linux with bubblewrap." ) - binary = shutil.which("bwrap") - if not binary: + return _trusted_executable(_TRUSTED_BWRAP, "Bubblewrap binary") + + +def _seccomp_launcher_binary() -> str: + if not sys.platform.startswith("linux"): raise SandboxUnavailable( - "Sandboxed agent execution is unavailable because bubblewrap " - "(`bwrap`) is not installed." + "Sandboxed agent execution requires Linux with the trusted seccomp launcher." ) - return os.path.realpath(binary) + return _trusted_executable(_TRUSTED_SECCOMP_LAUNCHER, "seccomp launcher") def _normalized_workspace(workspace: str) -> str: @@ -315,27 +366,46 @@ def sandbox_command( workspace: str, readonly_files: Mapping[str, str] | None = None, extra_environment: Mapping[str, str] | None = None, - allow_network: bool = False, + 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 access remains isolated unless the caller explicitly - enables it. + 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.") + if network_profile is SandboxNetworkProfile.BROKERED_ONLY: + raise SandboxUnavailable( + "Brokered Internet was requested, but no trusted sandbox egress " + "bridge is configured. Refusing to expose raw container networking." + ) + + launcher = _seccomp_launcher_binary() binary = _bubblewrap_binary() root = _normalized_workspace(workspace) + if _is_within(launcher, root): + 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, - "--unshare-all", + "--unshare-user", + "--unshare-ipc", + "--unshare-pid", + "--unshare-net", + "--unshare-uts", + "--unshare-cgroup", "--die-with-parent", "--new-session", "--clearenv", @@ -351,16 +421,14 @@ def sandbox_command( "usr/lib", "/lib", ] - if allow_network: - # Retain only the network namespace; every other namespace requested by - # --unshare-all stays isolated. - args.append("--share-net") if os.path.exists("/usr/lib64"): args.extend(("--symlink", "usr/lib64", "/lib64")) args.extend( ( "--dev", "/dev", + "--proc", + "/proc", "--tmpfs", "/tmp", "--dir", @@ -370,6 +438,9 @@ def sandbox_command( 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)) @@ -394,6 +465,7 @@ def sandbox_command( "LC_ALL": "C.UTF-8", "LINES": "40", "PATH": "/usr/local/bin:/usr/bin:/bin", + "SSL_CERT_FILE": _CA_CERTIFICATE, "TERM": "xterm-256color", "TMPDIR": "/tmp", } diff --git a/src/teacher_escalation.py b/src/teacher_escalation.py index 4497324382..31093c1886 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,7 +526,7 @@ async def run_teacher_inline( tool_policy: Any = None, active_document: Any = None, active_email: Optional[Dict[str, str]] = None, - allow_network: bool = False, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, ): """Async generator. Yields SSE event strings. @@ -637,7 +639,7 @@ async def run_teacher_inline( tool_policy=tool_policy, active_document=active_document, active_email=active_email, - allow_network=allow_network, + 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 2a3eadb59b..bef52e6db0 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -29,6 +29,7 @@ ) 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 ( AGENT_WORKSPACE_DIR, @@ -466,7 +467,7 @@ async def _call_mcp_tool( tool: str, content: str, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, - allow_network: bool = False, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, ) -> Dict: """Route a legacy tool call through the MCP manager, with direct fallbacks.""" mcp = get_mcp_manager() @@ -475,7 +476,7 @@ async def _call_mcp_tool( tool, content, progress_cb=progress_cb, - allow_network=allow_network, + 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] @@ -489,7 +490,7 @@ async def _call_mcp_tool( tool, content, progress_cb=progress_cb, - allow_network=allow_network, + network_profile=network_profile, ) if fallback: return fallback @@ -550,14 +551,14 @@ async def _direct_fallback( progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, session_id: Optional[str] = None, owner: Optional[str] = None, - allow_network: bool = False, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, ) -> Optional[Dict]: try: ctx = { "progress_cb": progress_cb, "session_id": session_id, "owner": owner, - "allow_network": allow_network, + "network_profile": network_profile, } from src.agent_tools import TOOL_HANDLERS @@ -611,7 +612,7 @@ async def execute_tool_block( | _MissingToolSecurityContext ) = _MISSING_TOOL_SECURITY_CONTEXT, exact_approval: Optional[ExactToolApproval] = None, - allow_network: bool = False, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, ) -> Tuple[str, Dict]: """Execute a single tool block. Returns (description, result_dict). @@ -726,7 +727,7 @@ async def execute_tool_block( owner=owner, progress_cb=progress_cb, tool_policy=tool_policy, - allow_network=allow_network, + network_profile=network_profile, approved_document_id=( exact_approval.pending.document_id if approval_claimed @@ -761,7 +762,7 @@ async def _execute_tool_block_impl( owner: Optional[str] = None, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, tool_policy: Optional[Any] = None, - allow_network: bool = False, + network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, approved_document_id: Optional[str] = None, approved_document_version: Optional[int] = None, approved_document_digest: Optional[str] = None, @@ -886,7 +887,7 @@ async def _execute_tool_block_impl( _bg_cmd, session_id=session_id, cwd=agent_cwd(), - allow_network=allow_network, + network_profile=network_profile, ) except Exception as exc: return ( @@ -925,7 +926,7 @@ async def _execute_tool_block_impl( tool, content, progress_cb=progress_cb, - allow_network=allow_network, + network_profile=network_profile, ) elif tool in ("grep", "glob", "ls", "get_workspace"): # Code-navigation tools — no MCP server; run the direct implementation. @@ -1142,7 +1143,7 @@ async def _execute_tool_block_impl( tool, content, progress_cb=progress_cb, - allow_network=allow_network, + network_profile=network_profile, ) if isinstance(res, tuple): diff --git a/tests/seccomp_probe.c b/tests/seccomp_probe.c new file mode 100644 index 0000000000..2bc4839bb6 --- /dev/null +++ b/tests/seccomp_probe.c @@ -0,0 +1,177 @@ +#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"), EPERM); + } +#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_execution_sandbox.py b/tests/test_execution_sandbox.py index 5d7b808f89..157c157def 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -4,6 +4,7 @@ import os import socket import shutil +import stat import subprocess import time import uuid @@ -12,6 +13,7 @@ import pytest from src.execution_sandbox import ( + SandboxNetworkProfile, SandboxUnavailable, environment_for_sandbox_launcher, sandbox_command, @@ -21,27 +23,98 @@ @pytest.fixture(autouse=True) def _stable_bubblewrap_lookup(monkeypatch): """Keep argv-only tests independent of the CI runner's package set.""" - if shutil.which("bwrap") is None: - monkeypatch.setattr( - "src.execution_sandbox._bubblewrap_binary", - lambda: "/usr/bin/bwrap", - ) + 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", + ) requires_bubblewrap = pytest.mark.skipif( - shutil.which("bwrap") is None, - reason="bubblewrap is required for sandbox runtime assertions", + 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 "--unshare-all" in argv + assert argv[:2] == [ + "/usr/local/libexec/odysseus-seccomp-launcher", + "/usr/bin/bwrap", + ] + 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" in argv @@ -56,21 +129,62 @@ def test_sandbox_argv_is_positive_mount_networkless_by_default_and_clearenv(tmp_ ] 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, read-only"): + _trusted_executable("/trusted/launcher", "seccomp launcher") -def test_sandbox_argv_shares_only_network_when_explicitly_enabled(tmp_path): + +def test_sandbox_rejects_invalid_network_profile(tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() - argv = sandbox_command( - ["/bin/bash", "-c", "true"], - workspace=str(workspace), - allow_network=True, + 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"), ) - assert "--unshare-all" in argv - assert "--share-net" in argv - assert "--clearenv" in argv + with pytest.raises(SandboxUnavailable, match="overlaps"): + sandbox_command(["/bin/true"], workspace=str(workspace)) + + +def test_brokered_profile_fails_closed_without_trusted_bridge(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + + with pytest.raises(SandboxUnavailable, match="Brokered Internet"): + sandbox_command( + ["/bin/bash", "-c", "true"], + workspace=str(workspace), + network_profile=SandboxNetworkProfile.BROKERED_ONLY, + ) def test_sandbox_overlays_credentials_and_protects_git(tmp_path): @@ -139,6 +253,7 @@ def test_sandbox_rejects_symlinked_sensitive_mounts(tmp_path): def test_sandbox_hides_odysseus_data_inside_broader_workspace( tmp_path, monkeypatch, + runtime_seccomp_launcher, ): import src.constants as constants @@ -236,7 +351,10 @@ def test_sandbox_allows_only_dedicated_workspace_below_data( @requires_bubblewrap -def test_sandbox_hides_host_and_environment_at_runtime(tmp_path): +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" @@ -249,7 +367,7 @@ def test_sandbox_hides_host_and_environment_at_runtime(tmp_path): "test -z \"${OPENAI_API_KEY:-}\"; " "test ! -s .env; " "test ! -e /home; " - "test ! -e /proc; " + "test -r /proc/self/status; " "touch allowed.txt; " "if touch .git/blocked 2>/dev/null; then exit 91; fi" ) @@ -275,7 +393,10 @@ def test_sandbox_hides_host_and_environment_at_runtime(tmp_path): @requires_bubblewrap -def test_sandbox_network_namespace_has_no_external_route(tmp_path): +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: @@ -308,37 +429,214 @@ def test_sandbox_network_namespace_has_no_external_route(tmp_path): @requires_bubblewrap -def test_sandbox_can_share_network_namespace_when_enabled(tmp_path): +def test_sandbox_status_has_one_additional_inner_filter( + 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(1); " - f"s.connect(('127.0.0.1', {port}))" - ) - argv = sandbox_command( - ["/usr/bin/python3", "-I", "-c", code], - workspace=str(workspace), - allow_network=True, - ) - completed = subprocess.run( - argv, - cwd=str(workspace), - env={}, - capture_output=True, - text=True, - timeout=15, - check=False, - ) + 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", + "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 @@ -346,19 +644,22 @@ def test_tmux_session_identity_includes_network_policy(tmp_path): workspace.mkdir() isolated = _tmux_session_name("session-1", str(workspace)) - networked = _tmux_session_name( + brokered = _tmux_session_name( "session-1", str(workspace), - allow_network=True, + network_profile=SandboxNetworkProfile.BROKERED_ONLY, ) - assert isolated != networked - assert isolated.endswith("-nonet") - assert networked.endswith("-net") + assert isolated != brokered + assert isolated.endswith("-networkless") + assert brokered.endswith("-brokered-only") @requires_bubblewrap -def test_tmux_bash_shell_runs_inside_same_sandbox(tmp_path): +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, @@ -398,7 +699,11 @@ async def run(): @requires_bubblewrap -def test_detached_background_job_uses_sandbox(tmp_path, monkeypatch): +def test_detached_background_job_uses_sandbox( + tmp_path, + monkeypatch, + runtime_seccomp_launcher, +): from src import bg_jobs jobs_dir = tmp_path / "jobs" diff --git a/tests/test_foreground_model_routing.py b/tests/test_foreground_model_routing.py index 6a7521b2c4..cdd9342f09 100644 --- a/tests/test_foreground_model_routing.py +++ b/tests/test_foreground_model_routing.py @@ -160,7 +160,7 @@ async def fake_agent_stream(endpoint_url, model, messages, **kwargs): "fallbacks": kwargs.get("fallbacks"), } if capture_network: - captured["agent_allow_network"] = kwargs.get("allow_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: @@ -297,7 +297,14 @@ async def test_chat_stream_maps_only_web_toggle_to_sandbox_network( async for _ in response.body_iterator: pass - assert captured["agent_allow_network"] is expected + 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 diff --git a/tests/test_sandbox_network_policy.py b/tests/test_sandbox_network_policy.py index e434ab97e4..75a9789c98 100644 --- a/tests/test_sandbox_network_policy.py +++ b/tests/test_sandbox_network_policy.py @@ -9,6 +9,10 @@ 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 @@ -24,7 +28,7 @@ async def test_tool_executor_forwards_network_policy_to_subprocess_fallback(monk seen = [] async def fake_direct_fallback(tool, content, **kwargs): - seen.append((tool, content, kwargs.get("allow_network"))) + seen.append((tool, content, kwargs.get("network_profile"))) return {"output": "ok", "exit_code": 0} monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True) @@ -34,12 +38,14 @@ async def fake_direct_fallback(tool, content, **kwargs): _, result = await tool_execution.execute_tool_block( ToolBlock("bash", "printf ok"), owner="admin", - allow_network=True, + network_profile=SandboxNetworkProfile.BROKERED_ONLY, security_context=NO_TOOL_SECURITY_CONTEXT, ) assert result["exit_code"] == 0 - assert seen == [("bash", "printf ok", True)] + assert seen == [ + ("bash", "printf ok", SandboxNetworkProfile.BROKERED_ONLY) + ] @pytest.mark.asyncio @@ -76,10 +82,16 @@ async def fake_run_subprocess_streaming(*args, **kwargs): if tool_name == "bash" else subprocess_tools.PythonTool() ) - result = await handler.execute("printf ok", {"allow_network": True}) + result = await handler.execute( + "printf ok", + {"network_profile": SandboxNetworkProfile.BROKERED_ONLY}, + ) assert result["exit_code"] == 0 - assert sandbox_calls[0][1]["allow_network"] is True + assert ( + sandbox_calls[0][1]["network_profile"] + is SandboxNetworkProfile.BROKERED_ONLY + ) @pytest.mark.asyncio @@ -100,7 +112,7 @@ def fake_launch(command, **kwargs): session_id="session-1", owner="admin", workspace="/tmp/workspace", - allow_network=True, + network_profile=SandboxNetworkProfile.BROKERED_ONLY, security_context=NO_TOOL_SECURITY_CONTEXT, ) @@ -111,7 +123,7 @@ def fake_launch(command, **kwargs): { "session_id": "session-1", "cwd": "/tmp/workspace", - "allow_network": True, + "network_profile": SandboxNetworkProfile.BROKERED_ONLY, }, ) ] @@ -140,7 +152,7 @@ async def fake_stream(_candidates, messages, **kwargs): yield "data: [DONE]\n\n" async def fake_execute(block, **kwargs): - calls.append((block.tool_type, kwargs.get("allow_network"))) + 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) @@ -154,12 +166,12 @@ async def fake_execute(block, **kwargs): owner="admin", max_rounds=2, relevant_tools={"bash"}, - allow_network=True, + network_profile=SandboxNetworkProfile.BROKERED_ONLY, _is_teacher_run=True, ) ) - assert calls == [("bash", True)] + assert calls == [("bash", SandboxNetworkProfile.BROKERED_ONLY)] def test_background_followup_preserves_originating_network_policy(monkeypatch): @@ -168,7 +180,7 @@ def test_background_followup_preserves_originating_network_policy(monkeypatch): seen = [] async def fake_stream_agent_loop(*args, **kwargs): - seen.append(kwargs.get("allow_network")) + seen.append(kwargs.get("network_profile")) yield "data: [DONE]" monkeypatch.setattr(agent_loop, "stream_agent_loop", fake_stream_agent_loop) @@ -181,6 +193,39 @@ async def fake_stream_agent_loop(*args, **kwargs): owner="admin", ) - asyncio.run(bg_monitor._drain_agent(session, [], allow_network=True)) + asyncio.run( + bg_monitor._drain_agent( + session, + [], + network_profile=SandboxNetworkProfile.BROKERED_ONLY, + ) + ) + + assert seen == [SandboxNetworkProfile.BROKERED_ONLY] + - assert seen == [True] +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 + ) diff --git a/tests/test_seccomp_launcher.py b/tests/test_seccomp_launcher.py new file mode 100644 index 0000000000..9a2047ceed --- /dev/null +++ b/tests/test_seccomp_launcher.py @@ -0,0 +1,183 @@ +"""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" + + +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") +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 0000000000..ec14e2acea --- /dev/null +++ b/tests/test_seccomp_policy.py @@ -0,0 +1,135 @@ +"""Determinism and deployment invariants for the two seccomp layers.""" + +from __future__ import annotations + +import copy +import json +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["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"] + + +@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" + ) diff --git a/tests/test_workspace_confine.py b/tests/test_workspace_confine.py index b76a1e2fee..e3b38ca1ec 100644 --- a/tests/test_workspace_confine.py +++ b/tests/test_workspace_confine.py @@ -269,8 +269,9 @@ async def test_glob_skips_sensitive_files_in_workspace(ws, admin): @pytest.mark.asyncio @pytest.mark.skipif( - shutil.which("bwrap") is None, - reason="bubblewrap is required for subprocess sandbox execution", + 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).""" From 37f3be42cd77959dc41252531067f636b697c846 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:53:35 +0000 Subject: [PATCH 09/18] fix(agent): broker sandbox HTTP egress --- Dockerfile | 8 +- security/egress/Makefile | 22 + security/egress/README.md | 25 + security/egress/odysseus_egress_bridge.py | 183 ++++++ security/egress/odysseus_egress_broker.py | 720 +++++++++++++++++++++ src/agent_tools/subprocess_tools.py | 12 + src/execution_sandbox.py | 125 +++- tests/test_egress_broker.py | 745 ++++++++++++++++++++++ tests/test_execution_sandbox.py | 96 ++- 9 files changed, 1898 insertions(+), 38 deletions(-) create mode 100644 security/egress/Makefile create mode 100644 security/egress/README.md create mode 100644 security/egress/odysseus_egress_bridge.py create mode 100644 security/egress/odysseus_egress_broker.py create mode 100644 tests/test_egress_broker.py diff --git a/Dockerfile b/Dockerfile index fc12538d30..721c907369 100644 --- a/Dockerfile +++ b/Dockerfile @@ -98,10 +98,12 @@ 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 under a -# root-owned path that the dropped runtime user and model workspace cannot -# modify. The policy generator verifies pinned Moby provenance first. +# 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) diff --git a/security/egress/Makefile b/security/egress/Makefile new file mode 100644 index 0000000000..86723c49c7 --- /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 0000000000..dcdc77d1c9 --- /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 0000000000..23714d5695 --- /dev/null +++ b/security/egress/odysseus_egress_bridge.py @@ -0,0 +1,183 @@ +#!/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 +from typing import 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 + + +class BridgeError(RuntimeError): + """The trusted bridge could not be established safely.""" + + +def _copy(source: socket.socket, destination: socket.socket) -> None: + try: + while True: + data = source.recv(COPY_BUFFER_BYTES) + if not data: + break + destination.sendall(data) + except OSError: + pass + finally: + try: + destination.shutdown(socket.SHUT_WR) + except OSError: + pass + + +def _relay(left: socket.socket, right: socket.socket) -> None: + upstream = threading.Thread(target=_copy, args=(left, right), daemon=True) + downstream = threading.Thread(target=_copy, args=(right, left), daemon=True) + upstream.start() + downstream.start() + upstream.join() + downstream.join() + 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, + ) -> None: + if not os.path.isabs(broker_socket): + raise BridgeError("broker socket path must be absolute") + self.broker_socket = broker_socket + 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] = [] + + @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, + ) + self.connections = [ + worker for worker in self.connections if worker.is_alive() + ] + self.connections.append(thread) + thread.start() + + 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) + 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) + for thread in self.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 0000000000..f2446d4c5e --- /dev/null +++ b/security/egress/odysseus_egress_broker.py @@ -0,0 +1,720 @@ +#!/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 +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", +} + + +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 + # 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() + 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: + seen.add(normalized) + targets.append(PublicTarget(family, sockaddr)) + 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: + try: + outbound = connector( + target.family, + target.sockaddr, + CONNECT_TIMEOUT_SECONDS, + ) + except OSError: + continue + try: + peer = outbound.getpeername() + if not isinstance(peer, tuple) or not peer: + raise BrokerError("connected peer address is unavailable") + _public_ip(peer[0]) + 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 _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") + + outbound = connect_public_target( + host, + port, + resolver=resolver, + connector=connector, + ) + try: + path = parsed.path or "/" + if parsed.query: + path = f"{path}?{parsed.query}" + connection_tokens: set[str] = set() + for name, value in headers: + if name.casefold() == "connection": + connection_tokens.update( + token.strip().casefold() + for token in value.split(",") + if token.strip() + ) + 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.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, + ) + self.threads = [worker for worker in self.threads if worker.is_alive()] + self.threads.append(thread) + thread.start() + + 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) + for thread in self.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/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index 97ba954572..869a0f965e 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -116,6 +116,8 @@ async def _ensure_tmux_session( 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( @@ -342,6 +344,16 @@ def _sandbox_setup_failure( "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": ( diff --git a/src/execution_sandbox.py b/src/execution_sandbox.py index 91149a4ad6..de08b64939 100644 --- a/src/execution_sandbox.py +++ b/src/execution_sandbox.py @@ -119,6 +119,10 @@ def network_profile_from_snapshot(value: object) -> SandboxNetworkProfile: _MAX_WORKSPACE_SCAN_ENTRIES = 100_000 _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", @@ -151,6 +155,30 @@ def _trusted_executable(path: str, description: str) -> str: 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( @@ -167,6 +195,22 @@ def _seccomp_launcher_binary() -> str: 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 _normalized_workspace(workspace: str) -> str: if not isinstance(workspace, str) or not workspace.strip(): raise SandboxUnavailable("Sandboxed execution requires a workspace.") @@ -380,16 +424,18 @@ def sandbox_command( if not isinstance(network_profile, SandboxNetworkProfile): raise SandboxUnavailable("Invalid server-owned sandbox network profile.") - if network_profile is SandboxNetworkProfile.BROKERED_ONLY: - raise SandboxUnavailable( - "Brokered Internet was requested, but no trusted sandbox egress " - "bridge is configured. Refusing to expose raw container networking." - ) - 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() root = _normalized_workspace(workspace) - if _is_within(launcher, root): + 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." ) @@ -397,30 +443,33 @@ def sandbox_command( raise SandboxUnavailable( "Sandboxed agent execution requires `/usr/bin/prlimit`." ) - args = [ - launcher, - binary, - "--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", - ] + 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( @@ -469,13 +518,25 @@ def sandbox_command( "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, "--", "/usr/bin/prlimit")) + args.extend(("--chdir", root, "--")) + if bridge is not None: + args.extend((bridge, _BROKER_SOCKET, "--")) + args.extend(("/usr/bin/prlimit",)) args.extend(_SANDBOX_LIMITS) args.extend(("--",)) args.extend(command) diff --git a/tests/test_egress_broker.py b/tests/test_egress_broker.py new file mode 100644 index 0000000000..9da607b0d8 --- /dev/null +++ b/tests/test_egress_broker.py @@ -0,0 +1,745 @@ +"""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 +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", + ], +) +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_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_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_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.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 + + +@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_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.IDLE_TIMEOUT_SECONDS > 0 + assert broker.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") + response = rejected.recv(4096) + rejected.close() + for client in clients: + client.close() + for origin in origins: + origin.close() + transport.close() + + assert b" 503 " in response + + +@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 index 157c157def..abb61a24b4 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -31,6 +31,14 @@ def _stable_bubblewrap_lookup(monkeypatch): "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( @@ -101,6 +109,9 @@ def test_sandbox_argv_is_positive_mount_networkless_by_default_and_clearenv(tmp_ "/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", @@ -151,6 +162,30 @@ def test_trusted_executable_rejects_missing_or_writable_install(monkeypatch): _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() @@ -175,13 +210,68 @@ def test_sandbox_rejects_launcher_workspace_overlap(tmp_path, monkeypatch): sandbox_command(["/bin/true"], workspace=str(workspace)) -def test_brokered_profile_fails_closed_without_trusted_bridge(tmp_path): +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() - with pytest.raises(SandboxUnavailable, match="Brokered Internet"): + 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/bash", "-c", "true"], + ["/bin/true"], workspace=str(workspace), network_profile=SandboxNetworkProfile.BROKERED_ONLY, ) From a9f20e03641850841038a7c3e5a038dd33fc6749 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:09:57 +0000 Subject: [PATCH 10/18] fix(agent): close sandbox hardening review gaps --- Dockerfile | 12 ++ security/egress/odysseus_egress_bridge.py | 103 +++++++++-- security/egress/odysseus_egress_broker.py | 70 ++++++-- security/seccomp/README.md | 2 + security/seccomp/odysseus-seccomp-launcher.c | 25 ++- security/seccomp/policy.json | 6 +- src/agent_tools/subprocess_tools.py | 6 +- src/execution_sandbox.py | 60 ++++++- tests/seccomp_probe.c | 9 +- tests/test_egress_broker.py | 179 ++++++++++++++++++- tests/test_execution_sandbox.py | 91 +++++++++- tests/test_sandbox_network_policy.py | 61 +++++++ tests/test_seccomp_launcher.py | 66 +++++++ tests/test_seccomp_policy.py | 57 ++++++ 14 files changed, 713 insertions(+), 34 deletions(-) diff --git a/Dockerfile b/Dockerfile index 721c907369..ac4c3045fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,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, diff --git a/security/egress/odysseus_egress_bridge.py b/security/egress/odysseus_egress_bridge.py index 23714d5695..2527cbf995 100644 --- a/security/egress/odysseus_egress_bridge.py +++ b/security/egress/odysseus_egress_bridge.py @@ -9,7 +9,8 @@ import subprocess import sys import threading -from typing import Sequence +import time +from typing import Callable, Sequence BROKER_SOCKET = "/run/odysseus-egress/broker.sock" @@ -17,21 +18,40 @@ 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) -> None: +def _copy( + source: socket.socket, + destination: socket.socket, + stop: threading.Event, + touch: Callable[[], None], + expired: Callable[[], bool], + *, + stop_after_eof: bool, +) -> None: try: - while True: - data = source.recv(COPY_BUFFER_BYTES) + 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: - pass + stop.set() finally: try: destination.shutdown(socket.SHUT_WR) @@ -39,13 +59,63 @@ def _copy(source: socket.socket, destination: socket.socket) -> None: pass -def _relay(left: socket.socket, right: socket.socket) -> None: - upstream = threading.Thread(target=_copy, args=(left, right), daemon=True) - downstream = threading.Thread(target=_copy, args=(right, left), daemon=True) +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() - upstream.join() - downstream.join() + 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() @@ -58,10 +128,16 @@ def __init__( 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)) @@ -124,7 +200,12 @@ def _connect(self, client: socket.socket) -> None: self.slots.release() return try: - _relay(client, broker) + _relay( + client, + broker, + idle_timeout=self.idle_timeout, + connection_lifetime=self.connection_lifetime, + ) finally: self.slots.release() diff --git a/security/egress/odysseus_egress_broker.py b/security/egress/odysseus_egress_broker.py index f2446d4c5e..4a77a9c2ff 100644 --- a/security/egress/odysseus_egress_broker.py +++ b/security/egress/odysseus_egress_broker.py @@ -35,6 +35,7 @@ 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 @@ -51,6 +52,14 @@ "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): @@ -85,6 +94,22 @@ def _public_ip(value: object) -> ipaddress.IPv4Address | ipaddress.IPv6Address: 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 ( @@ -126,6 +151,7 @@ def resolve_public_targets( 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") @@ -139,8 +165,13 @@ def resolve_public_targets( _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 @@ -167,6 +198,8 @@ def connect_public_target( """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, @@ -177,9 +210,11 @@ def connect_public_target( continue try: peer = outbound.getpeername() - if not isinstance(peer, tuple) or not peer: + if not isinstance(peer, tuple) or len(peer) < 2: raise BrokerError("connected peer address is unavailable") - _public_ip(peer[0]) + 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 @@ -390,6 +425,28 @@ def _single_header(headers: Sequence[tuple[str, str]], name: str) -> str | None: 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, @@ -471,6 +528,7 @@ def _serve_http( 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, @@ -482,14 +540,6 @@ def _serve_http( path = parsed.path or "/" if parsed.query: path = f"{path}?{parsed.query}" - connection_tokens: set[str] = set() - for name, value in headers: - if name.casefold() == "connection": - connection_tokens.update( - token.strip().casefold() - for token in value.split(",") - if token.strip() - ) forwarded = [f"{method} {path} {version}\r\n"] forwarded.append(f"Host: {_format_authority(host, port)}\r\n") for name, value in headers: diff --git a/security/seccomp/README.md b/security/seccomp/README.md index f341983d4d..555ac2ec25 100644 --- a/security/seccomp/README.md +++ b/security/seccomp/README.md @@ -4,4 +4,6 @@ The payload allowlist is derived deterministically from Moby's default seccomp p `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/odysseus-seccomp-launcher.c b/security/seccomp/odysseus-seccomp-launcher.c index 86b35a01dd..26c03c0a7a 100644 --- a/security/seccomp/odysseus-seccomp-launcher.c +++ b/security/seccomp/odysseus-seccomp-launcher.c @@ -21,7 +21,8 @@ #include "generated_inner_policy.h" -/* Minimal libseccomp ABI copied from seccomp.h.in at de2bf463. */ +/* Minimal libseccomp ABI copied from seccomp.h.in at + * de2bf463afa565e1573f58096167e31eaf6e08b6. */ typedef void *scmp_filter_ctx; enum scmp_compare { @@ -248,10 +249,30 @@ static scmp_filter_ctx build_filter(const struct seccomp_api *api) .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, @@ -296,7 +317,7 @@ static bool valid_bwrap(const char *path) return stat(path, &metadata) == 0 && S_ISREG(metadata.st_mode) && metadata.st_uid == 0 - && (metadata.st_mode & (S_IWGRP | S_IWOTH)) == 0 + && (metadata.st_mode & (S_ISUID | S_ISGID | S_IWGRP | S_IWOTH)) == 0 && access(path, X_OK) == 0; } diff --git a/security/seccomp/policy.json b/security/seccomp/policy.json index 3d3b91878d..eb5d70af8d 100644 --- a/security/seccomp/policy.json +++ b/security/seccomp/policy.json @@ -12,6 +12,7 @@ }, "default_errno": "EPERM", "clone3_errno": "ENOSYS", + "tiocsti_errno": "EACCES", "clone_namespace_mask": 2114060288, "socket_families": [ "AF_UNIX", @@ -102,6 +103,9 @@ "umount2" ], "bubblewrap_version_basis": "v0.11.0", - "bubblewrap_commit": "a871b148b7bc0571f50b917cd5fd03b427f54ed1" + "bubblewrap_tag_object": "a871b148b7bc0571f50b917cd5fd03b427f54ed1", + "bubblewrap_commit": "9ca3b05ec787acfb4b17bed37db5719fa777834f", + "bubblewrap_release_sha256": "988fd6b232dafa04b8b8198723efeaccdb3c6aa9c1c7936219d5791a8b7a8646", + "shipped_package_basis": "0.11.0-2+deb13u1" } } diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index 869a0f965e..be92fd5f9d 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -23,6 +23,7 @@ PROGRESS_INTERVAL_S = 2.0 PROGRESS_TAIL_LINES = 12 TMUX_CAPTURE_LINES = 2000 +_TMUX_ENV_SCRUBBER = "/usr/bin/env" async def _create_bash_subprocess(command: str, **kwargs): @@ -65,6 +66,7 @@ async def _run_exec(*args: str, timeout: float = 10) -> Tuple[str, str, int]: *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env={}, ) try: out_b, err_b = await asyncio.wait_for(proc.communicate(), timeout=timeout) @@ -108,9 +110,11 @@ async def _ensure_tmux_session( if await _tmux_has_session(name): 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, - *shell_argv, + _TMUX_ENV_SCRUBBER, "-i", *shell_argv, timeout=10, ) if not await _tmux_has_session(name): diff --git a/src/execution_sandbox.py b/src/execution_sandbox.py index de08b64939..0a29539879 100644 --- a/src/execution_sandbox.py +++ b/src/execution_sandbox.py @@ -8,6 +8,7 @@ from __future__ import annotations import os +import re import stat import sys from enum import Enum @@ -117,6 +118,8 @@ def network_profile_from_snapshot(value: object) -> SandboxNetworkProfile: } ) _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" @@ -146,11 +149,13 @@ def _trusted_executable(path: str, description: str) -> str: os.path.realpath(path) != path or not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0 - or metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH) + 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, read-only executable at {path}." + f"Trusted {description} is not a root-owned, non-setuid, " + f"read-only executable at {path}." ) return path @@ -263,12 +268,45 @@ def _is_sensitive_file(name: str) -> bool: ) +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): @@ -284,7 +322,8 @@ def _workspace_overlays( path = os.path.join(root, name) folded = name.casefold() relative = os.path.relpath(path, workspace).replace(os.sep, "/").casefold() - if os.path.islink(path) and ( + is_symlink = os.path.islink(path) + if is_symlink and ( folded == ".git" or folded in _SENSITIVE_DIR_NAMES or relative == ".config/gh" @@ -308,6 +347,17 @@ def _workspace_overlays( 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 name.casefold() == ".git" and os.path.islink(path): raise SandboxUnavailable( "Sensitive sandbox path cannot be a symlink: .git" @@ -431,6 +481,10 @@ def sandbox_command( 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: diff --git a/tests/seccomp_probe.c b/tests/seccomp_probe.c index 2bc4839bb6..053efa6987 100644 --- a/tests/seccomp_probe.c +++ b/tests/seccomp_probe.c @@ -146,7 +146,14 @@ int main(int argc, char **argv) #endif #ifdef TIOCSTI if (strcmp(probe, "tiocsti") == 0) { - return expect_errno(ioctl(STDIN_FILENO, TIOCSTI, "x"), EPERM); + 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 diff --git a/tests/test_egress_broker.py b/tests/test_egress_broker.py index 9da607b0d8..08d4ea40a0 100644 --- a/tests/test_egress_broker.py +++ b/tests/test_egress_broker.py @@ -10,6 +10,7 @@ import subprocess import sys import threading +import time from pathlib import Path import pytest @@ -124,6 +125,8 @@ def close(self): "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): @@ -145,6 +148,18 @@ def test_public_ipv4_and_ipv6_destinations_are_allowed(): 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( @@ -154,6 +169,17 @@ def test_mixed_public_private_dns_answer_fails_closed(): ) +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() @@ -172,6 +198,25 @@ def connector(_family, _sockaddr, _timeout): 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() @@ -191,6 +236,7 @@ def connector(_family, sockaddr, _timeout): 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" @@ -257,6 +303,42 @@ def origin_server(): 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", [ @@ -423,12 +505,94 @@ def echo_server(): 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 + while not proxy.connections and time.monotonic() < deadline: + time.sleep(0.01) + assert proxy.connections + connection_thread = proxy.connections[0] + 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.IDLE_TIMEOUT_SECONDS > 0 - assert broker.CONNECTION_LIFETIME_SECONDS < broker.BROKER_LIFETIME_SECONDS + 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): @@ -497,7 +661,10 @@ def connector(_family, _sockaddr, _timeout): rejected = socket.create_connection(transport.bridge.address, timeout=2) rejected.sendall(b"CONNECT public.example:443 HTTP/1.1\r\n\r\n") - response = rejected.recv(4096) + try: + response = rejected.recv(4096) + except ConnectionResetError: + response = b"" rejected.close() for client in clients: client.close() @@ -505,7 +672,11 @@ def connector(_family, _sockaddr, _timeout): origin.close() transport.close() - assert b" 503 " in response + # 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") diff --git a/tests/test_execution_sandbox.py b/tests/test_execution_sandbox.py index abb61a24b4..4c47eeb350 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -158,7 +158,14 @@ def test_trusted_executable_rejects_missing_or_writable_install(monkeypatch): 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, read-only"): + 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") @@ -277,6 +284,22 @@ def missing_broker(): ) +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_sandbox_overlays_credentials_and_protects_git(tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() @@ -301,6 +324,71 @@ def test_sandbox_overlays_credentials_and_protects_git(tmp_path): assert ["--ro-bind", "/dev/null", str(workspace / ".profile")] 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="/") @@ -580,6 +668,7 @@ def status_value(text, name): "af_alg", "af_vsock", "tiocsti", + "tiocsti_high_bits", "userfaultfd", "io_uring_setup", "fork", diff --git a/tests/test_sandbox_network_policy.py b/tests/test_sandbox_network_policy.py index 75a9789c98..9041f2973f 100644 --- a/tests/test_sandbox_network_policy.py +++ b/tests/test_sandbox_network_policy.py @@ -229,3 +229,64 @@ def test_launch_snapshot_does_not_follow_a_later_toggle_change(): 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_launcher.py b/tests/test_seccomp_launcher.py index 9a2047ceed..fa978497af 100644 --- a/tests/test_seccomp_launcher.py +++ b/tests/test_seccomp_launcher.py @@ -37,6 +37,33 @@ def test_launcher(tmp_path_factory): 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: @@ -137,6 +164,45 @@ def test_launcher_injects_filter_and_executes_payload(test_launcher): 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" diff --git a/tests/test_seccomp_policy.py b/tests/test_seccomp_policy.py index ec14e2acea..761b6a3a4a 100644 --- a/tests/test_seccomp_policy.py +++ b/tests/test_seccomp_policy.py @@ -4,6 +4,7 @@ import copy import json +import re import subprocess from pathlib import Path @@ -67,6 +68,7 @@ def test_outer_profile_is_exact_moby_profile_plus_bwrap_bootstrap_rules(): 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 ( @@ -88,6 +90,17 @@ def test_inner_policy_records_required_default_deny_and_conditional_rules(): 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", [ @@ -133,3 +146,47 @@ def test_docker_image_installs_root_owned_launcher_and_libseccomp(): 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", + } From 87b93e657173d20e6b13079ee7063f9acc575edc Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:42:25 +0000 Subject: [PATCH 11/18] fix(agent): tolerate older seccomp syscall tables --- security/seccomp/generate.py | 31 ++++++------- tests/test_seccomp_generator.py | 82 +++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 tests/test_seccomp_generator.py diff --git a/security/seccomp/generate.py b/security/seccomp/generate.py index 2d231faa6f..b1f92832a1 100644 --- a/security/seccomp/generate.py +++ b/security/seccomp/generate.py @@ -152,33 +152,29 @@ def _load_libseccomp() -> ctypes.CDLL: return lib -def _verify_arches(source: dict[str, Any], policy: dict[str, Any]) -> None: +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, moby_arch in policy["target_arches"].items(): + 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}") - # Portable Moby lists legitimately contain negative pseudo syscall - # numbers for calls absent on one architecture. The launcher skips - # those under default-deny. Every argument-sensitive rule that it must - # actively install has to resolve to a native nonnegative number. - names = set(_allowlist_for_arch(source, policy, moby_arch)) - unrecognized = { - name - for name in names - if lib.seccomp_syscall_resolve_name_arch( - token, name.encode("ascii") - ) == -1 - } missing_required = { name for name in required @@ -186,11 +182,10 @@ def _verify_arches(source: dict[str, Any], policy: dict[str, Any]) -> None: token, name.encode("ascii") ) < 0 } - unknown = sorted(unrecognized | missing_required) - if unknown: + if missing_required: raise RuntimeError( f"libseccomp cannot resolve {generated_arch} syscalls: " - + ", ".join(unknown) + + ", ".join(sorted(missing_required)) ) @@ -212,7 +207,7 @@ def main() -> int: policy = _load_json(POLICY) _verify_source(policy) if args.verify_arches: - _verify_arches(source, policy) + _verify_arches(policy) outputs = { GENERATED_HEADER: _render_header(source, policy), diff --git a/tests/test_seccomp_generator.py b/tests/test_seccomp_generator.py new file mode 100644 index 0000000000..c44daa80d4 --- /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) From 1e90ed87e5eb67582d785bea0365865c2ee7a634 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:29:38 +0200 Subject: [PATCH 12/18] fix(agent): route process tools through native sandbox --- src/agent_tools/subprocess_tools.py | 69 ++++------- src/tool_execution.py | 28 +++-- tests/test_process_sandbox_dispatch.py | 152 +++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 56 deletions(-) create mode 100644 tests/test_process_sandbox_dispatch.py diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index be92fd5f9d..895df75fb6 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -3,7 +3,6 @@ import os import re import shutil -import sys import time import collections from typing import Optional, Callable, Awaitable, Tuple, Dict @@ -24,18 +23,11 @@ PROGRESS_TAIL_LINES = 12 TMUX_CAPTURE_LINES = 2000 _TMUX_ENV_SCRUBBER = "/usr/bin/env" +_TMUX_LOCKS: dict[str, asyncio.Lock] = {} 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: @@ -377,7 +369,6 @@ async def execute(self, content: str, ctx: dict) -> dict: if isinstance(content, dict): 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") network_profile = ctx.get( "network_profile", SandboxNetworkProfile.NETWORKLESS @@ -422,35 +413,22 @@ async def execute(self, content: str, ctx: dict) -> dict: return { "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)", "exit_code": rc or 0, - "tmux_session": _tmux_session_name( - str(session_id), - workspace, - network_profile=network_profile, - ), + "tmux_session": tmux_session, } try: - if IS_WINDOWS: - proc = await _create_bash_subprocess( - content, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=subproc_env, - cwd=workspace, - ) - else: - 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=environment_for_sandbox_launcher(), - cwd=workspace, - ) + 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=environment_for_sandbox_launcher(), + cwd=workspace, + ) except (RuntimeError, SandboxUnavailable) as exc: return {"error": f"bash: {exc}", "exit_code": 1, "blocked": True} stdout, stderr, rc, timed_out = await _run_subprocess_streaming( @@ -474,7 +452,6 @@ class PythonTool: async def execute(self, content: str, ctx: dict) -> dict: from src.tool_execution import agent_cwd, _truncate progress_cb = ctx.get("progress_cb") - subproc_env = ctx.get("subproc_env") network_profile = ctx.get( "network_profile", SandboxNetworkProfile.NETWORKLESS ) @@ -489,16 +466,12 @@ async def execute(self, content: str, ctx: dict) -> dict: "blocked": True, } try: - if IS_WINDOWS: - argv = [sys.executable, "-I", "-c", content] - process_env = subproc_env - else: - argv = sandbox_command( - [sandbox_python_executable(), "-I", "-c", content], - workspace=workspace, - network_profile=network_profile, - ) - process_env = environment_for_sandbox_launcher() + argv = sandbox_command( + [sandbox_python_executable(), "-I", "-c", content], + workspace=workspace, + network_profile=network_profile, + ) + process_env = environment_for_sandbox_launcher() except SandboxUnavailable as exc: return {"error": f"python: {exc}", "exit_code": 1, "blocked": True} try: diff --git a/src/tool_execution.py b/src/tool_execution.py index bef52e6db0..bf4fa4d6c5 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -347,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"), @@ -412,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()}, @@ -916,10 +913,25 @@ async def _execute_tool_block_impl( 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( diff --git a/tests/test_process_sandbox_dispatch.py b/tests/test_process_sandbox_dispatch.py new file mode 100644 index 0000000000..c6d590e237 --- /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, + }, + ) From 98cbd6e66a45c03f11b08071984828a4717dac6f Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:29:54 +0200 Subject: [PATCH 13/18] fix(sandbox): reject workspaces containing live SQLite databases --- core/database.py | 75 +++-------------------- src/execution_sandbox.py | 44 ++++++-------- src/sqlite_paths.py | 100 +++++++++++++++++++++++++++++++ tests/test_app_db_permissions.py | 27 +++++++++ tests/test_execution_sandbox.py | 95 +++++++++++++++++++++++++---- 5 files changed, 237 insertions(+), 104 deletions(-) create mode 100644 src/sqlite_paths.py diff --git a/core/database.py b/core/database.py index 65ad403162..6b69cdbb33 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/src/execution_sandbox.py b/src/execution_sandbox.py index 0a29539879..d534687af1 100644 --- a/src/execution_sandbox.py +++ b/src/execution_sandbox.py @@ -14,7 +14,6 @@ from enum import Enum from pathlib import Path from typing import Mapping, Sequence -from urllib.parse import unquote class SandboxUnavailable(RuntimeError): @@ -414,32 +413,27 @@ def _odysseus_data_overlays(workspace: str) -> tuple[list[str], list[str]]: args.extend(("--tmpfs", protected)) hidden_roots.append(protected) - protected_files = {os.path.realpath(APP_DB)} + protected_database_paths = {os.path.realpath(APP_DB)} configured_database = os.environ.get("DATABASE_URL", "").strip() - sqlite_prefix = "sqlite:///" - if configured_database.startswith(sqlite_prefix): - try: - database_path = unquote( - configured_database[len(sqlite_prefix):].split("?", 1)[0] + 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." ) - if ( - database_path - and database_path != ":memory:" - and not database_path.casefold().startswith("file:") - ): - if not os.path.isabs(database_path): - from src.runtime_paths import get_app_root - - database_path = os.path.join(get_app_root(), database_path) - protected_files.add(os.path.realpath(database_path)) - except (TypeError, ValueError): - pass - for protected in sorted(protected_files): - for candidate in (protected, f"{protected}-journal", f"{protected}-shm", f"{protected}-wal"): - if any(_is_within(candidate, hidden) for hidden in hidden_roots): - continue - if _is_within(candidate, workspace) and os.path.isfile(candidate): - args.extend(("--ro-bind", "/dev/null", candidate)) return args, hidden_roots diff --git a/src/sqlite_paths.py b/src/sqlite_paths.py new file mode 100644 index 0000000000..b1b4aa1a97 --- /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/tests/test_app_db_permissions.py b/tests/test_app_db_permissions.py index d6fad8d7e0..a3b5d5c483 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_execution_sandbox.py b/tests/test_execution_sandbox.py index 4c47eeb350..7fa56573a6 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -449,6 +449,7 @@ def test_sandbox_hides_odysseus_data_inside_broader_workspace( 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( [ @@ -460,8 +461,10 @@ def test_sandbox_hides_odysseus_data_inside_broader_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), @@ -474,37 +477,107 @@ def test_sandbox_hides_odysseus_data_inside_broader_workspace( assert completed.returncode == 0, completed.stderr -def test_sandbox_masks_configured_sqlite_database_inside_workspace( +def test_sandbox_rejects_configured_sqlite_database_inside_workspace( tmp_path, monkeypatch, ): workspace = tmp_path / "workspace" workspace.mkdir() database = workspace / "custom.db" - database.write_text("private", encoding="utf-8") monkeypatch.setenv("DATABASE_URL", f"sqlite:///{database}") - argv = sandbox_command(["/bin/true"], workspace=str(workspace)) + with pytest.raises( + SandboxUnavailable, + match="selected workspace contains an Odysseus SQLite database", + ): + sandbox_command(["/bin/true"], workspace=str(workspace)) - triples = [argv[index:index + 3] for index in range(len(argv) - 2)] - assert ["--ro-bind", "/dev/null", str(database)] in triples -def test_sandbox_resolves_relative_configured_sqlite_database( +def test_sandbox_rejects_relative_configured_sqlite_database( tmp_path, monkeypatch, ): workspace = tmp_path / "workspace" workspace.mkdir() - database = workspace / "relative.db" - database.write_text("private", encoding="utf-8") monkeypatch.setenv("DATABASE_URL", "sqlite:///relative.db") monkeypatch.setattr("src.runtime_paths.get_app_root", lambda: str(workspace)) - argv = sandbox_command(["/bin/true"], workspace=str(workspace)) + with pytest.raises( + SandboxUnavailable, + match="selected workspace contains an Odysseus SQLite database", + ): + sandbox_command(["/bin/true"], workspace=str(workspace)) - triples = [argv[index:index + 3] for index in range(len(argv) - 2)] - assert ["--ro-bind", "/dev/null", str(database)] in triples + +@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( From 5f057e8d8fa87baf09ab214c683542e916b0e5bb Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:30:14 +0200 Subject: [PATCH 14/18] fix(sandbox): rotate tmux sessions on policy changes --- src/agent_tools/subprocess_tools.py | 248 ++++++++++++++++++++------- src/execution_sandbox.py | 2 + tests/test_execution_sandbox.py | 254 ++++++++++++++++++++++++++-- 3 files changed, 429 insertions(+), 75 deletions(-) diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index 895df75fb6..13d24d8baa 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -39,11 +39,25 @@ async def _create_bash_subprocess(command: str, **kwargs): return await asyncio.create_subprocess_shell(command, **kwargs) -def _tmux_session_name( +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( @@ -53,6 +67,34 @@ def _tmux_session_name( 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, @@ -80,6 +122,61 @@ 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: + 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 + ): + 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, @@ -159,57 +256,87 @@ async def _run_tmux_bash( timeout: float, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, network_profile: SandboxNetworkProfile = SandboxNetworkProfile.NETWORKLESS, -) -> Tuple[str, str, Optional[int], bool]: - # The launch snapshot is part of the persistent session identity, so a - # tmux shell can never be reused under a different network profile. - name = _tmux_session_name(session_id, cwd, network_profile=network_profile) - shell_argv = sandbox_command( - ["/bin/bash", "--noprofile", "--norc"], - workspace=cwd, +) -> 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, ) - await _ensure_tmux_session(name, 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" + 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: @@ -386,25 +513,24 @@ async def execute(self, content: str, ctx: dict) -> dict: # tmux is a POSIX persistence path. A stray MSYS/Cygwin tmux.exe on # native Windows must not bypass the platform-specific launcher. 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=workspace, - timeout=DEFAULT_BASH_TIMEOUT, - progress_cb=progress_cb, - network_profile=network_profile, - ) + 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} 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_name( - str(session_id), - workspace, - network_profile=network_profile, - ), + "tmux_session": tmux_session, } output = stdout.rstrip() err = stderr.rstrip() diff --git a/src/execution_sandbox.py b/src/execution_sandbox.py index d534687af1..b77499068b 100644 --- a/src/execution_sandbox.py +++ b/src/execution_sandbox.py @@ -309,6 +309,8 @@ def _workspace_overlays( 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( diff --git a/tests/test_execution_sandbox.py b/tests/test_execution_sandbox.py index 7fa56573a6..b2b76419c4 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -40,7 +40,6 @@ def _stable_bubblewrap_lookup(monkeypatch): 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", @@ -75,6 +74,8 @@ def runtime_seccomp_launcher(monkeypatch, compiled_seccomp_launcher, tmp_path): "src.execution_sandbox._seccomp_launcher_binary", lambda: str(compiled_seccomp_launcher), ) + + preflight_workspace = tmp_path / "sandbox-preflight" preflight_workspace.mkdir() completed = subprocess.run( @@ -105,6 +106,8 @@ def test_sandbox_argv_is_positive_mount_networkless_by_default_and_clearenv(tmp_ argv = sandbox_command(["/bin/bash", "-c", "true"], workspace=str(workspace)) + + assert argv[:2] == [ "/usr/local/libexec/odysseus-seccomp-launcher", "/usr/bin/bwrap", @@ -895,16 +898,186 @@ def test_tmux_session_identity_includes_network_policy(tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() - isolated = _tmux_session_name("session-1", str(workspace)) + 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.endswith("-networkless") - assert brokered.endswith("-brokered-only") + 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 @@ -923,26 +1096,24 @@ def test_tmux_bash_shell_runs_inside_same_sandbox( outside = tmp_path / "outside-secret" outside.write_text("secret", encoding="utf-8") session_id = f"sandbox-test-{uuid.uuid4().hex}" - session_name = _tmux_session_name(session_id, str(workspace)) + session_name = None async def run(): + nonlocal session_name try: - return await _run_tmux_bash( + 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: - await _run_exec( - "tmux", - "kill-session", - "-t", - session_name, - timeout=3, - ) + if session_name: + await _run_exec("tmux", "kill-session", "-t", session_name, timeout=3) - stdout, stderr, returncode, timed_out = asyncio.run(run()) + stdout, stderr, returncode, timed_out, session_name = asyncio.run(run()) assert timed_out is False assert returncode == 0, stderr @@ -950,6 +1121,61 @@ async def run(): 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, From 6a893cf2920b2923453332cd2f2a7a90859a6de5 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:30:22 +0200 Subject: [PATCH 15/18] fix(bg-jobs): clean failed sandbox launch artifacts --- src/bg_jobs.py | 199 ++++++++++++++++++---------- tests/test_bg_job_launch_cleanup.py | 177 +++++++++++++++++++++++++ 2 files changed, 306 insertions(+), 70 deletions(-) create mode 100644 tests/test_bg_job_launch_cleanup.py diff --git a/src/bg_jobs.py b/src/bg_jobs.py index cca201f33f..36beccbb73 100644 --- a/src/bg_jobs.py +++ b/src/bg_jobs.py @@ -24,7 +24,6 @@ import json import os -import shlex import subprocess import sys import time @@ -36,8 +35,6 @@ from core.platform_compat import ( IS_WINDOWS, detached_popen_kwargs, - find_bash, - git_bash_path, kill_process_tree, pid_alive, ) @@ -64,6 +61,7 @@ _DETACHED_SANDBOX_WRAPPER = """ import json +import os import subprocess import sys from pathlib import Path @@ -72,8 +70,15 @@ log_path = Path(sys.argv[2]) exit_path = Path(sys.argv[3]) 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: - with log_path.open("wb") as output: + 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, @@ -85,10 +90,13 @@ code = int(completed.returncode) except Exception as exc: try: - log_path.write_text(f"sandbox launch failed: {exc}\\n", encoding="utf-8") + write_private_text(log_path, f"sandbox launch failed: {exc}\\n") except Exception: pass -exit_path.write_text(str(code), encoding="utf-8") +try: + write_private_text(exit_path, str(code)) +except Exception: + pass """.strip() @@ -116,6 +124,74 @@ def _pid_alive(pid: Optional[int]) -> bool: return pid_alive(pid) +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, @@ -133,43 +209,21 @@ def launch( raise RuntimeError( "Sandboxed agent execution requires Linux with bubblewrap." ) - _JOBS_DIR.mkdir(parents=True, exist_ok=True) + _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" + try: + preexisting_paths = set(_JOBS_DIR.glob(f"{job_id}.*")) + except OSError: + preexisting_paths = set() + created_paths: list[Path] = [cmd_path, log_path, exit_path] + proc: subprocess.Popen | None = None + record_saved = False - if IS_WINDOWS: - bash = find_bash() - if bash: - 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(path)) - for path 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: - 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", - ) - argv = [os.environ.get("ComSpec", "cmd.exe"), "/c", str(script_path)] - process_cwd = cwd or None - process_env = None - else: - cmd_path = _JOBS_DIR / f"{job_id}.cmd.sh" - cmd_path.write_text(command + "\n", encoding="utf-8") + try: + _write_private_file(cmd_path, command + "\n") sandbox_argv = sandbox_command( ["/bin/bash", "--noprofile", "--norc", "/run/odysseus/command.sh"], workspace=cwd or "", @@ -185,38 +239,43 @@ def launch( str(log_path), str(exit_path), ] - process_cwd = None - process_env = environment_for_sandbox_launcher() - - proc = subprocess.Popen( - argv, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - cwd=process_cwd, - env=process_env, - **detached_popen_kwargs(), # detach from the request lifecycle (setsid / DETACHED_PROCESS) - ) + proc = subprocess.Popen( + argv, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + cwd=None, + env=environment_for_sandbox_launcher(), + **detached_popen_kwargs(), # detach from the request lifecycle (setsid) + ) - 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, - "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, + "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: diff --git a/tests/test_bg_job_launch_cleanup.py b/tests/test_bg_job_launch_cleanup.py new file mode 100644 index 0000000000..9f9ae96e4d --- /dev/null +++ b/tests/test_bg_job_launch_cleanup.py @@ -0,0 +1,177 @@ +"""Transactional cleanup coverage for detached sandbox launches.""" + +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, "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") + 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 jobs_dir.stat().st_mode & 0o777 == 0o700 + assert command_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" + completed = subprocess.run( + [ + sys.executable, + "-I", + "-c", + bg_jobs._DETACHED_SANDBOX_WRAPPER, + json.dumps([sys.executable, "-I", "-c", "print('ok')"]), + 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 From a67cc16e12e417975caf0bdce5005857f78cc71e Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:30:31 +0200 Subject: [PATCH 16/18] docs(security): correct sandbox procfs boundary --- THREAT_MODEL.md | 2 +- tests/test_execution_sandbox.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 33928db01f..fada75f0f4 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. **Linux sandbox portability.** Agent `bash`, Python, tmux, and detached background commands run through a networkless bubblewrap profile with a cleared environment, private temp/home, a single writable workspace, credential-path overlays, read-only `.git` metadata, resource limits, and explicit hiding of Odysseus data/log roots even when they sit below a broader selected workspace. The Docker image includes bubblewrap. Sandboxed process execution fails closed when that profile is unavailable; a portable equivalent for non-Linux hosts is not implemented yet. The sandbox intentionally omits `/proc`, so commands that require process inspection degrade rather than gaining access to the app process namespace. +1. **Linux sandbox portability.** Agent `bash`, Python, tmux, and detached background commands run through a Bubblewrap profile with a private network namespace, a cleared environment, private temp/home, a single writable workspace, credential-path overlays, read-only `.git` metadata, per-process rlimits, and explicit hiding of Odysseus data/log roots even when they sit below a broader selected workspace. The default profile is networkless; when the existing web toggle is enabled, the brokered-only profile permits HTTP(S) through trusted egress helpers without sharing raw container networking. The Docker image includes Bubblewrap. Sandboxed process execution fails closed when that profile is unavailable; a portable equivalent for non-Linux hosts is not implemented yet. The sandbox mounts a fresh procfs scoped to its private PID namespace. Sandboxed commands can inspect their own sandbox process tree and standard procfs state, but do not receive the Odysseus application or host PID namespace. Aggregate process-tree quotas via a delegated cgroup v2 subtree are not established by the shipped runtime and remain a separate security follow-up. 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/tests/test_execution_sandbox.py b/tests/test_execution_sandbox.py index b2b76419c4..e6886a5cb3 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -622,6 +622,7 @@ def test_sandbox_hides_host_and_environment_at_runtime( "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" ) From 251cacd14ab8f6c243f06cd4f12567aae0959dcb Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:12:35 +0200 Subject: [PATCH 17/18] feat(agent): add capability-gated process authority modes --- THREAT_MODEL.md | 2 +- app.py | 32 +++ routes/auth_routes.py | 73 +++++ security/egress/odysseus_egress_bridge.py | 16 +- security/egress/odysseus_egress_broker.py | 12 +- src/agent_tools/subprocess_tools.py | 327 +++++++++++++++++++--- src/bg_jobs.py | 106 +++++-- src/bg_monitor.py | 2 + src/execution_sandbox.py | 176 ++++++++++-- src/process_execution.py | 275 ++++++++++++++++++ src/tool_execution.py | 3 + static/index.html | 21 ++ static/js/settings.js | 185 ++++++++++++ tests/test_bg_job_launch_cleanup.py | 74 ++++- tests/test_egress_broker.py | 12 +- tests/test_execution_sandbox.py | 98 ++++++- tests/test_process_execution_mode.py | 275 ++++++++++++++++++ tests/test_process_execution_routes.py | 129 +++++++++ 18 files changed, 1725 insertions(+), 93 deletions(-) create mode 100644 src/process_execution.py create mode 100644 tests/test_process_execution_mode.py create mode 100644 tests/test_process_execution_routes.py diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index fada75f0f4..27b84209d6 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. **Linux sandbox portability.** Agent `bash`, Python, tmux, and detached background commands run through a Bubblewrap profile with a private network namespace, a cleared environment, private temp/home, a single writable workspace, credential-path overlays, read-only `.git` metadata, per-process rlimits, and explicit hiding of Odysseus data/log roots even when they sit below a broader selected workspace. The default profile is networkless; when the existing web toggle is enabled, the brokered-only profile permits HTTP(S) through trusted egress helpers without sharing raw container networking. The Docker image includes Bubblewrap. Sandboxed process execution fails closed when that profile is unavailable; a portable equivalent for non-Linux hosts is not implemented yet. The sandbox mounts a fresh procfs scoped to its private PID namespace. Sandboxed commands can inspect their own sandbox process tree and standard procfs state, but do not receive the Odysseus application or host PID namespace. Aggregate process-tree quotas via a delegated cgroup v2 subtree are not established by the shipped runtime and remain a separate security follow-up. +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 bb4f51ffbe..f1192940ea 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/routes/auth_routes.py b/routes/auth_routes.py index a35d466c74..4288865edd 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/security/egress/odysseus_egress_bridge.py b/security/egress/odysseus_egress_bridge.py index 2527cbf995..70c654021a 100644 --- a/security/egress/odysseus_egress_bridge.py +++ b/security/egress/odysseus_egress_bridge.py @@ -147,6 +147,7 @@ def __init__( 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]: @@ -184,11 +185,12 @@ def _accept(self) -> None: args=(client,), daemon=True, ) - self.connections = [ - worker for worker in self.connections if worker.is_alive() - ] - self.connections.append(thread) - thread.start() + 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) @@ -217,7 +219,9 @@ def close(self) -> None: pass if self.accept_thread is not None: self.accept_thread.join(timeout=1.0) - for thread in self.connections: + with self.connections_lock: + connections = list(self.connections) + for thread in connections: thread.join(timeout=0.2) diff --git a/security/egress/odysseus_egress_broker.py b/security/egress/odysseus_egress_broker.py index 4a77a9c2ff..2cf28d5b45 100644 --- a/security/egress/odysseus_egress_broker.py +++ b/security/egress/odysseus_egress_broker.py @@ -614,6 +614,7 @@ def __init__( 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: @@ -638,9 +639,10 @@ def _accept(self) -> None: args=(client,), daemon=True, ) - self.threads = [worker for worker in self.threads if worker.is_alive()] - self.threads.append(thread) - thread.start() + 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: @@ -660,7 +662,9 @@ def close(self) -> None: pass if self.accept_thread is not None: self.accept_thread.join(timeout=1.0) - for thread in self.threads: + with self.threads_lock: + threads = list(self.threads) + for thread in threads: thread.join(timeout=0.2) diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index 13d24d8baa..cdd22864de 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -3,6 +3,7 @@ import os import re import shutil +import sys import time import collections from typing import Optional, Callable, Awaitable, Tuple, Dict @@ -12,9 +13,17 @@ 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 @@ -24,6 +33,7 @@ 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): @@ -141,6 +151,7 @@ async def _tmux_session_names() -> list[str]: 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 ( @@ -148,6 +159,7 @@ async def _tmux_kill_session(name: str) -> None: 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()}") @@ -197,8 +209,16 @@ async def _ensure_tmux_session( 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 + 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( @@ -218,6 +238,7 @@ async def _ensure_tmux_session( "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) @@ -493,26 +514,116 @@ def _sandbox_setup_failure( 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") session_id = ctx.get("session_id") network_profile = ctx.get( "network_profile", SandboxNetworkProfile.NETWORKLESS ) workspace = agent_cwd() - if IS_WINDOWS: + 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 — " + "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("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 + ) return { - "error": ( - "bash: Sandboxed agent execution requires Linux with " - "bubblewrap." + "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" ), - "exit_code": 1, - "blocked": True, + "warning": FULL_ACCESS_WARNING, } - # tmux is a POSIX persistence path. A stray MSYS/Cygwin tmux.exe on - # native Windows must not bypass the platform-specific launcher. - if session_id and not IS_WINDOWS and shutil.which("tmux"): + + 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, @@ -523,23 +634,37 @@ async def execute(self, content: str, ctx: dict) -> dict: network_profile=network_profile, ) except (OSError, RuntimeError, SandboxUnavailable) as exc: - return {"error": f"bash: {exc}", "exit_code": 1, "blocked": True} + 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", + "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 + 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, + "tmux_session": tmux_session, + "execution_mode": execution_mode.value, } try: @@ -555,74 +680,198 @@ async def execute(self, content: str, ctx: dict) -> dict: env=environment_for_sandbox_launcher(), cwd=workspace, ) - except (RuntimeError, SandboxUnavailable) as exc: - return {"error": f"bash: {exc}", "exit_code": 1, "blocked": True} + 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") network_profile = ctx.get( "network_profile", SandboxNetworkProfile.NETWORKLESS ) workspace = agent_cwd() - if IS_WINDOWS: + 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 { - "error": ( - "python: Sandboxed agent execution requires Linux with " - "bubblewrap." + "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" ), - "exit_code": 1, - "blocked": True, + "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, ) - process_env = environment_for_sandbox_launcher() - except SandboxUnavailable as exc: - return {"error": f"python: {exc}", "exit_code": 1, "blocked": True} - try: proc = await asyncio.create_subprocess_exec( *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - env=process_env, + env=environment_for_sandbox_launcher(), cwd=workspace, ) - except (OSError, RuntimeError) as exc: - return {"error": f"python: {exc}", "exit_code": 1, "blocked": True} + 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 36beccbb73..6844a95de8 100644 --- a/src/bg_jobs.py +++ b/src/bg_jobs.py @@ -14,14 +14,17 @@ * 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. Model commands execute inside the same -Linux bubblewrap profile as foreground Bash; a tiny isolated Python wrapper -outside the sandbox only records output and the exit code. 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 subprocess @@ -43,8 +46,15 @@ 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) @@ -60,15 +70,17 @@ _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 -argv = json.loads(sys.argv[1]) -log_path = Path(sys.argv[2]) -exit_path = Path(sys.argv[3]) +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): @@ -77,6 +89,17 @@ def write_private_text(path, text): 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( @@ -84,13 +107,13 @@ def write_private_text(path, text): stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT, - env={}, + env=child_env, check=False, ) code = int(completed.returncode) except Exception as exc: try: - write_private_text(log_path, f"sandbox launch failed: {exc}\\n") + write_private_text(log_path, f"process launch failed: {exc}\\n") except Exception: pass try: @@ -214,28 +237,56 @@ def launch( 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, log_path, exit_path] + created_paths: list[Path] = [cmd_path, plan_path, log_path, exit_path] proc: subprocess.Popen | None = None record_saved = False try: _write_private_file(cmd_path, command + "\n") - sandbox_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, - ) + 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, - json.dumps(sandbox_argv), + str(plan_path), + plan_digest, str(log_path), str(exit_path), ] @@ -245,7 +296,7 @@ def launch( stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, cwd=None, - env=environment_for_sandbox_launcher(), + env=wrapper_environment, **detached_popen_kwargs(), # detach from the request lifecycle (setsid) ) @@ -260,6 +311,17 @@ def launch( "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), @@ -413,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 fd2e42db75..7fb20852a7 100644 --- a/src/bg_monitor.py +++ b/src/bg_monitor.py @@ -149,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/execution_sandbox.py b/src/execution_sandbox.py index b77499068b..b480a588f7 100644 --- a/src/execution_sandbox.py +++ b/src/execution_sandbox.py @@ -93,8 +93,21 @@ def network_profile_from_snapshot(value: object) -> SandboxNetworkProfile: ".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", @@ -127,12 +140,11 @@ def network_profile_from_snapshot(value: object) -> SandboxNetworkProfile: _BROKER_PROXY_URL = "http://127.0.0.1:3128" _CA_CERTIFICATE = "/etc/ssl/certs/ca-certificates.crt" _SANDBOX_LIMITS = ( - "--as=4294967296", + "--as=4294967296", # 4 GiB virtual address space per process "--core=0", - "--cpu=900", - "--fsize=1073741824", - "--nofile=256", - "--nproc=256", + "--cpu=3600", # one hour of CPU time per process + "--fsize=4294967296", # 4 GiB per output file + "--nofile=1024", ) @@ -215,23 +227,53 @@ def _egress_bridge_binary() -> str: return _trusted_python_helper(_TRUSTED_EGRESS_BRIDGE, "egress bridge") -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)) - home_roots = { +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 resolved in home_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 sandbox workspace: {resolved}" + f"Refusing broad or login-profile sandbox workspace: {resolved}" ) try: Path(resolved).mkdir(mode=0o700, parents=True, exist_ok=True) @@ -359,6 +401,10 @@ def _workspace_overlays( 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" @@ -586,13 +632,111 @@ def sandbox_command( args.extend(("--chdir", root, "--")) if bridge is not None: args.extend((bridge, _BROKER_SOCKET, "--")) - args.extend(("/usr/bin/prlimit",)) - args.extend(_SANDBOX_LIMITS) - args.extend(("--",)) - args.extend(command) + 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 0000000000..a9086000e7 --- /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/tool_execution.py b/src/tool_execution.py index bf4fa4d6c5..381abb6143 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -909,7 +909,10 @@ 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 diff --git a/static/index.html b/static/index.html index 4dd4c67954..83df358e7b 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 09552b26c5..9d61bf7395 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/test_bg_job_launch_cleanup.py b/tests/test_bg_job_launch_cleanup.py index 9f9ae96e4d..ef7a5b94df 100644 --- a/tests/test_bg_job_launch_cleanup.py +++ b/tests/test_bg_job_launch_cleanup.py @@ -1,5 +1,6 @@ """Transactional cleanup coverage for detached sandbox launches.""" +import hashlib import json import os import subprocess @@ -32,6 +33,27 @@ def launch_context(tmp_path, monkeypatch): 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 @@ -128,13 +150,17 @@ def test_successful_launch_persists_record_and_private_command_file( 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): @@ -155,13 +181,23 @@ def test_command_file_creation_is_exclusive(launch_context, monkeypatch): 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, - json.dumps([sys.executable, "-I", "-c", "print('ok')"]), + str(plan_path), + hashlib.sha256(plan_bytes).hexdigest(), str(log_path), str(exit_path), ], @@ -175,3 +211,39 @@ def test_detached_wrapper_writes_success_exit_artifact(tmp_path): 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 index 08d4ea40a0..17b6ab3399 100644 --- a/tests/test_egress_broker.py +++ b/tests/test_egress_broker.py @@ -523,10 +523,14 @@ def close_connections(): client = socket.create_connection(proxy.address, timeout=2) deadline = time.monotonic() + 2 - while not proxy.connections and time.monotonic() < deadline: - time.sleep(0.01) - assert proxy.connections - connection_thread = proxy.connections[0] + 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() diff --git a/tests/test_execution_sandbox.py b/tests/test_execution_sandbox.py index e6886a5cb3..1c5c2f7964 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -16,7 +16,9 @@ SandboxNetworkProfile, SandboxUnavailable, environment_for_sandbox_launcher, + full_access_command, sandbox_command, + sandbox_python_executable, ) @@ -131,8 +133,11 @@ def test_sandbox_argv_is_positive_mount_networkless_by_default_and_clearenv(tmp_ ] assert "--clearenv" in argv assert "/usr/bin/prlimit" in argv - assert "--nproc=256" in argv - assert "--as=4294967296" 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=8589934592" in argv assert ["--ro-bind", "/", "/"] not in [ argv[index:index + 3] for index in range(len(argv) - 2) ] @@ -303,6 +308,38 @@ def test_brokered_profile_fails_closed_without_ca_bundle(tmp_path, monkeypatch): ) +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() @@ -311,6 +348,7 @@ def test_sandbox_overlays_credentials_and_protects_git(tmp_path): (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)) @@ -325,6 +363,7 @@ def test_sandbox_overlays_credentials_and_protects_git(tmp_path): 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): @@ -407,6 +446,59 @@ def test_sandbox_rejects_the_process_home_as_workspace(tmp_path, monkeypatch): 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() @@ -666,7 +758,7 @@ def test_sandbox_network_namespace_has_no_external_route( "\nraise SystemExit(1)" ) argv = sandbox_command( - ["/usr/bin/python3", "-I", "-c", code], + [sandbox_python_executable(), "-I", "-c", code], workspace=str(workspace), ) diff --git a/tests/test_process_execution_mode.py b/tests/test_process_execution_mode.py new file mode 100644 index 0000000000..d40789dd85 --- /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 0000000000..b04629fa36 --- /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 From b3994bd396b8eb35fa66df252006ad0a6f66ba19 Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:20:18 +0200 Subject: [PATCH 18/18] fix(sandbox): align process failure contracts --- src/agent_tools/subprocess_tools.py | 4 ++-- tests/test_execution_sandbox.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index cdd22864de..072df9bf1b 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -610,7 +610,7 @@ async def execute(self, content: str, ctx: dict) -> dict: return blocked_process_result( "bash", execution_mode, - "Sandbox mode requires Linux with Bubblewrap.", + "Sandbox mode requires Linux with bubblewrap.", ) capability = process_capability().sandbox if not capability.supports(network_profile): @@ -814,7 +814,7 @@ async def execute(self, content: str, ctx: dict) -> dict: return blocked_process_result( "python", execution_mode, - "Sandbox mode requires Linux with Bubblewrap.", + "Sandbox mode requires Linux with bubblewrap.", ) capability = process_capability().sandbox if not capability.supports(network_profile): diff --git a/tests/test_execution_sandbox.py b/tests/test_execution_sandbox.py index 1c5c2f7964..5d8a5fa7e0 100644 --- a/tests/test_execution_sandbox.py +++ b/tests/test_execution_sandbox.py @@ -137,7 +137,7 @@ def test_sandbox_argv_is_positive_mount_networkless_by_default_and_clearenv(tmp_ assert "--cpu=3600" in argv assert "--fsize=4294967296" in argv assert "--nofile=1024" in argv - assert "--as=8589934592" in argv + assert "--as=4294967296" in argv assert ["--ro-bind", "/", "/"] not in [ argv[index:index + 3] for index in range(len(argv) - 2) ]