mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
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:
parent
e6323b1839
commit
8df21413e8
8 changed files with 729 additions and 91 deletions
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
103
src/bg_jobs.py
103
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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
334
src/execution_sandbox.py
Normal 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 {}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
264
tests/test_execution_sandbox.py
Normal file
264
tests/test_execution_sandbox.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue