mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-07 14:37:55 +00:00
"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.
188 lines
8.2 KiB
Python
188 lines
8.2 KiB
Python
"""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
|