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.
This commit is contained in:
RaresKeY 2026-07-25 16:21:57 +00:00
parent e6323b1839
commit 8df21413e8
8 changed files with 729 additions and 91 deletions

View file

@ -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 \

View file

@ -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.

View file

@ -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,

View file

@ -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)
)

View file

@ -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")

334
src/execution_sandbox.py Normal file
View file

@ -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 {}

View file

@ -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 <repo_root>/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 = {

View file

@ -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()