fix(runtime): enforce workspace confinement in one place

"Is this path inside that root" is asked in twenty places in this tree and
answered twenty times by a locally written realpath/commonpath pair. Nine test
files exist because nine call sites each needed their own proof. Each one is
defensible alone; together they are the defect, because the boundary has no
single definition and a site that gets a detail wrong is wrong by itself.

src/path_confinement.py is that definition, and it settles the details the
copies disagreed on. Both sides get canonicalized: comparing a realpath-ed
candidate against a root that was only abspath-ed is the macOS /tmp ->
/private/tmp mismatch that has already produced a false failure here, and
canonicalizing one side is worse than canonicalizing neither. commonpath rather
than startswith, because /a/bc begins with /a/b and is not inside it. A relative
candidate joins the root rather than os.getcwd(), which is whatever directory
the server happens to be running in. NUL and newline are refused with a reason
instead of caught by a bare `except Exception` and reported as an ordinary
escape. Eighteen call sites go through it now. It deliberately does not decide
whether a path is sensitive -- that deny list answers "allowed" rather than
"inside", and it stays with src/tool_execution, which owns it. The one
commonpath left in the tree, in src/workspace_paths.py, stays: that function
translates a host path into a container path, so canonicalizing either side
would change the relative path it computes and break the mapping. It is not a
confinement check.

Two of those sites were weaker than the rest and are fixed rather than moved.
The email attachment check used abspath, which folds `..` but does not resolve
symlinks, so a symlink written into the extraction directory passed it and was
then read through. The skill-reference guard compared a realpath-ed target
against a raw dirname, so on a host where the skills tree is reached through a
symlink the two sides never matched and the guard could not fire.

The execution boundary had two separate holes.

The workspace namespace bound /home and /mnt read-write. On the one platform
where that namespace engages at all, a command inside it reaches outside the
workspace and writes to the user's home directory -- measured by running this
argv on a Linux host with working bubblewrap, not inferred from the source.
Binding the user's whole home directory into a workspace-confinement namespace
gives back most of what the namespace was for. Both are read-only now. The
workspace is also bound writable at its real host path, not only at /workspace:
BashTool's own /tmp redirect rewrites `/tmp/` to `<agent_cwd()>/.tmp/` before
the namespace is built, so the command bwrap receives already names the real
path, and those writes previously landed only because the workspace happened to
sit under the writable /home.

`namespaced or _replace_workspace_alias(...)` chose between a mount namespace
and a regex with nothing in the result saying which one ran. The fallback
rewrites the literal token /workspace in the command string, so a command that
never mentions /workspace is untouched by it and runs on the host unrestricted
-- which is every agent shell command on macOS. Both tools now ask
containment.probe() instead of each deciding for itself, and every bash and
python result carries a containment block naming the mechanism and stating
whether the filesystem dimension actually held. Under enforcing mode the
command is not run and the result says so.

That block reports the filesystem dimension only, and says so in a
reported_dimensions field. The probe knows this host could also give a process
group and a real wall clock, but these two tools still assemble their own
create_subprocess_* call and pass neither, so listing those dimensions would be
exactly the false claim src/containment.py calls worse than an honest absence.

probe() is new on src/containment.py: the same mechanism table and the same
arithmetic as acquire(), stopping before the side effects. acquire() is the
wrong shape for a decision -- it writes a durable grant record, and a record
whose pid is never filled in and whose release() never runs is an entry a
restart reaper keeps finding.

CONTAINMENT_MODE stays report_only. Flipping it refuses every agent shell
command on macOS and on any Linux host without bubblewrap, which is a product
decision rather than a code one.

Smaller things in the same area: the /tmp redirect's makedirs was unguarded, so
a read-only workspace turned a command that merely mentioned `/tmp/` into an
OSError traceback instead of a tool error; it degrades now. WORKSPACE_MOUNT
moved to src/constants.py so the namespace and the path resolvers read one
definition of the contract rather than two. The ".tmp" dirname got a constant,
since it appeared in both tool paths.

One generated artifact moved with it: website/configuration-reference.md pins
the source line where each ODYSSEUS_* variable is read, and three of those
shifted. Regenerated with scripts/generate_env_reference.py; the diff is line
numbers only.

Three existing tests changed. test_workspace_artifact_tool_floor asserted that
an unsafe interpreter prefix produces no `--ro-bind <prefix> <prefix>`, which
now fires on /home because /home is legitimately a read-only base mount.
Asserting the absence of a literal flag string cannot distinguish "the prefix
was rejected" from "the argv mounted that root itself", so it compares the argv
against the no-prefix baseline instead: an unsafe prefix must add nothing.

The Windows bash test asserted dict equality on the
whole result, which makes adding a field to every bash result impossible without
touching a test about tmux; it asserts the shape now. The personal-dir symlink
test grepped the resolver's source for the literal "os.path.realpath", which is
gone because the resolution moved into the shared boundary -- it keeps the
negative assertion that the closure must not grow its own abspath check again,
and the behavioural half now runs against the boundary, where it covers every
call site instead of one closure.

