diff --git a/Dockerfile b/Dockerfile index 3732d20a6..545de9398 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 48665a61d..5616f1ae3 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 15041c76e..9e7731704 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -1,12 +1,17 @@ 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 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 @@ -16,9 +21,12 @@ PROGRESS_TAIL_LINES = 12 TMUX_CAPTURE_LINES = 2000 -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]: @@ -61,19 +69,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): @@ -113,12 +119,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}__" @@ -278,14 +287,13 @@ class BashTool: 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() if session_id 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, ) @@ -295,7 +303,7 @@ class BashTool: "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() @@ -304,15 +312,19 @@ class BashTool: 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), } - proc = await asyncio.create_subprocess_shell( - content, + 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=_subproc_env, - cwd=agent_cwd(), + env=environment_for_sandbox_launcher(), + cwd=workspace, ) stdout, stderr, rc, timed_out = await _run_subprocess_streaming( proc, @@ -332,13 +344,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 f864f8ef1..349219a90 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 @@ Design goals: * 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 typing import Any, Dict, List, Optional 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 @@ _MAX_OUTPUT_CHARS = 16000 # 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 584494290..87818943c 100644 --- a/src/constants.py +++ b/src/constants.py @@ -9,6 +9,7 @@ APP_VERSION = "1.0.2" # 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 @@ EMOJI_CACHE_DIR = os.path.join(DATA_DIR, "emoji_cache") RAG_DIR = os.path.join(DATA_DIR, "rag") CHROMA_DIR = os.path.join(DATA_DIR, "chroma") BG_JOBS_DIR = os.path.join(DATA_DIR, "bg_jobs") +AGENT_WORKSPACE_DIR = os.path.join(DATA_DIR, "agent_workspace") DEEP_RESEARCH_DIR = os.path.join(DATA_DIR, "deep_research") MCP_OAUTH_DIR = os.path.join(DATA_DIR, "mcp_oauth") GENERATED_IMAGES_DIR = os.path.join(DATA_DIR, "generated_images") diff --git a/src/execution_sandbox.py b/src/execution_sandbox.py new file mode 100644 index 000000000..5b4c9970c --- /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 c7633fdde..e447c26d3 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -29,15 +29,19 @@ from src.tool_security import ( ) from src.tool_capabilities import ToolRunSecurityContext, blocked_tool_result 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 -# 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 @@ -524,18 +528,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, } @@ -739,7 +734,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 000000000..96a45c17d --- /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()