mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 15:02:20 +02: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.
226 lines
9.2 KiB
Python
226 lines
9.2 KiB
Python
"""The single filesystem confinement boundary.
|
|
|
|
Nine test files in this suite each prove one call site confines correctly, and
|
|
each call site had its own ``realpath``/``commonpath`` pair to prove it about.
|
|
This file covers the one implementation they now all go through, so a property
|
|
is asserted once instead of nine times and inconsistently.
|
|
|
|
Each test names the detail the scattered copies disagreed on. The macOS tests
|
|
are the ones with history: ``/tmp`` is a symlink to ``/private/tmp`` there, and
|
|
comparing a canonicalized candidate against a root that was not canonicalized
|
|
has already produced a false failure in this suite.
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from src.path_confinement import (
|
|
PathEscape,
|
|
canonical_root,
|
|
confine,
|
|
is_inside,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def root(tmp_path):
|
|
"""A real directory, canonicalized the way a caller's root should be.
|
|
|
|
tmp_path is under ``/private/var/...`` on macOS via a ``/var`` symlink, so
|
|
this fixture is itself an instance of the aliasing the module exists to
|
|
handle — which is why it is used as-is rather than pre-resolved.
|
|
"""
|
|
(tmp_path / "inside.txt").write_text("x", encoding="utf-8")
|
|
(tmp_path / "sub").mkdir()
|
|
return str(tmp_path)
|
|
|
|
|
|
# ── the basic shape ─────────────────────────────────────────────────────────
|
|
def test_path_under_the_root_resolves(root):
|
|
assert confine(root, "inside.txt") == os.path.join(canonical_root(root), "inside.txt")
|
|
|
|
|
|
def test_relative_candidate_joins_the_root_not_the_process_cwd(root, tmp_path, monkeypatch):
|
|
"""abspath() of a relative path silently uses os.getcwd().
|
|
|
|
A confinement helper that does that resolves against whatever directory the
|
|
server happens to be running in, which is the wrong base before the
|
|
comparison even starts.
|
|
"""
|
|
elsewhere = tmp_path.parent / "elsewhere"
|
|
elsewhere.mkdir()
|
|
monkeypatch.chdir(elsewhere)
|
|
assert confine(root, "inside.txt") == os.path.join(canonical_root(root), "inside.txt")
|
|
|
|
|
|
def test_the_root_itself_is_inside_by_default(root):
|
|
assert confine(root, root) == canonical_root(root)
|
|
|
|
|
|
def test_allow_root_false_excludes_the_root(root):
|
|
"""For an operation only meaningful on something *under* the root."""
|
|
with pytest.raises(PathEscape):
|
|
confine(root, root, allow_root=False)
|
|
assert confine(root, "sub", allow_root=False)
|
|
|
|
|
|
def test_a_target_that_does_not_exist_yet_is_confined_not_refused(root):
|
|
"""A write target is a legitimate thing to confine.
|
|
|
|
realpath is the non-strict kind: it resolves what exists and normalizes the
|
|
rest, so a new file under the root passes while a new file above it does
|
|
not.
|
|
"""
|
|
assert confine(root, "not-created-yet.txt").startswith(canonical_root(root))
|
|
with pytest.raises(PathEscape):
|
|
confine(root, "../not-created-yet.txt")
|
|
|
|
|
|
# ── traversal ───────────────────────────────────────────────────────────────
|
|
def test_dotdot_escape_is_refused(root):
|
|
with pytest.raises(PathEscape):
|
|
confine(root, "../outside.txt")
|
|
with pytest.raises(PathEscape):
|
|
confine(root, "sub/../../outside.txt")
|
|
|
|
|
|
def test_absolute_candidate_outside_the_root_is_refused(root):
|
|
with pytest.raises(PathEscape):
|
|
confine(root, os.path.dirname(canonical_root(root)))
|
|
|
|
|
|
def test_sibling_with_a_shared_prefix_is_not_inside(tmp_path):
|
|
"""`/a/bc` begins with `/a/b` and is not inside it.
|
|
|
|
This is why the boundary uses commonpath and not startswith. A copy written
|
|
with startswith accepts the sibling.
|
|
"""
|
|
(tmp_path / "b").mkdir()
|
|
(tmp_path / "bc").mkdir()
|
|
(tmp_path / "bc" / "f.txt").write_text("x", encoding="utf-8")
|
|
assert not is_inside(tmp_path / "b", tmp_path / "bc" / "f.txt")
|
|
assert is_inside(tmp_path / "bc", tmp_path / "bc" / "f.txt")
|
|
|
|
|
|
# ── symlinks ────────────────────────────────────────────────────────────────
|
|
@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX symlinks")
|
|
def test_symlink_as_the_final_component_is_followed_before_the_check(root, tmp_path):
|
|
outside = tmp_path.parent / "outside-secret.txt"
|
|
outside.write_text("secret", encoding="utf-8")
|
|
os.symlink(outside, os.path.join(root, "link.txt"))
|
|
with pytest.raises(PathEscape):
|
|
confine(root, "link.txt")
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX symlinks")
|
|
def test_symlinked_intermediate_directory_is_followed_before_the_check(root, tmp_path):
|
|
outside_dir = tmp_path.parent / "outside-dir"
|
|
outside_dir.mkdir()
|
|
(outside_dir / "f.txt").write_text("secret", encoding="utf-8")
|
|
os.symlink(outside_dir, os.path.join(root, "hop"))
|
|
with pytest.raises(PathEscape):
|
|
confine(root, "hop/f.txt")
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX symlinks")
|
|
def test_symlink_pointing_back_inside_the_root_is_allowed(root):
|
|
os.symlink(os.path.join(root, "inside.txt"), os.path.join(root, "loop.txt"))
|
|
assert confine(root, "loop.txt") == os.path.join(canonical_root(root), "inside.txt")
|
|
|
|
|
|
# ── the aliasing class that has already cost real time ──────────────────────
|
|
@pytest.mark.skipif(
|
|
not os.path.islink("/tmp"), reason="needs a platform where /tmp is a symlink",
|
|
)
|
|
def test_root_reached_through_a_symlink_still_contains_its_own_files(tmp_path):
|
|
"""macOS: /tmp is a symlink to /private/tmp.
|
|
|
|
A root given as `/tmp/x` and a candidate that canonicalizes to
|
|
`/private/tmp/x/f` describe the same file. Canonicalizing one side and not
|
|
the other reads as an escape and refuses a legitimate access — the false
|
|
false failure this suite has already recorded. Canonicalizing *neither*
|
|
side would agree, which is why the rule is both or nothing.
|
|
"""
|
|
unresolved_root = os.path.join("/tmp", os.path.basename(str(tmp_path)))
|
|
os.makedirs(unresolved_root, exist_ok=True)
|
|
try:
|
|
target = os.path.join(unresolved_root, "f.txt")
|
|
with open(target, "w", encoding="utf-8") as handle:
|
|
handle.write("x")
|
|
assert os.path.realpath(unresolved_root) != unresolved_root, (
|
|
"fixture assumption: /tmp should not canonicalize to itself here"
|
|
)
|
|
# Either spelling of the root, either spelling of the candidate.
|
|
assert is_inside(unresolved_root, target)
|
|
assert is_inside(unresolved_root, os.path.realpath(target))
|
|
assert is_inside(os.path.realpath(unresolved_root), target)
|
|
finally:
|
|
try:
|
|
os.remove(os.path.join(unresolved_root, "f.txt"))
|
|
os.rmdir(unresolved_root)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def test_canonical_root_is_idempotent(root):
|
|
once = canonical_root(root)
|
|
assert canonical_root(once) == once
|
|
|
|
|
|
# ── malformed input is a reason, not an accidental "outside" ────────────────
|
|
@pytest.mark.parametrize("bad", ["", " ", None])
|
|
def test_empty_candidate_is_a_value_error_not_an_escape(root, bad):
|
|
with pytest.raises(ValueError) as caught:
|
|
confine(root, bad)
|
|
assert not isinstance(caught.value, PathEscape)
|
|
assert "required" in str(caught.value)
|
|
|
|
|
|
def test_nul_is_refused_with_a_reason(root):
|
|
with pytest.raises(ValueError, match="NUL"):
|
|
confine(root, "a\x00b")
|
|
|
|
|
|
def test_newline_is_refused_with_a_reason(root):
|
|
with pytest.raises(ValueError, match="newline"):
|
|
confine(root, "a\nb")
|
|
|
|
|
|
def test_is_inside_is_false_for_malformed_input_rather_than_raising(root):
|
|
"""The predicate form never raises; that is why callers wrapped the old
|
|
copies in `except Exception` and reached "outside" by accident."""
|
|
for bad in ("", None, "a\x00b", "a\nb", 17, object()):
|
|
assert is_inside(root, bad) is False
|
|
assert is_inside(None, "x") is False
|
|
|
|
|
|
def test_path_escape_is_a_value_error(root):
|
|
"""The call sites this replaces raised ValueError and their callers catch
|
|
it as such, so the subclass relationship is part of the contract."""
|
|
assert issubclass(PathEscape, ValueError)
|
|
with pytest.raises(ValueError):
|
|
confine(root, "../elsewhere")
|
|
|
|
|
|
def test_path_escape_names_the_root_and_the_candidate(root):
|
|
with pytest.raises(PathEscape) as caught:
|
|
confine(root, "../elsewhere")
|
|
message = str(caught.value)
|
|
assert "../elsewhere" in message
|
|
assert canonical_root(root) in message
|
|
|
|
|
|
# ── the deny list is somebody else's job ────────────────────────────────────
|
|
def test_confinement_does_not_decide_whether_a_path_is_sensitive(root):
|
|
"""`.ssh` inside the root is inside the root.
|
|
|
|
Confinement answers "inside"; the sensitive-file deny list answers
|
|
"allowed", and it stays with src.tool_execution, which owns that policy.
|
|
Folding the two together here is how a boundary acquires a second job and
|
|
then disagrees with itself.
|
|
"""
|
|
secret = os.path.join(root, ".ssh")
|
|
os.makedirs(secret, exist_ok=True)
|
|
assert is_inside(root, os.path.join(secret, "id_rsa"))
|