Not verified: the bubblewrap argv is asserted, not executed. There is no bwrap
on macOS, and in Docker it needs --privileged to work at all -- default and
seccomp=unconfined both fail with "Creating new namespace failed", and
--cap-add=SYS_ADMIN fails at pivot_root. The Python tool's
needs_virtual_namespace gate means ordinary Python code gets no namespace even
on a Linux host that could provide one; that is reported now but deliberately
not changed, because it alters the Linux Python path on every call and cannot be
checked from here.
This commit is contained in:
Léo
2026-10-01 19:45:59 +02:00
parent c004a26d46
commit 2a540f2acc
22 changed files with 1214 additions and 173 deletions
+2 -7
View File
@@ -9,6 +9,7 @@ import tempfile
from typing import Optional, Dict, Any, Tuple, List
from src.constants import MAX_READ_CHARS, MAX_DIFF_LINES, MAX_OUTPUT_CHARS
from src.path_confinement import is_inside
_CODENAV_SKIP_DIRS = frozenset({
".git", ".hg", ".svn", "node_modules", "venv", ".venv", "__pycache__",
@@ -675,13 +676,7 @@ class GlobTool:
# confinement that _resolve_search_root applies to the root.
# An escaping literal falls through to the walk, which only ever
# yields paths under base.
nbase = os.path.normcase(rbase)
try:
inside = cand == rbase or os.path.commonpath(
[os.path.normcase(cand), nbase]
) == nbase
except ValueError:
inside = False
inside = is_inside(rbase, cand)
# A literal that names a deny-listed sensitive file (.env,
# .ssh/id_rsa, …) falls through to the walk, which skips it —
# otherwise glob would surface secret paths that read_file /
+284 -35
View File
@@ -1,5 +1,6 @@
import asyncio
import ast
import logging
import os
import re
import shlex
@@ -15,9 +16,12 @@ from urllib.parse import urlparse
import httpx
from src.constants import MAX_OUTPUT_CHARS
from src import containment
from src.constants import AGENT_ISOLATED_TMP_DIRNAME, MAX_OUTPUT_CHARS, WORKSPACE_MOUNT
from src.agent_runtime.journal import mark_operation_started
logger = logging.getLogger(__name__)
# Agent shell calls must fail fast enough for the loop to recover and choose a
# better tool. A one-hour default can pin an entire benchmark worker on an
# accidental recursive scan, even though ordinary artifact commands complete
@@ -227,11 +231,221 @@ def _replace_workspace_alias(content: str, cwd: str) -> str:
)
#: Roots the namespace argv mounts itself. A host path under one of these is
#: already reachable inside the namespace, so it needs no bind and must not get
#: a ``--dir`` chain: mkdir inside a read-only bind fails and takes the whole
#: namespace with it.
_NAMESPACE_MOUNTED_ROOTS = ("/usr", "/home", "/mnt")
#: Destinations a bind must never overlay. Replacing the private root, the
#: private /tmp or the workspace mount with a host directory undoes the
#: namespace from inside the argv that builds it.
_NAMESPACE_RESERVED_DESTS = frozenset({
"/", "/tmp", "/var", "/opt", "/etc", WORKSPACE_MOUNT,
"/root", "/run", "/proc", "/dev", "/sys", *_NAMESPACE_MOUNTED_ROOTS,
})
def _namespace_visible_without_bind(path: str) -> bool:
"""True when ``path`` is already reachable through a root the argv mounts."""
return any(
path == root or path.startswith(root + os.sep)
for root in _NAMESPACE_MOUNTED_ROOTS
)
def _namespace_dir_chain(path: str) -> list[str]:
"""``--dir`` args for every ancestor of ``path`` the argv has to create.
bwrap mounts into a tmpfs root, so a bind destination's parents have to
exist before the bind. Returns nothing when the parents already exist by
virtue of a mount the argv made — creating a directory inside a read-only
bind is an error, not a no-op.
"""
if _namespace_visible_without_bind(path):
return []
parents: list[str] = []
parent = os.path.dirname(path)
while parent not in ("/", "", "/tmp", "/etc", WORKSPACE_MOUNT, *_NAMESPACE_MOUNTED_ROOTS):
parents.append(parent)
parent = os.path.dirname(parent)
args: list[str] = []
for directory in reversed(parents):
args.extend(("--dir", directory))
return args
def _isolated_tmp_dir(cwd: str) -> str:
"""The workspace-local stand-in for the host ``/tmp``.
Creation is best-effort: the source tree is read-only in Docker and a
workspace can be mounted read-only, and a command that mentions ``/tmp/``
must not die with an OSError traceback because a scratch directory could
not be made. The rewrite still points at the workspace, so a command that
really needs to write there fails on its own terms, inside the boundary,
with its own error message.
"""
path = os.path.join(cwd, AGENT_ISOLATED_TMP_DIRNAME)
try:
os.makedirs(path, exist_ok=True)
except OSError:
pass
return path
def _execution_boundary(
cwd: str, *, wall_clock_s: int = DEFAULT_BASH_TIMEOUT,
) -> "containment.ContainmentProbe":
"""What this host can actually enforce for an agent command in ``cwd``.
The single place the shell and Python tools ask. Both used to decide for
themselves, by testing whether a namespace wrapper came back non-None, and
both then fell through to a regex if it had not — so "was that command
confined" had no answer and no field in the result. Routing the question
through :mod:`src.containment` means one mechanism table, one answer, and a
``containment`` block in the tool result either way.
``network`` is left inherited on purpose: ``--unshare-net`` was measured to
cut the loopback sidecars this product depends on (ChromaDB on 8100), and
the Dockerfile installs ``nmap``/``iproute2``/``dnsutils`` because
Docker-hosted agents are expected to do LAN work. It is a reported
dimension here, not an enforced one.
"""
try:
return containment.probe(
containment.agent_spec(
workspace=cwd,
env={},
wall_clock_s=wall_clock_s,
max_output_bytes=MAX_OUTPUT_CHARS,
)
)
except ValueError as exc:
# A workspace that is not a usable directory is a caller bug to
# containment, which raises rather than reporting. Here it must not
# take out the tool, and it is still a containment failure: nothing can
# be confined to a directory that is not there. Fail closed. The reason
# goes in the message rather than a traceback -- this is a known shape,
# not an unexpected exception.
logger.warning(
"execution boundary: cannot probe containment for workspace %r (%s); "
"treating every required dimension as unenforced",
cwd, exc,
)
return containment.ContainmentProbe(
mechanism="none",
enforced=frozenset(),
degraded=(),
unenforced_required=tuple(sorted(containment.DEFAULT_REQUIRED)),
mode=containment.CONTAINMENT_MODE,
)
#: What the fallback actually is, named so it cannot be mistaken for a
#: mechanism. ``_replace_workspace_alias`` rewrites the literal token
#: ``/workspace`` to the real path in the command string; a command that never
#: mentions ``/workspace`` is untouched by it and runs on the host unrestricted.
ALIAS_REWRITE_MECHANISM = "workspace_alias_rewrite"
#: Guards the one-per-process fallback warning below. Module state, because the
#: fact it reports is a property of the host rather than of a command.
_ALIAS_FALLBACK_LOGGED = False
def _filesystem_boundary_block(mechanism: str, mode: str, *, confined: bool) -> dict:
"""The ``containment`` block for a spawn these tools still build themselves.
Reports the **filesystem dimension only**, deliberately. The probe knows
this host could also give a process group and a real wall clock, but
BashTool and PythonTool still assemble their own ``create_subprocess_*``
call and pass neither ``start_new_session`` nor a group-wide kill, so
listing those dimensions here would be the false claim
:mod:`src.containment` calls worse than an honest absence. They arrive when
this spawn path moves onto :func:`containment.run`, not before.
"""
return {
"mechanism": mechanism,
"mode": mode,
"enforced": [containment.FILESYSTEM] if confined else [],
"unenforced_required": [] if confined else [containment.FILESYSTEM],
"contained": confined,
"executed": True,
# Names the scope of the claim, so "process_tree is absent from
# enforced" reads as "not reported here" rather than "not enforced".
"reported_dimensions": [containment.FILESYSTEM],
}
def _contained_command(
content: str,
cwd: str,
*,
chdir: str = WORKSPACE_MOUNT,
interpreter_prefix: str | None = None,
) -> tuple[str, dict, bool]:
"""Resolve ``content`` into the strongest form this host can run.
Returns ``(command, containment_block, confined)``. The caller spawns
``command``, copies ``containment_block`` into its result verbatim, and
refuses instead when ``confined`` is false under enforcing mode.
This replaces ``namespaced or _replace_workspace_alias(...)``, the line this
ticket exists to delete. The two branches it chose between are not
comparable — one is a mount namespace, the other is a regex — and choosing
the second silently means an uncontained host execution reads in the
transcript exactly like a contained one. The fallback still happens under
report-only mode, which is what ships; the difference is that it is now
recorded in the result.
:raises containment.ContainmentUnavailable: filesystem containment could
not be established and the mode is enforcing. The command is not run.
"""
probe = _execution_boundary(cwd)
wrapped = _wrap_workspace_namespace(
content, cwd, chdir=chdir, interpreter_prefix=interpreter_prefix,
)
# The probe's filesystem answer and the wrapper's None/not-None answer rest
# on the same condition (`not IS_WINDOWS and which("bwrap")`), so they agree
# by construction. `wrapped` is still what decides, because it is what
# actually runs: a probe that said yes to a wrapper that declined would be
# the same false claim in the other direction.
if wrapped is not None:
return wrapped, _filesystem_boundary_block(
probe.mechanism, probe.mode, confined=True,
), True
if probe.mode == containment.MODE_ENFORCING:
raise containment.ContainmentUnavailable(
frozenset({containment.FILESYSTEM}), ALIAS_REWRITE_MECHANISM,
)
# Once per process, not once per command. The host's ability to establish a
# namespace does not change between calls, so a per-call warning would
# drown the log on every macOS install while adding nothing — and the
# per-call fact is already in the result block, which is where a reader
# looking at one command will look.
global _ALIAS_FALLBACK_LOGGED
if not _ALIAS_FALLBACK_LOGGED:
_ALIAS_FALLBACK_LOGGED = True
logger.warning(
"execution boundary: no filesystem containment is available on this "
"host (mechanism %r); agent commands fall back to the %s, which is "
"a path rewrite and not a boundary. Reported per command in the "
"result's containment block.",
probe.mechanism, ALIAS_REWRITE_MECHANISM,
)
return (
_replace_workspace_alias(content, cwd),
_filesystem_boundary_block(
ALIAS_REWRITE_MECHANISM, probe.mode, confined=False,
),
False,
)
def _wrap_workspace_namespace(
content: str,
cwd: str,
*,
chdir: str = "/workspace",
chdir: str = WORKSPACE_MOUNT,
interpreter_prefix: str | None = None,
) -> str | None:
"""Run a shell command with the active workspace mounted at /workspace.
@@ -251,12 +465,31 @@ def _wrap_workspace_namespace(
"--symlink", "usr/lib64", "/lib64",
"--symlink", "usr/bin", "/sbin",
"--dir", "/etc", "--ro-bind", "/etc", "/etc",
"--dir", "/home", "--bind", "/home", "/home",
"--dir", "/mnt", "--bind", "/mnt", "/mnt",
# Read-only, not read-write. These two binds exist so a command can
# *read* host material it legitimately needs — a dataset under /mnt, a
# dotfile under /home. Binding them writable gave back most of what
# the namespace was for: a Linux host with working bubblewrap running
# this argv reaches outside the workspace and writes to the user's home
# directory, measured rather than inferred. The workspace bind below is
# the one writable path, which is what "workspace confinement" means.
"--dir", "/home", "--ro-bind", "/home", "/home",
"--dir", "/mnt", "--ro-bind", "/mnt", "/mnt",
"--dir", "/tmp", "--tmpfs", "/tmp",
"--dev-bind", "/dev", "/dev", "--proc", "/proc",
"--dir", "/workspace", "--bind", cwd, "/workspace",
"--dir", WORKSPACE_MOUNT, "--bind", cwd, WORKSPACE_MOUNT,
]
# The workspace stays writable at its real host path as well as at
# /workspace. A command can carry the absolute host path: BashTool's own
# /tmp redirect rewrites `/tmp/` to `<agent_cwd()>/.tmp/` before the
# namespace is built, so the command reaching bwrap already names the real
# path. Before /home and /mnt became read-only those writes landed only
# because the workspace happened to sit under one of them. Binding the
# workspace itself is the narrow version of what that accident provided:
# the same directory by either name, and nothing else writable.
real_cwd = os.path.realpath(cwd)
if real_cwd not in _NAMESPACE_RESERVED_DESTS and len(real_cwd.split(os.sep)) >= 3:
args.extend(_namespace_dir_chain(real_cwd))
args.extend(("--bind", real_cwd, real_cwd))
# setup-python installs interpreters under /opt, and local CI virtualenvs
# can live under /tmp. Those paths are hidden by the private root/tmpfs.
# Expose only the active interpreter environment, read-only, so Python
@@ -264,15 +497,7 @@ def _wrap_workspace_namespace(
if interpreter_prefix:
prefix = os.path.abspath(interpreter_prefix)
resolved_prefix = os.path.realpath(prefix)
mounted_roots = ("/usr", "/home", "/mnt")
reserved_roots = {
"/", "/tmp", "/var", "/opt", "/etc", "/workspace",
"/root", "/run", "/proc", "/dev", "/sys", *mounted_roots,
}
already_visible = any(
prefix == root or prefix.startswith(root + os.sep)
for root in mounted_roots
)
already_visible = _namespace_visible_without_bind(prefix)
# A prefix is trusted only when it names a specific interpreter tree.
# In particular, never overlay the private root, tmpfs, or workspace
# with a broad host directory. Reject symlinked prefixes too: bwrap
@@ -289,18 +514,12 @@ def _wrap_workspace_namespace(
if (
not already_visible
and prefix == resolved_prefix
and prefix not in reserved_roots
and prefix not in _NAMESPACE_RESERVED_DESTS
and len(prefix.split(os.sep)) >= 3
and os.path.isdir(prefix)
and has_environment_layout
):
parents = []
parent = os.path.dirname(prefix)
while parent not in ("/", "/tmp", "/etc", "/workspace", *mounted_roots):
parents.append(parent)
parent = os.path.dirname(parent)
for directory in reversed(parents):
args.extend(("--dir", directory))
args.extend(_namespace_dir_chain(prefix))
args.extend(("--ro-bind", prefix, prefix))
args.extend(("--chdir", chdir, "/bin/bash", "-lc", content))
return shlex.join(args)
@@ -635,12 +854,13 @@ class BashTool:
),
"exit_code": 1,
}
isolated_tmp = os.path.join(agent_cwd(), ".tmp")
if "/tmp/" in content:
os.makedirs(isolated_tmp, exist_ok=True)
isolated_tmp = _isolated_tmp_dir(agent_cwd())
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
namespaced = _wrap_workspace_namespace(content, agent_cwd())
content = namespaced or _replace_workspace_alias(content, agent_cwd())
try:
content, boundary, _confined = _contained_command(content, agent_cwd())
except containment.ContainmentUnavailable as exc:
return containment.unavailable_tool_result(exc, tool="bash")
progress_cb = ctx.get("progress_cb")
_subproc_env = ctx.get("subproc_env")
session_id = ctx.get("session_id")
@@ -660,6 +880,7 @@ class BashTool:
"stdout": _truncate(stdout, MAX_OUTPUT_CHARS),
"stderr": _truncate(stderr, MAX_OUTPUT_CHARS),
"tmux_session": _tmux_session_name(str(session_id)),
"containment": boundary,
}
output = stdout.rstrip()
err = stderr.rstrip()
@@ -669,6 +890,7 @@ class BashTool:
"output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)",
"exit_code": rc or 0,
"tmux_session": _tmux_session_name(str(session_id)),
"containment": boundary,
}
try:
@@ -690,7 +912,7 @@ class BashTool:
cwd=agent_cwd(),
)
except RuntimeError as exc:
return {"error": str(exc), "exit_code": 1}
return {"error": str(exc), "exit_code": 1, "containment": boundary}
mark_operation_started('subprocess', pid=proc.pid)
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
proc,
@@ -698,13 +920,13 @@ class BashTool:
progress_cb=progress_cb,
)
if timed_out:
return {"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS)}
return {"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), "containment": boundary}
output = stdout.rstrip()
err = stderr.rstrip()
if err:
output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err
output = _truncate(output, MAX_OUTPUT_CHARS)
return {"output": output or "(no output)", "exit_code": rc or 0}
return {"output": output or "(no output)", "exit_code": rc or 0, "containment": boundary}
class HostShellTool:
async def execute(self, content: str, ctx: dict) -> dict:
@@ -959,12 +1181,11 @@ class PythonTool:
# every invocation would make that stable contract appear as
# ``/workspace`` instead.
needs_virtual_namespace = bool(
"/workspace" in content
WORKSPACE_MOUNT in content
or re.search(r"\b(?:runpy\.run_path|exec\s*\(|importlib\.)", content)
)
isolated_tmp = os.path.join(agent_cwd(), ".tmp")
if "/tmp/" in content:
os.makedirs(isolated_tmp, exist_ok=True)
isolated_tmp = _isolated_tmp_dir(agent_cwd())
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
progress_cb = ctx.get("progress_cb")
_subproc_env = ctx.get("subproc_env")
@@ -987,12 +1208,39 @@ class PythonTool:
_wrap_workspace_namespace(
python_command,
agent_cwd(),
chdir="/workspace",
chdir=WORKSPACE_MOUNT,
interpreter_prefix=sys.prefix,
)
if needs_virtual_namespace
else None
)
# The boundary this call actually got, reported either way. Note what
# the gate above means: code that does not mention /workspace and is
# not dynamic gets NO namespace, on every platform including a Linux
# host with working bubblewrap. That is deliberate -- it keeps
# os.getcwd() the real workspace -- but it is also a filesystem
# containment gap wider than the macOS one, and until now nothing said
# so. Reporting it is in scope here; closing it is not: it changes the
# Linux Python path for every call and cannot be verified on a host
# without bwrap. It is the reason enforcing mode cannot be switched on
# yet, because enforcing it as written would refuse ordinary Python on
# a correctly configured host.
boundary_probe = _execution_boundary(
agent_cwd(), wall_clock_s=DEFAULT_PYTHON_TIMEOUT,
)
confined = namespaced is not None
if not confined and boundary_probe.mode == containment.MODE_ENFORCING:
return containment.unavailable_tool_result(
containment.ContainmentUnavailable(
frozenset({containment.FILESYSTEM}), ALIAS_REWRITE_MECHANISM,
),
tool="python",
)
boundary = _filesystem_boundary_block(
boundary_probe.mechanism if confined else ALIAS_REWRITE_MECHANISM,
boundary_probe.mode,
confined=confined,
)
if namespaced:
proc = await asyncio.create_subprocess_exec(
"/bin/bash", "-lc", namespaced,
@@ -1024,7 +1272,7 @@ class PythonTool:
progress_cb=progress_cb,
)
if timed_out:
return {"error": f"python: timed out after {DEFAULT_PYTHON_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS)}
return {"error": f"python: timed out after {DEFAULT_PYTHON_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), "containment": boundary}
child_failure = _python_child_runtime_failure(stdout, stderr, rc)
if child_failure:
return {
@@ -1035,10 +1283,11 @@ class PythonTool:
),
"exit_code": 1,
"stderr": _truncate(stderr, MAX_OUTPUT_CHARS),
"containment": boundary,
}
output = stdout.rstrip()
err = stderr.rstrip()
if err:
output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err
output = _truncate(output, MAX_OUTPUT_CHARS)
return {"output": output or "(no output)", "exit_code": rc or 0}
return {"output": output or "(no output)", "exit_code": rc or 0, "containment": boundary}
+3 -8
View File
@@ -7,6 +7,8 @@ from fastapi import HTTPException
from fastapi.responses import HTMLResponse
from starlette.requests import Request
from src.path_confinement import is_inside
logger = logging.getLogger(__name__)
def read_if_exists(path: str) -> str:
@@ -51,11 +53,4 @@ def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
def inside_base_dir(base_dir: str, path: str) -> bool:
"""Check if path is inside base directory."""
if not isinstance(base_dir, str) or not isinstance(path, str):
return False
base = os.path.realpath(base_dir)
p = os.path.realpath(path)
try:
return os.path.commonpath([base, p]) == base
except Exception:
return False
return is_inside(base_dir, path)
+11
View File
@@ -155,6 +155,17 @@ SCHOLARLY_LOOKUP_TOTAL_BUDGET = 20.0
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "True").lower() == "true"
CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
# Agent workspace
# The stable virtual root the tool contract promises an agent, independent of
# where the workspace physically lives. Both the mount namespace and the
# path resolvers map it to the active workspace, so it is the one absolute path
# a contained command may assume.
WORKSPACE_MOUNT = "/workspace"
# Scratch directory inside the workspace that agent shell commands get in place
# of the host /tmp. A dirname rather than a path: the workspace is dynamic, so
# the full path is only knowable per turn.
AGENT_ISOLATED_TMP_DIRNAME = ".tmp"
# Auth policy
PASSWORD_MIN_LENGTH = 8
+62 -6
View File
@@ -67,7 +67,11 @@ from core.atomic_io import atomic_write_json
from core.platform_compat import IS_WINDOWS, find_bash, pid_alive
from src import process_ownership
from src.constants import CONTAINMENT_STATE_FILE, MAX_OUTPUT_CHARS
from src.constants import (
CONTAINMENT_STATE_FILE,
MAX_OUTPUT_CHARS,
WORKSPACE_MOUNT,
)
logger = logging.getLogger(__name__)
@@ -118,13 +122,12 @@ _DEATH_POLL_S = 0.05
# private /tmp or the workspace itself with a host directory would undo the
# namespace from inside the argv that builds it.
_RESERVED_BIND_DESTS = frozenset({
"/", "/tmp", "/proc", "/dev", "/sys", "/workspace",
"/", "/tmp", "/proc", "/dev", "/sys", WORKSPACE_MOUNT,
})
#: Where the workspace is mounted inside a namespace. The public tool contract
#: already promises this path, so it is the one path a contained command may
#: assume.
WORKSPACE_MOUNT = "/workspace"
# WORKSPACE_MOUNT is re-exported from src.constants: where the workspace is
# mounted inside a namespace is a property of the tool contract, not of this
# module, and two definitions of it would be two contracts.
class ContainmentUnavailable(RuntimeError):
@@ -604,6 +607,59 @@ def _select(spec: ContainmentSpec) -> tuple[Optional[Mechanism], frozenset[str]]
return best, best_provided
@dataclass(frozen=True)
class ContainmentProbe:
"""What a spec *would* get on this host. No grant, no record, no process.
For a spawn path that has not yet been rewritten to run through
:func:`run` and still builds its own ``create_subprocess_*`` call. Such a
caller still has to decide — refuse, or run and say so — and that decision
has to come from the same mechanism table :func:`acquire` consults, or the
tree grows a second opinion about what this host can enforce.
Calling :func:`acquire` for the answer is the wrong shape: it writes a
durable grant record, and a record whose pid is never filled in and whose
:func:`release` never runs is an entry a restart reaper will keep finding.
"""
mechanism: str
enforced: frozenset[str]
degraded: tuple[str, ...]
unenforced_required: tuple[str, ...]
mode: str
@property
def contained(self) -> bool:
return not self.unenforced_required
@property
def refuses(self) -> bool:
"""True when this spec cannot run at all under the current mode."""
return bool(self.unenforced_required) and self.mode == MODE_ENFORCING
def probe(spec: ContainmentSpec) -> ContainmentProbe:
"""Answer what this host can establish for ``spec``, without acquiring it.
Same selection, same mechanism table and same arithmetic as
:func:`acquire`; it just stops before the side effects. The command is not
an input here either.
:raises ValueError: the spec is malformed (a caller bug, in either mode).
"""
spec = _validate_spec(spec)
mechanism, provided = _select(spec)
enforced = provided & spec.requested
missing_required = frozenset(spec.required) - enforced
return ContainmentProbe(
mechanism=mechanism.name if mechanism else "none",
enforced=enforced,
degraded=tuple(sorted(spec.requested - enforced - spec.required)),
unenforced_required=tuple(sorted(missing_required)),
mode=CONTAINMENT_MODE,
)
def acquire(spec: ContainmentSpec, *, owner: str) -> ContainmentGrant:
"""Establish containment, or refuse.
+3 -6
View File
@@ -1,10 +1,10 @@
import os
import re
from pathlib import Path
from fastapi import HTTPException
from src.constants import GENERATED_IMAGES_DIR
from src.path_confinement import confine
GENERATED_IMAGE_DIR = Path(GENERATED_IMAGES_DIR)
@@ -20,12 +20,9 @@ GENERATED_IMAGE_HEADERS = {
def resolve_generated_image_path(filename: str) -> Path:
if not isinstance(filename, str) or not GENERATED_IMAGE_RE.fullmatch(filename):
raise HTTPException(status_code=400, detail="Invalid filename")
root = GENERATED_IMAGE_DIR.resolve()
path = (GENERATED_IMAGE_DIR / filename).resolve()
try:
if os.path.commonpath([str(root), str(path)]) != str(root):
raise ValueError
except Exception:
path = Path(confine(GENERATED_IMAGE_DIR, filename, allow_root=False))
except (ValueError, OSError):
raise HTTPException(status_code=400, detail="Invalid filename")
if not path.exists():
raise HTTPException(status_code=404, detail="Image not found")
+188
View File
@@ -0,0 +1,188 @@
"""The filesystem confinement boundary. One implementation, every call site.
"Is this path inside that root" is asked in twenty places in this tree, and
twenty times it is answered by a locally written ``realpath`` +
``os.path.commonpath`` pair. Each one is defensible on its own. Together they
are the problem: the boundary has no single definition, so a site that gets a
detail wrong is wrong *alone*, and a site added tomorrow starts from whichever
neighbour its author happened to copy.
The details that differ between those copies, and what this module settles:
**Both sides get canonicalized.** Comparing a ``realpath``-ed candidate against
a root that was only ``abspath``-ed is the bug class that has already cost this
project real time: on macOS ``/tmp`` is a symlink to ``/private/tmp``, so the
two sides disagree about a path neither of them is wrong about. It reads as an
escape and refuses a legitimate access. Canonicalizing one side is worse than
canonicalizing neither.
**``commonpath``, never ``startswith``.** ``/a/bc`` begins with ``/a/b`` and is
not inside it.
**Case folding is the filesystem's business, not the comparison's.**
``os.path.normcase`` lowercases on Windows and is the identity everywhere else
— including macOS, whose default filesystem is case-insensitive while its
``realpath`` preserves case. So normcase alone does not make the comparison
agree with the filesystem on macOS, and :func:`is_inside` does not pretend
otherwise: it answers about the canonical path, which is the question a
confinement check should be asking. Where a caller needs to match the
filesystem's own folding it must compare real paths of real files, not strings.
**A relative candidate joins the root, never the process cwd.** ``abspath`` of a
relative path silently uses ``os.getcwd()``, which is whatever the server
happens to be running in. A confinement helper that does that is resolving
against the wrong base before it even starts comparing.
**NUL and newline are rejected, not caught.** Several of the copies wrap the
whole comparison in ``except Exception: return False``, which turns a malformed
path into "outside" — the safe answer, reached by accident. Here it is a
``ValueError`` with a reason.
**``commonpath`` raising means outside.** It raises across Windows drive letters
and for mixed absolute/relative inputs. Both mean the candidate is not under the
root, so the refusal is deliberate rather than incidental.
What this module does *not* do: decide whether a path is sensitive (``.ssh``,
``id_rsa``, …). That is a separate deny list applied inside an allowed root, and
it lives with the callers that own it — ``src/tool_execution`` for the agent
tools. Confinement answers "inside the root"; it does not answer "allowed".
Relationship to :mod:`src.containment`: that module is the boundary for *where a
process runs*; this one is the boundary for *which paths a path check accepts*.
A contained process is restricted by a mount namespace, which this module cannot
express and does not try to; an in-process read of a model-supplied path is
restricted by this module, which a namespace does not see.
"""
from __future__ import annotations
import os
__all__ = [
"PathEscape",
"canonical_root",
"confine",
"is_inside",
]
class PathEscape(ValueError):
"""A candidate path does not resolve inside the root it was checked against.
A subclass of :class:`ValueError` so the call sites this replaces — which
raise ``ValueError`` and are caught as such by their callers and their
tests — keep behaving the way they did.
"""
def __init__(self, root: str, candidate: str, reason: str = "") -> None:
self.root = str(root)
self.candidate = str(candidate)
self.reason = str(reason or "outside the allowed root")
super().__init__(
f"path {self.candidate!r} is {self.reason} ({self.root})"
)
def _reject_unusable(value: str, *, label: str) -> str:
"""Normalize a path argument to ``str``, refusing the unusable shapes.
``\\x00`` is refused here because the OS layer raises on it much later and
from somewhere unhelpful, and because a broad ``except Exception`` around
the comparison would otherwise record it as an ordinary escape. Newlines
are refused for the same reason the workspace-mount parser refuses them:
a path carrying one has been built by splitting something that was not a
path list.
"""
if value is None:
raise ValueError(f"{label} is required")
if isinstance(value, os.PathLike):
value = os.fspath(value)
if not isinstance(value, str):
raise ValueError(f"{label} must be a path, got {type(value).__name__}")
text = value.strip()
if not text:
raise ValueError(f"{label} is required")
if "\x00" in text:
raise ValueError(f"{label} must not contain NUL")
if "\n" in text or "\r" in text:
raise ValueError(f"{label} must not contain a newline")
return text
def canonical_root(root) -> str:
"""The canonical form of a confinement root.
Exposed because a caller that holds a root across several checks should
canonicalize it once, and because a caller comparing two paths itself needs
the same canonical form this module compares against — a realpath-ed value
tested against a raw one is the asymmetry this module exists to remove.
"""
text = _reject_unusable(root, label="root")
return os.path.realpath(os.path.expanduser(text))
def _canonical_candidate(root: str, candidate) -> str:
"""Canonicalize ``candidate``, resolving a relative path under ``root``.
``realpath`` is deliberately the non-strict kind: a final component that
does not exist yet is normalized rather than refused, because a write target
is a legitimate thing to confine. Everything that *does* exist is resolved,
so a symlink anywhere in the chain — including the final component — is
followed before the comparison rather than after the open.
"""
text = _reject_unusable(candidate, label="path")
expanded = os.path.expanduser(text)
if not os.path.isabs(expanded):
expanded = os.path.join(root, expanded)
return os.path.realpath(expanded)
def is_inside(root, candidate, *, allow_root: bool = True) -> bool:
"""True when ``candidate`` resolves inside ``root``.
The boolean form, for call sites whose contract is a predicate. A malformed
argument is ``False`` here rather than a raise, because a predicate that
raises is the reason those call sites wrapped themselves in
``except Exception`` in the first place. Use :func:`confine` where the
caller wants the resolved path and a reason for the refusal.
``allow_root=False`` excludes the root itself, for a caller whose operation
is only meaningful on something *under* the root — deleting a file, say,
where the root is the directory it must not be.
"""
try:
confine(root, candidate, allow_root=allow_root)
return True
except (ValueError, OSError):
return False
def confine(root, candidate, *, allow_root: bool = True) -> str:
"""Resolve ``candidate`` inside ``root``, or raise.
Returns the canonical absolute path, which is what the caller should then
open: resolving and then opening the *original* string re-introduces the
symlink race the resolution just closed.
:raises ValueError: either argument is unusable as a path.
:raises PathEscape: the candidate resolves outside the root.
"""
base = canonical_root(root)
resolved = _canonical_candidate(base, candidate)
if resolved == base:
if allow_root:
return resolved
raise PathEscape(base, candidate, "the root itself, not a path inside it")
# normcase folds case on Windows and is the identity elsewhere; it is
# applied to both sides or to neither, which is the whole point.
try:
common = os.path.commonpath([os.path.normcase(resolved), os.path.normcase(base)])
except ValueError:
# Different Windows drives, or mixed absolute/relative. Both mean the
# candidate is not under the root.
raise PathEscape(base, candidate) from None
if common != os.path.normcase(base):
raise PathEscape(base, candidate)
return resolved
+3 -7
View File
@@ -4,11 +4,11 @@ from __future__ import annotations
import json
import logging
import os
import re
from pathlib import Path
from src.constants import GENERATED_IMAGES_DIR
from src.path_confinement import confine
logger = logging.getLogger(__name__)
@@ -26,14 +26,10 @@ def _generated_image_path_for_cleanup(filename: str) -> Path | None:
name = Path(filename).name
if name != filename or name in {".", ".."}:
return None
root = Path(GENERATED_IMAGES_DIR).resolve()
path = (root / name).resolve()
try:
if os.path.commonpath([str(root), str(path)]) != str(root):
return None
except Exception:
return Path(confine(GENERATED_IMAGES_DIR, name, allow_root=False))
except (ValueError, OSError):
return None
return path
def _image_filename_from_url(url: str) -> str:
+20 -25
View File
@@ -34,7 +34,14 @@ from src.tool_capabilities import ToolRunSecurityContext, blocked_tool_result
from src.tool_approvals import ExactToolApproval
from src.tool_policy import ToolPolicy
from src.client_tool_contract import TUI_ROUTED_BRIDGE_TOOL_NAMES
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR
from src.constants import (
DATA_DIR,
MAX_DIFF_LINES,
MAX_OUTPUT_CHARS,
MAX_READ_CHARS,
WORKSPACE_MOUNT,
)
from src.path_confinement import canonical_root, confine, is_inside
from src.tool_utils import _truncate, get_mcp_manager
@@ -814,13 +821,7 @@ def _resolve_tool_path(raw_path: str) -> str:
)
for root in _tool_path_roots():
if resolved == root:
return resolved
try:
common = os.path.commonpath([resolved, root])
except ValueError:
continue
if common == root:
if is_inside(root, resolved):
return resolved
raise ValueError(
f"path '{raw_path}' is outside the allowed roots"
@@ -838,33 +839,27 @@ def _resolve_tool_path_in_workspace(workspace: str, raw_path: str) -> str:
"""
if raw_path is None or not str(raw_path).strip():
raise ValueError("path is required")
base = os.path.realpath(workspace)
base = canonical_root(workspace)
expanded = os.path.expanduser(str(raw_path).strip())
# `/workspace` is the stable user-facing agent root in tasks and docs.
# Native/manual installs may bind the request to another physical folder;
# resolve the alias inside that active workspace rather than rejecting it.
if expanded == "/workspace":
if expanded == WORKSPACE_MOUNT:
expanded = base
elif expanded.startswith("/workspace/"):
expanded = os.path.join(base, expanded.removeprefix("/workspace/"))
candidate = expanded if os.path.isabs(expanded) else os.path.join(base, expanded)
resolved = os.path.realpath(candidate)
elif expanded.startswith(WORKSPACE_MOUNT + "/"):
expanded = os.path.join(base, expanded.removeprefix(WORKSPACE_MOUNT + "/"))
try:
resolved = confine(base, expanded)
except (ValueError, OSError):
raise ValueError(f"path '{raw_path}' is outside the workspace ({workspace})")
# Confinement says "inside the root"; the deny list says "allowed". They
# are separate questions and this one stays here, with the policy that
# owns it.
if _is_sensitive_path(resolved):
raise ValueError(
f"path '{raw_path}' is inside a sensitive directory "
f"(e.g. .ssh, .gnupg) or matches a sensitive filename"
)
if resolved != base:
# normcase so containment holds on case-insensitive filesystems
# (Windows, default macOS): it lowercases on Windows and is a no-op on
# POSIX. commonpath raises ValueError across Windows drives (C: vs D:)
# or mixed abs/rel — both mean "outside", so the except rejects them.
nbase = os.path.normcase(base)
try:
if os.path.commonpath([os.path.normcase(resolved), nbase]) != nbase:
raise ValueError
except ValueError:
raise ValueError(f"path '{raw_path}' is outside the workspace ({workspace})")
return resolved
+3 -12
View File
@@ -13,6 +13,7 @@ from datetime import datetime, timedelta
from typing import Dict, Any, Optional
from fastapi import HTTPException, UploadFile
from src.path_confinement import is_inside
from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes
@@ -256,12 +257,7 @@ class UploadHandler:
def inside_base_dir(self, path: str) -> bool:
"""Check if path is inside base directory"""
base = os.path.realpath(self.base_dir)
p = os.path.realpath(path)
try:
return os.path.commonpath([base, p]) == base
except Exception:
return False
return is_inside(self.base_dir, path)
def get_upload_dir(self):
"""Get date-based upload directory"""
@@ -684,12 +680,7 @@ class UploadHandler:
def _inside_upload_dir(self, path: str) -> bool:
"""Check if path is inside the upload directory."""
base = os.path.normcase(os.path.realpath(self.upload_dir))
p = os.path.normcase(os.path.realpath(path))
try:
return os.path.commonpath([base, p]) == base
except Exception:
return False
return is_inside(self.upload_dir, path)
def _atomic_write_json(
self,