Skip to content

Commit d32c43a

Browse files
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_011UMR3jzKAuZ2od1t6WC6bY
1 parent 4df72ff commit d32c43a

3 files changed

Lines changed: 577 additions & 0 deletions

File tree

crates/calm-core/src/fs/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//! Filesystem primitives that need a stronger containment guarantee than
2+
//! `path_policy`'s textual canonicalize-then-compare check can give -- see
3+
//! `rooted`'s own doc comment for what that gap is and how this closes it.
4+
5+
pub mod rooted;
6+
7+
pub use rooted::{ContainmentMethod, RootedFilesystem, RootedFsError, WriteReceipt};

0 commit comments

Comments
 (0)