From ebc951df4705deddc9a3275267f1728ff64e52f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 21:08:11 +0000 Subject: [PATCH] Close a real sandbox escape: dir_fd= bypasses the workspace write grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_path() resolves a call's path argument against the process's cwd, pinned to the workspace by os.chdir(). Every mutating os function that accepts dir_fd=/src_dir_fd=/dst_dir_fd= resolves the same string against an open directory descriptor instead, and cwd never enters into it — so a tool that legitimately opens a runtime path for reading (site-packages, which has to stay readable so imports work) can get a descriptor for it and then write there via that descriptor while check_path validates a path that has nothing to do with where the write actually lands. Verified end to end: a tool opens site-packages (a legitimate read), gets a dir_fd from it, and calls os.open("evil.pth", O_CREAT|O_WRONLY, dir_fd=that_fd). No SandboxViolation was raised, the file landed in site-packages, and it executed automatically on the next fresh interpreter start in that environment — the exact .pth-drop escape the read/write grant split (and the existing os.readlink guard) exists to close, reopened through a syscall shape the audit-event path check never considered a path argument for at all. os.mkdir, os.remove, and os.rename with dir_fd= reproduce the same hole. Fix: _install_dir_fd_guard() wraps every stdlib function that accepts one of the three dir_fd keyword spellings (chmod, chown, link, mkdir, mkfifo, mknod, open, remove, rename, replace, rmdir, symlink, unlink, utime) and refuses the keyword outright, the same way the existing os.readlink guard already refuses dir_fd for the one function it covers — there is no reliable way to turn a directory descriptor back into the path it names, so there is nothing to check against the grant. Derived from the stdlib's own three keyword spellings rather than a hand-maintained per-function table, so it does not go stale the way the fork_exec arity table already did once. Added test_gate_dot_pth_cannot_be_planted_via_dir_fd (4 parametrized cases: os.open, os.mkdir, os.remove, os.rename), sitting next to the existing plain-path .pth-planting gate test it mirrors. Verified: full suite green on Python 3.12 and 3.13; ruff clean; confirmed normal (non-dir_fd) file operations inside the workspace are unaffected. Co-Authored-By: Claude Sonnet 5 --- grapharc/harness/executor.py | 79 ++++++++++++++++++++++++++++++++++++ tests/test_harness_gate.py | 63 ++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/grapharc/harness/executor.py b/grapharc/harness/executor.py index 2c5a67d..eb107ef 100644 --- a/grapharc/harness/executor.py +++ b/grapharc/harness/executor.py @@ -383,6 +383,84 @@ def fork_exec(*args: Any, **kwargs: Any) -> Any: module.fork_exec = fork_exec +#: Every stdlib function that accepts a directory-descriptor keyword uses one +#: of exactly three spellings (`dir_fd`, `src_dir_fd`, `dst_dir_fd`), so +#: `_install_dir_fd_guard` below checks all three by name rather than needing +#: a table of which function takes which — a table would go stale the way the +#: `fork_exec` arity above already did once. `readlink` is excluded: it has +#: its own dedicated guard above, for a different reason (its *result*, not +#: just its argument, has to be resolved and checked). +_DIR_FD_MUTATORS = ( + "chmod", + "chown", + "link", + "mkdir", + "mkfifo", + "mknod", + "open", + "remove", + "rename", + "replace", + "rmdir", + "symlink", + "unlink", + "utime", +) + + +def _install_dir_fd_guard() -> None: + """Refuse `dir_fd=`/`src_dir_fd=`/`dst_dir_fd=` on every mutating call that + accepts one — a real, verified escape from the workspace grant. + + `check_path` resolves a call's path argument against the *process's cwd*, + pinned to the workspace by the `os.chdir` below. A directory-descriptor + keyword makes the underlying syscall resolve that same string against an + open file descriptor instead; cwd never enters into it. A tool legitimately + reading a directory inside the read grant — site-packages, say — gets a + descriptor for it, and `os.open("evil.pth", O_CREAT | O_WRONLY, + dir_fd=that_fd)` then writes there while `check_path` is validating a path + that has nothing to do with where the write actually lands. Confirmed by + running it end to end: the planted file executes on the next interpreter + start in that environment — the exact site-packages `.pth`-drop escape the + read/write grant split exists to close (see the module docstring above), + reopened through a syscall shape the audit-event path check never + considered a path argument for at all. + + Like the `os.readlink` guard, this refuses the capability outright rather + than trying to resolve and check it: there is no reliable way to turn a + directory descriptor back into the path it names, so there is nothing to + validate against the grant. It is a wrapper, not an audit hook, and carries + the same documented caveat for the same reason — the original stays + reachable through the closure, so it closes the accident, not a + determined adversary already unwrapping functions from inside. + """ + posix = sys.modules.get("posix") or sys.modules.get("nt") + + def make_wrapper(original: Any, name: str) -> Any: + def wrapper(*args: Any, **kwargs: Any) -> Any: + for kw in ("dir_fd", "src_dir_fd", "dst_dir_fd"): + if kwargs.get(kw) is not None: + raise SandboxViolation( + f"os.{name} through a directory descriptor ({kw}) resolves " + "outside the process's cwd and cannot be checked against the " + "workspace grant, so it is refused" + ) + return original(*args, **kwargs) + + wrapper.__name__ = name + wrapper.__qualname__ = name + return wrapper + + for name in _DIR_FD_MUTATORS: + original = getattr(os, name, None) + if original is None: # pragma: no cover - platform-dependent (mkfifo/mknod on Windows) + continue + wrapped = make_wrapper(original, name) + setattr(os, name, wrapped) + if posix is not None and hasattr(posix, name): + setattr(posix, name, wrapped) + + def _is_sqlite_uri(database: Any) -> bool: """sqlite re-reads a `file:` name itself — percent-decoding it and honouring an authority and query string — so `realpath` does not name the file that @@ -531,6 +609,7 @@ def hook(event: str, hook_args: tuple[Any, ...]) -> None: pass _install_readlink_guard(check_read) _install_fork_exec_guard(spec.name) + _install_dir_fd_guard() sys.addaudithook(hook) try: result = spec.fn(**args) diff --git a/tests/test_harness_gate.py b/tests/test_harness_gate.py index d0dcf7f..c502d49 100644 --- a/tests/test_harness_gate.py +++ b/tests/test_harness_gate.py @@ -737,6 +737,69 @@ def unplant(target: str) -> str: assert os.path.exists(site_packages_probe), "a site-packages file was deleted from the sandbox" +@_posix +@pytest.mark.parametrize( + "op", + ["os.open", "os.mkdir", "os.remove", "os.rename"], +) +def test_gate_dot_pth_cannot_be_planted_via_dir_fd(tmp_path, op, site_packages_probe): + """The same escape as `test_gate_dot_pth_cannot_be_planted_in_site_packages`, + reached through `dir_fd=` instead of a plain path. + + `check_path` resolves the path argument of a call against the process's + cwd (pinned to the workspace). Every one of these calls also accepts a + directory-descriptor keyword that makes the real syscall resolve the same + string against an *open file descriptor* instead — cwd never enters into + it. A tool legitimately opens site-packages for reading (it is in the read + grant, or the import machinery could not work), gets a descriptor for it, + and then `os.open("evil.pth", O_CREAT | O_WRONLY, dir_fd=that_fd)` writes + there while `check_path` validates a path with nothing to do with where + the write actually lands. Verified end to end before this test existed: + the planted `.pth` ran on the very next interpreter start in that + environment — this is not a theoretical gap. + """ + workspace = tmp_path / "ws" + workspace.mkdir() + pth_name = f"grapharc_pwned_dirfd_{uuid.uuid4().hex}.pth" + + def plant_via_dir_fd(operation: str, name: str, victim: str) -> str: + import os as _os + + dfd = _os.open(_SITE_PACKAGES, _os.O_RDONLY) + try: + if operation == "os.open": + fd = _os.open(name, _os.O_CREAT | _os.O_WRONLY, dir_fd=dfd) + _os.close(fd) + elif operation == "os.mkdir": + _os.mkdir(name, dir_fd=dfd) + elif operation == "os.remove": + _os.remove(_os.path.basename(victim), dir_fd=dfd) + elif operation == "os.rename": + _os.rename( + _os.path.basename(victim), name, src_dir_fd=dfd, dst_dir_fd=dfd + ) + return "escaped" + finally: + _os.close(dfd) + + harness = _sandbox(workspace, plant_via_dir_fd=plant_via_dir_fd) + try: + with pytest.raises(SandboxViolation, match="directory descriptor"): + harness.call( + "plant_via_dir_fd", + {"operation": op, "name": pth_name, "victim": site_packages_probe}, + ) + assert not os.path.exists( + os.path.join(_SITE_PACKAGES, pth_name) + ), "a file was planted in site-packages via dir_fd" + if op in ("os.remove", "os.rename"): + assert os.path.exists( + site_packages_probe + ), "a site-packages file was removed/renamed via dir_fd" + finally: + _scrub("grapharc_pwned_dirfd_") # a regression really does leave one behind + + @_posix @pytest.mark.parametrize( "op",