Commit d32c43a
committed
feat(fs): add RootedFilesystem, a real TOCTOU-safe containment primitive (CCK-05, P1)
Root cause named in the plan: path_policy::resolve_within_root is a
TEXTUAL guarantee -- canonicalize the candidate, canonicalize the
root, compare prefixes. That proves where a path resolves to AT THE
MOMENT OF THE CHECK, not what the actual open/write a moment later
resolves to when it re-derives the same path from a fresh string.
Nothing stops a path component from being swapped for a symlink
pointing outside the project root in between -- a classic
check-then-use TOCTOU (CWE-367) that canonicalizing harder cannot
close, only a kernel-enforced atomic resolution can.
New crates/calm-core/src/fs/rooted.rs: RootedFilesystem, backed on
target_os = "linux" + target_arch = "x86_64" by openat2(2)'s
RESOLVE_BENEATH resolve flag (kernel 5.6+, raw syscall 437 -- stable
since introduction, but not exposed as libc::SYS_openat2 for glibc
targets in the locked libc version, only musl; open_how's struct
layout IS provided by libc and used as-is). The kernel refuses to
resolve any path component -- including through a symlink -- that
would step outside the directory fd resolution started from, so there
is no separate check to race: containment is atomic with the open
itself. Once that first resolution produces a directory fd, the temp-
file-then-rename dance (same atomic-write contract as edit::
atomic_write) uses that same fd with bare, `/`-free file names -- never
a re-resolved path string -- so nothing downstream can race either.
Every other target (other architectures, non-Linux) falls back to
path_policy::resolve_within_root, honestly reported as
ContainmentMethod::TextualFallback rather than silently claiming a
guarantee that platform can't back up.
Tests include a genuine TOCTOU reproduction, not just golden-path
round-trips: concurrent_symlink_swap_never_leaks_content_from_outside_root
spawns a thread that repeatedly re-links a path between an inside and
an outside target while the main thread hammers open_read_beneath
3000 times, asserting the outside file's content is never observed on
any single iteration -- exactly the race path_policy's textual check
cannot rule out. Also: a `..` escape refused at the kernel level
(open_dir_beneath_refuses_a_dotdot_escape_at_the_kernel_level), plain
write/overwrite/nested-directory round trips, and split_relative's
path-shape validation.
cargo test -p calm-core --lib: 1201 passed, 0 failed (was 1192; +9
new, all in fs::rooted). cargo test -p calm-server --lib: 380 passed,
2 failed -- the same 2 pre-existing, environment-only failures as
every commit on this branch (root in this container bypasses the Unix
readonly-file-permission technique those tests use to force
txn::begin to fail; confirmed identical on unmodified main).
Deliberate scope boundary, stated plainly rather than silently
implied: this module is NOT wired into edit::atomic_write_with
(CALM's actual production write path -- every edit_lines/edit_symbol/
format_files call goes through it) or into resolve_repo_path. Doing
that requires redesigning atomic_write_with's temp-file/rename dance
around an already-open directory fd end to end, plus every calling
MCP tool threading a RootedFilesystem through instead of the resolved
PathBuf resolve_repo_path returns today -- a substantial,
independently-reviewable change against the highest-traffic code path
in the server. Bundling that rewiring into the same change as a brand
new, first-use-of-raw-syscalls primitive would risk the one thing this
session has consistently avoided: a rushed change to security-critical
code with no room left to review it carefully. This commit ships the
primitive itself, real and tested against the exact race class CCK-05
names; wiring it into the live write path is deliberately left as its
own follow-up PR.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011UMR3jzKAuZ2od1t6WC6bY1 parent 4df72ff commit d32c43a
3 files changed
Lines changed: 577 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
0 commit comments