From ff4ba7d3194d0fbef54ff88806593020ae377c66 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 9 Nov 2026 17:46:33 -0800 Subject: [PATCH] perf(runner): run launch artifacts in place from a trusted read-only root (#20428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Rust Runner (`paperclip-runnerd`) starts provider sidecars and proxies from qualified launch artifacts: a Node runtime and the scripts it runs > - Since #12699, Runner copies each artifact into a sealed memfd and checks its SHA-256 on every verification. This defends against a path swap or an in-place rewrite between the check and the exec > - On a hosted agent computer (4 vCPU), the copy and digest of the 127 MB Node takes about 0.26–0.30 s warm and about 1.2 s right after a wake. This is on the path from `session.open` to the sidecar spawn, and again on every warm `run.attach`. Each Runner also holds about 125 MiB of shmem for the copy > - On that computer, the artifacts live on the Runner ROM: a read-only erofs image that the deploy pins by digest. The threat that the memfd defends against does not exist there. On local hosts the artifacts are writable, so the memfd must stay > - This pull request adds an explicit, path-scoped, default-off `--trusted-launch-root `. Runner runs an artifact in place only when its open descriptor proves that it is on an immutable mount under that root. Everything else keeps the sealed, hashed snapshot > - The benefit is a shorter `session.open` and `run.attach` on agent computers, and about 125 MiB less shmem per Runner, with no change where the flag is absent ## Linked Issues or Issue Description **What existing behavior does this improve?** Launch-artifact verification in `paperclip-runnerd` (`qualified_launch.rs`). Its callers are the ACPX `verified_transport` (every `session_config`, including the park rebind) and the OpenCode and Cursor Cloud proxy launches. **Subsystem affected** Rust Runner (`packages/paperclip-runner/runner/crates/runner-core`). **Current behavior** Every verification opens the artifact, copies all bytes into a sealed memfd, and hashes them. Node is 127 MB. The cost is described in the Thinking Path. **Proposed behavior** When the host passes `--trusted-launch-root /opt/paperclip/runner` and the root passes its checks, Runner holds each admitted artifact open in place and executes it through `/proc/self/fd/N`. It does not copy or re-hash it. Without the flag, nothing changes. **Reason and benefit** See the Thinking Path. Refs #12699 (introduced the sealed memfd). Refs #20423 (overlap, see below). ## What Changed - `qualified_launch.rs`: new `TrustedLaunchRoot` and `set_trusted_launch_root` (process-global, set once at startup, like `--task-memory-cgroup`). - **Root checks at startup.** The path must be absolute, already canonical (no symlink or `..`), a directory, and on a read-only, exec-permitted erofs or squashfs mount. If a check fails, runnerd logs a warning and keeps today's behavior. - **Per-artifact checks, on the open descriptor.** After today's checks (regular file, not a symlink, not group- or world-writable, open-time identity), the descriptor must be a regular file on the root's device. Its kernel path (`readlink /proc/self/fd/N`) must lie under the root by path components, so `/opt/paperclip/runner2` does not match. Its mount must pass `fstatvfs` (`ST_RDONLY` set, `ST_NOEXEC` clear) and `fstatfs` (erofs or squashfs magic). If any check fails, Runner logs the reason and uses the sealed snapshot. - **Admitted artifacts** become `VerifiedProcessArtifact::in_place_on_trusted_root` (crate-private). The held file goes through the existing `inherited_artifact` path, so the exec is `/proc/self/fd/N` and `PAPERCLIP_VERIFIED_RUNTIME_EXECUTABLE` still names a descriptor that nested launches can reopen. - New `verify_assigned_executable_launch_artifact` never uses the root. `stdio_mcp.rs` now calls it, so host-assigned stdio MCP binaries always keep the sealed snapshot. - `paperclip-runnerd.rs`: parse `--trusted-launch-root `. Older runnerd builds ignore unknown flags, so a host can pass it before every Runner understands it. - The trusted path is Linux-only. On macOS and Windows, `TrustedLaunchRoot::open` always refuses. - `tests/runner-e2e/SECURITY.md`: documents how the Kimi and Pi provider-death fixtures behave under a trusted root. ### Decision: the trusted path drops the digest The trusted path does not hash the artifact, not even once per process: - The ROM image is pinned by digest at deploy time. - On the agent computer, the Launcher computes the expected `--acpx-sidecar-command-sha256` and `--acpx-sidecar-script-sha256` from the same ROM files immediately before it starts Runner. A re-hash of an immutable file can only repeat that result. - One hash per process would not help `session.open`, because each task starts a new Runner process. - The mount check is the guard: erofs and squashfs have no write path, so the bytes cannot change while the descriptor is open. ### Fixtures that assert the sealed memfd - Untrusted-mode assertions are unchanged. The fixtures do not pass the flag. - Kimi (`kimi-provider-fault.py` `checked_parent_descriptor`): with a trusted root, the parent descriptor is the ROM's Node file (one link). The existing linked-descriptor branch handles it: same inode as `/proc//exe`, and the complete bytes hashed against `nodeSha256`. That branch never accepts the file as a memfd. The existing test `test_linked_descriptor_keeps_regular_single_link_checks_without_memfd_claim` covers it. - Pi provider-death admission binds `/proc/self/fd/3|7` to the native distribution snapshot's Node. The TypeScript ACPX closure copies that file whether or not a trusted root is set, so the flag does not affect it. ### Overlap with #20423 #20423 (draft) moves the same copy and hash to Runner boot and reuses the result. Where this flag is set (Cloud agent computers), there is nothing left to precompute, so #20423 is mostly redundant there. #20423 still helps hosts without a trusted root (local and sandbox Runners). This PR does not change #20423. ## Verification All runs are on Linux (aarch64, Rust 1.97.1, in a container): - `cargo fmt --manifest-path runner/Cargo.toml --all -- --check` - `cargo check --manifest-path runner/Cargo.toml --locked --workspace` - `cargo test -p paperclip-runner-core --lib -- qualified_launch trusted_root commonjs`: 12 passed. The new tests are: - `an_artifact_under_an_immutable_trusted_root_runs_in_place_without_a_digest`: trusted-root hit, with no memfd, the same inode as the source, and no re-hash. - `an_artifact_outside_the_trusted_root_keeps_the_sealed_snapshot`: a path outside the root is sealed, and a wrong digest still fails. - `a_sibling_with_the_root_as_a_name_prefix_is_outside_the_root`: `rom2/` does not match `rom`. - `a_root_that_is_not_read_only_falls_back_to_the_sealed_snapshot`: the real probe refuses a writable root at startup. A per-artifact mount failure falls back, and a wrong digest still fails. - `the_real_mount_probe_rejects_a_writable_filesystem` - `symlink_and_parent_escapes_from_the_root_fall_back`: a directory symlink out of the root, a `..` path out of the root, and a final-component symlink (still refused). - `a_root_must_be_absolute_canonical_and_a_directory` - `without_a_trusted_root_launch_artifacts_are_sealed_and_hashed`: flag absent, behavior unchanged. - `host_assigned_executables_ignore_the_trusted_root` - `trusted_root_artifacts_execute_the_held_file_and_reopen_it_for_nested_launches`: Runner execs from the held file, and a child re-execs `$PAPERCLIP_VERIFIED_RUNTIME_EXECUTABLE`. Both report the source file as `/proc/self/exe`. - `cargo test -p paperclip-runner-core --test stdio_mcp --test process_supervisor`: 4 and 8 passed. - Ad hoc, not committed: a privileged container with a real loop-mounted squashfs. The real `fstatvfs`/`fstatfs` probe admitted a `ro` mount and refused a `ro,noexec` mount. The unit tests replace only that mount probe, because CI cannot mount filesystems. - Not verified: a run on an agent computer with the real erofs ROM, and the latency and memory savings there. The Cloud Launcher change that passes the flag is a separate follow-up. ## Risks - **This change needs review from a security owner before merge.** It removes the sealed copy and the per-launch digest for admitted artifacts. That relies on the host's assertion that the root is a digest-pinned immutable image, plus the descriptor checks above. - **Misconfiguration fails closed.** A wrong, writable, `noexec`, non-canonical or missing root only loses the speedup, and runnerd logs a warning. There is no global "trust everything" switch, and Runner never selects a root from the filesystem it finds. - **Residual trust.** On an admitted file, Runner no longer detects a ROM image that differs from what the launch profile expected. On the agent computer, the Launcher derives the expected digests from the same ROM. Another host that passes the flag must pin its image the same way. An attacker with root in the guest (who could write the backing block device or remount) is out of scope; such an attacker can already control runnerd. - **Allowlist.** Only erofs and squashfs are accepted, because they cannot be written through any mount. A read-only bind mount of a writable filesystem is refused. A ROM that changes to another filesystem type will silently fall back to the sealed path (slower, still safe). - No migration. Behavior is unchanged when the flag is absent. Older runnerd builds ignore the flag. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits, container builds). Extended reasoning was on. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../runner-core/src/bin/paperclip-runnerd.rs | 18 + .../runner-core/src/process_supervisor.rs | 66 +++ .../runner-core/src/qualified_launch.rs | 464 +++++++++++++++++- .../crates/runner-core/src/stdio_mcp.rs | 6 +- tests/runner-e2e/SECURITY.md | 10 + 5 files changed, 559 insertions(+), 5 deletions(-) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs index 1241f1c5843..6d5ffa68fbf 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs @@ -559,6 +559,23 @@ fn apply_oom_score_adj(args: &[String]) -> Result<(), LocalRunnerError> { Ok(()) } +/// `--trusted-launch-root `: the host asserts that `` is an +/// immutable, digest-pinned image (the agent computer's Runner ROM). Launch +/// artifacts the root admits run in place instead of from a sealed copy. A root +/// that fails its startup checks only loses that speedup, never protection. +fn apply_trusted_launch_root(args: &[String]) -> Result<(), LocalRunnerError> { + let Some(path) = optional_value(args, "--trusted-launch-root")? else { + return Ok(()); + }; + match paperclip_runner_core::qualified_launch::TrustedLaunchRoot::open(Path::new(&path)) { + Ok(root) => paperclip_runner_core::qualified_launch::set_trusted_launch_root(root), + Err(reason) => eprintln!( + "paperclip-runnerd: ignoring --trusted-launch-root {path}: {reason}; launch artifacts keep sealed snapshots" + ), + } + Ok(()) +} + fn apply_task_memory_cgroup(args: &[String]) -> Result<(), LocalRunnerError> { if let Some(path) = optional_value(args, "--task-memory-cgroup")? { paperclip_runner_core::task_memory::set_task_cgroup(PathBuf::from(path)); @@ -640,6 +657,7 @@ fn run(args: &[String]) -> Result<(), LocalRunnerError> { } apply_oom_score_adj(args)?; apply_task_memory_cgroup(args)?; + apply_trusted_launch_root(args)?; if args.iter().any(|argument| argument == "--connect-url") || args.iter().any(|argument| argument == "--listen-address") || args.iter().any(|argument| argument == "--listen-port") diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs index 64d27043b29..e479e5247e9 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs @@ -102,6 +102,26 @@ impl VerifiedProcessArtifact { #[cfg(not(target_os = "macos"))] Self::snapshot_verified(display_path, file, expected_sha256) } + + /// Hold a launch artifact open in place instead of snapshotting it. + /// + /// Only `qualified_launch` calls this, and only after the trusted launch + /// root proved that this open descriptor itself lies on an immutable + /// mount under that root. The process then executes this descriptor + /// through `/proc/self/fd/N`, so a later path swap cannot change what + /// runs and nested launches reopen the same file. + #[cfg(target_os = "linux")] + pub(crate) fn in_place_on_trusted_root(display_path: PathBuf, file: File) -> Self { + Self { + display_path, + file: Arc::new(file), + } + } + + #[cfg(test)] + pub(crate) fn held_file(&self) -> &File { + &self.file + } } #[cfg(target_os = "macos")] @@ -2013,6 +2033,52 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[cfg(target_os = "linux")] + #[test] + fn trusted_root_artifacts_execute_the_held_file_and_reopen_it_for_nested_launches() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "paperclip-trusted-root-launch-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&directory).unwrap(); + let shell = fs::canonicalize(directory.join(".")).unwrap().join("sh"); + fs::copy(fs::canonicalize("/bin/sh").unwrap(), &shell).unwrap(); + let program = VerifiedProcessArtifact::in_place_on_trusted_root( + shell.clone(), + File::open(&shell).unwrap(), + ); + let launch = VerifiedProcessLaunch::new( + program, + vec![ + VerifiedProcessArgument::Literal("-c".to_owned()), + VerifiedProcessArgument::Literal(format!( + "readlink /proc/$$/exe; \"${VERIFIED_RUNTIME_EXECUTABLE_ENV}\" -c 'readlink /proc/$$/exe'" + )), + ], + ); + + let inherited = launch.inherited_command().unwrap(); + assert!(inherited.program.starts_with("/proc/self/fd/")); + let output = Command::new(&inherited.program) + .args(&inherited.args) + .env(VERIFIED_RUNTIME_EXECUTABLE_ENV, &inherited.program) + .output() + .unwrap(); + drop(inherited); + + assert!(output.status.success(), "{output:?}"); + let expected = format!("{}\n", shell.display()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + expected.repeat(2) + ); + fs::remove_dir_all(directory).unwrap(); + } + #[cfg(target_os = "macos")] #[test] fn executable_arguments_use_the_private_nested_launch_contract() { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/qualified_launch.rs b/packages/paperclip-runner/runner/crates/runner-core/src/qualified_launch.rs index 570080ff877..c65f07b3379 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/qualified_launch.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/qualified_launch.rs @@ -1,4 +1,6 @@ use std::fs::{self, File}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; #[cfg(unix)] use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -6,24 +8,210 @@ use std::os::unix::fs::{MetadataExt, PermissionsExt}; use crate::durable::{DurableRunnerError, QualifiedLaunchArtifact}; use crate::process_supervisor::VerifiedProcessArtifact; +static TRUSTED_LAUNCH_ROOT: OnceLock = OnceLock::new(); + +/// Set once at startup from `--trusted-launch-root`. Without it, every launch +/// artifact is copied into a sealed snapshot and hashed, as before. +pub fn set_trusted_launch_root(root: TrustedLaunchRoot) { + let _ = TRUSTED_LAUNCH_ROOT.set(root); +} + +/// A directory the host asserts is an immutable, digest-pinned image, such as +/// the agent computer's Runner ROM. Runner-owned launch artifacts whose open +/// descriptor lies on that image are executed in place: no sealed copy and no +/// per-launch digest. Anything the root does not admit keeps the sealed copy. +/// +/// The assertion is explicit and path-scoped. Admission still proves, from the +/// open descriptor, that the file is inside the root and on a read-only, +/// exec-permitted mount of a filesystem that cannot be written at all (erofs or +/// squashfs). A misconfigured root therefore only loses the speedup. +#[derive(Debug)] +pub struct TrustedLaunchRoot { + path: PathBuf, + #[cfg(target_os = "linux")] + device: u64, + #[cfg(target_os = "linux")] + immutable_mount: fn(&File) -> std::io::Result, +} + +impl TrustedLaunchRoot { + /// Accept a root only when it is absolute, already canonical (no symlink + /// or `..` component) and itself on an immutable mount. + pub fn open(path: &Path) -> Result { + #[cfg(target_os = "linux")] + { + Self::open_with_mount_check(path, immutable_exec_mount) + } + #[cfg(not(target_os = "linux"))] + { + let _ = path; + Err("trusted launch roots are supported only on Linux".to_owned()) + } + } + + #[cfg(target_os = "linux")] + fn open_with_mount_check( + path: &Path, + immutable_mount: fn(&File) -> std::io::Result, + ) -> Result { + if !path.is_absolute() { + return Err("trusted launch root must be an absolute path".to_owned()); + } + let canonical = fs::canonicalize(path) + .map_err(|error| format!("trusted launch root cannot be resolved: {error}"))?; + if canonical != path { + return Err("trusted launch root must not contain symlinks or `..`".to_owned()); + } + let directory = File::open(&canonical) + .map_err(|error| format!("trusted launch root cannot be opened: {error}"))?; + let metadata = directory + .metadata() + .map_err(|error| format!("trusted launch root cannot be inspected: {error}"))?; + if !metadata.is_dir() { + return Err("trusted launch root must be a directory".to_owned()); + } + if !immutable_mount(&directory).unwrap_or(false) { + return Err( + "trusted launch root is not on a read-only, exec-permitted erofs or squashfs mount" + .to_owned(), + ); + } + Ok(Self { + path: canonical, + device: metadata.dev(), + immutable_mount, + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Decide from the open descriptor alone, never from the name it was + /// opened by: the kernel's path for the descriptor must lie under the + /// root, on the root's filesystem, and that mount must be immutable. + #[cfg(target_os = "linux")] + fn admits(&self, file: &File) -> Result<(), &'static str> { + use std::os::fd::AsRawFd; + + let metadata = file.metadata().map_err(|_| "cannot inspect descriptor")?; + if !metadata.is_file() { + return Err("not a regular file"); + } + if metadata.dev() != self.device { + return Err("not on the trusted root's filesystem"); + } + let held = fs::read_link(format!("/proc/self/fd/{}", file.as_raw_fd())) + .map_err(|_| "cannot resolve descriptor")?; + if !held.is_absolute() + || held + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + || !held.starts_with(&self.path) + || held == self.path + { + return Err("outside the trusted root"); + } + if !(self.immutable_mount)(file).unwrap_or(false) { + return Err("not on a read-only, exec-permitted erofs or squashfs mount"); + } + Ok(()) + } +} + +/// erofs and squashfs have no write path at all, so a read-only mount of one +/// cannot change underneath an open descriptor, not even through another +/// mount of the same superblock. +#[cfg(target_os = "linux")] +fn immutable_exec_mount(file: &File) -> std::io::Result { + use rustix::fs::StatVfsMountFlags; + const EROFS_SUPER_MAGIC_V1: u32 = 0xE0F5_E1E2; + const SQUASHFS_MAGIC: u32 = 0x7371_7368; + + let mount = rustix::fs::fstatvfs(file)?; + if !mount.f_flag.contains(StatVfsMountFlags::RDONLY) + || mount.f_flag.contains(StatVfsMountFlags::NOEXEC) + { + return Ok(false); + } + // `f_type` is signed or unsigned, 32 or 64 bits, by architecture. The + // magic numbers are 32-bit, so compare the low word. + #[allow(clippy::unnecessary_cast)] + let filesystem = rustix::fs::fstatfs(file)?.f_type as u32; + Ok(matches!(filesystem, EROFS_SUPER_MAGIC_V1 | SQUASHFS_MAGIC)) +} + +/// Who chose an artifact. Only Runner-owned launch-profile artifacts may use +/// the trusted launch root. +#[derive(Clone, Copy)] +enum ArtifactOwner { + Runner, + HostAssigned, +} + +/// A Runner-owned artifact (provider runtime, sidecar or proxy) pinned by the +/// launch profile. It may run in place from the trusted launch root. pub fn verify_launch_artifact( artifact: &QualifiedLaunchArtifact, label: &str, ) -> Result { - verify_launch_artifact_with_role(artifact, label, false) + verify_owned( + artifact, + label, + false, + ArtifactOwner::Runner, + TRUSTED_LAUNCH_ROOT.get(), + ) } +/// The executable counterpart of [`verify_launch_artifact`]. pub fn verify_executable_launch_artifact( artifact: &QualifiedLaunchArtifact, label: &str, ) -> Result { - verify_launch_artifact_with_role(artifact, label, true) + verify_owned( + artifact, + label, + true, + ArtifactOwner::Runner, + TRUSTED_LAUNCH_ROOT.get(), + ) +} + +/// A host-assigned executable, such as a stdio MCP server. It is always +/// snapshotted and hashed, whatever the trusted launch root. +pub fn verify_assigned_executable_launch_artifact( + artifact: &QualifiedLaunchArtifact, + label: &str, +) -> Result { + verify_owned( + artifact, + label, + true, + ArtifactOwner::HostAssigned, + TRUSTED_LAUNCH_ROOT.get(), + ) +} + +fn verify_owned( + artifact: &QualifiedLaunchArtifact, + label: &str, + executable: bool, + owner: ArtifactOwner, + configured_root: Option<&TrustedLaunchRoot>, +) -> Result { + let trusted_root = match owner { + ArtifactOwner::Runner => configured_root, + ArtifactOwner::HostAssigned => None, + }; + verify_launch_artifact_with_role(artifact, label, executable, trusted_root) } fn verify_launch_artifact_with_role( artifact: &QualifiedLaunchArtifact, label: &str, executable: bool, + trusted_root: Option<&TrustedLaunchRoot>, ) -> Result { let source_metadata = fs::symlink_metadata(&artifact.path).map_err(|error| { DurableRunnerError::invalid(format!("failed to inspect qualified {label}: {error}")) @@ -64,6 +252,24 @@ fn verify_launch_artifact_with_role( "qualified {label} changed while it was opened" ))); } + #[cfg(target_os = "linux")] + if let Some(root) = trusted_root { + match root.admits(&file) { + // The image is immutable and pinned by digest where it is + // deployed, so re-hashing it here could only repeat that check. + Ok(()) => { + return Ok(VerifiedProcessArtifact::in_place_on_trusted_root( + canonical, file, + )) + } + Err(reason) => eprintln!( + "paperclip-runnerd: qualified {label} is not admitted by trusted launch root {} ({reason}); using a sealed snapshot", + root.path.display() + ), + } + } + #[cfg(not(target_os = "linux"))] + let _ = trusted_root; let snapshot = if executable { VerifiedProcessArtifact::snapshot_verified_executable(canonical, file, &artifact.sha256) } else { @@ -87,3 +293,257 @@ fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { && left.modified().ok() == right.modified().ok() && left.is_file() == right.is_file() } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + use std::time::{SystemTime, UNIX_EPOCH}; + + // A writable temporary directory stands in for the ROM. These checks + // replace only the mount-type probe; path, device and descriptor checks + // stay real. + fn immutable(_: &File) -> std::io::Result { + Ok(true) + } + + fn writable(_: &File) -> std::io::Result { + Ok(false) + } + + struct Fixture { + root: PathBuf, + } + + impl Fixture { + fn new(name: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "paperclip-trusted-launch-root-{name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(root.join("rom/libexec")).unwrap(); + fs::create_dir_all(root.join("outside")).unwrap(); + Self { + root: fs::canonicalize(root).unwrap(), + } + } + + fn rom(&self) -> PathBuf { + self.root.join("rom") + } + + fn artifact(&self, path: PathBuf, bytes: &[u8]) -> QualifiedLaunchArtifact { + fs::write(&path, bytes).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + QualifiedLaunchArtifact { + path, + sha256: format!("sha256:{:x}", Sha256::digest(bytes)), + } + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + fn sealed(artifact: &VerifiedProcessArtifact) -> bool { + let file = artifact.held_file(); + let link = fs::read_link(format!( + "/proc/self/fd/{}", + std::os::fd::AsRawFd::as_raw_fd(file) + )) + .unwrap(); + file.metadata().unwrap().nlink() == 0 + && link + .to_string_lossy() + .starts_with("/memfd:paperclip-verified-launch") + } + + fn same_inode(artifact: &VerifiedProcessArtifact, path: &Path) -> bool { + let held = artifact.held_file().metadata().unwrap(); + let named = fs::metadata(path).unwrap(); + held.dev() == named.dev() && held.ino() == named.ino() + } + + #[test] + fn an_artifact_under_an_immutable_trusted_root_runs_in_place_without_a_digest() { + let fixture = Fixture::new("hit"); + let root = TrustedLaunchRoot::open_with_mount_check(&fixture.rom(), immutable).unwrap(); + let mut artifact = fixture.artifact(fixture.rom().join("libexec/node"), b"node"); + // The trusted path does not re-hash: a wrong expected digest still + // admits, because the image itself is the pinned authority. + artifact.sha256 = "sha256:not-rehashed".to_owned(); + + let verified = + verify_launch_artifact_with_role(&artifact, "test", true, Some(&root)).unwrap(); + + assert!(!sealed(&verified)); + assert!(same_inode(&verified, &artifact.path)); + } + + #[test] + fn an_artifact_outside_the_trusted_root_keeps_the_sealed_snapshot() { + let fixture = Fixture::new("outside"); + let root = TrustedLaunchRoot::open_with_mount_check(&fixture.rom(), immutable).unwrap(); + let artifact = fixture.artifact(fixture.root.join("outside/node"), b"node"); + + let verified = + verify_launch_artifact_with_role(&artifact, "test", true, Some(&root)).unwrap(); + assert!(sealed(&verified)); + + let mut tampered = artifact.clone(); + tampered.sha256 = "sha256:wrong".to_owned(); + assert!(verify_launch_artifact_with_role(&tampered, "test", true, Some(&root)).is_err()); + } + + #[test] + fn a_sibling_with_the_root_as_a_name_prefix_is_outside_the_root() { + let fixture = Fixture::new("prefix"); + fs::create_dir_all(fixture.root.join("rom2")).unwrap(); + let root = TrustedLaunchRoot::open_with_mount_check(&fixture.rom(), immutable).unwrap(); + let artifact = fixture.artifact(fixture.root.join("rom2/node"), b"node"); + + let verified = + verify_launch_artifact_with_role(&artifact, "test", false, Some(&root)).unwrap(); + assert!(sealed(&verified)); + } + + #[test] + fn a_root_that_is_not_read_only_falls_back_to_the_sealed_snapshot() { + let fixture = Fixture::new("writable"); + // The real probe rejects a writable temporary directory at startup. + assert!(TrustedLaunchRoot::open(&fixture.rom()).is_err()); + + // A mount that stops being immutable after startup is rejected per + // artifact, from the open descriptor. + let mut root = TrustedLaunchRoot::open_with_mount_check(&fixture.rom(), immutable).unwrap(); + root.immutable_mount = writable; + let artifact = fixture.artifact(fixture.rom().join("libexec/node"), b"node"); + let verified = + verify_launch_artifact_with_role(&artifact, "test", true, Some(&root)).unwrap(); + assert!(sealed(&verified)); + + let mut tampered = artifact.clone(); + tampered.sha256 = "sha256:wrong".to_owned(); + assert!(verify_launch_artifact_with_role(&tampered, "test", true, Some(&root)).is_err()); + } + + #[test] + fn the_real_mount_probe_rejects_a_writable_filesystem() { + let fixture = Fixture::new("probe"); + let file = File::open(fixture.artifact(fixture.rom().join("node"), b"node").path).unwrap(); + assert!(!immutable_exec_mount(&file).unwrap()); + } + + #[test] + fn symlink_and_parent_escapes_from_the_root_fall_back() { + let fixture = Fixture::new("escape"); + let root = TrustedLaunchRoot::open_with_mount_check(&fixture.rom(), immutable).unwrap(); + let outside = fixture.artifact(fixture.root.join("outside/node"), b"node"); + + // A directory symlink inside the root that points outside it. + std::os::unix::fs::symlink(fixture.root.join("outside"), fixture.rom().join("escape")) + .unwrap(); + let through_link = QualifiedLaunchArtifact { + path: fixture.rom().join("escape/node"), + sha256: outside.sha256.clone(), + }; + let verified = + verify_launch_artifact_with_role(&through_link, "test", true, Some(&root)).unwrap(); + assert!(sealed(&verified)); + + // A `..` path that starts under the root and leaves it. + let dotted = QualifiedLaunchArtifact { + path: fixture.rom().join("libexec/../../outside/node"), + sha256: outside.sha256.clone(), + }; + let verified = + verify_launch_artifact_with_role(&dotted, "test", true, Some(&root)).unwrap(); + assert!(sealed(&verified)); + + // A final-component symlink is still refused outright. + std::os::unix::fs::symlink(&outside.path, fixture.rom().join("libexec/link")).unwrap(); + let linked = QualifiedLaunchArtifact { + path: fixture.rom().join("libexec/link"), + sha256: outside.sha256, + }; + assert!(verify_launch_artifact_with_role(&linked, "test", true, Some(&root)).is_err()); + } + + #[test] + fn a_root_must_be_absolute_canonical_and_a_directory() { + let fixture = Fixture::new("root-shape"); + std::os::unix::fs::symlink(fixture.rom(), fixture.root.join("alias")).unwrap(); + for candidate in [ + PathBuf::from("relative/rom"), + fixture.root.join("alias"), + fixture.rom().join("libexec/.."), + fixture.root.join("missing"), + ] { + assert!( + TrustedLaunchRoot::open_with_mount_check(&candidate, immutable).is_err(), + "{}", + candidate.display() + ); + } + let file = fixture + .artifact(fixture.rom().join("libexec/node"), b"node") + .path; + assert!(TrustedLaunchRoot::open_with_mount_check(&file, immutable).is_err()); + } + + #[test] + fn without_a_trusted_root_launch_artifacts_are_sealed_and_hashed() { + let fixture = Fixture::new("absent"); + let artifact = fixture.artifact(fixture.rom().join("libexec/node"), b"node"); + + assert!(TRUSTED_LAUNCH_ROOT.get().is_none()); + assert!(sealed(&verify_launch_artifact(&artifact, "test").unwrap())); + assert!(sealed( + &verify_executable_launch_artifact(&artifact, "test").unwrap() + )); + + let mut tampered = artifact; + tampered.sha256 = "sha256:wrong".to_owned(); + assert!(verify_launch_artifact(&tampered, "test").is_err()); + } + + #[test] + fn host_assigned_executables_ignore_the_trusted_root() { + let fixture = Fixture::new("assigned"); + let root = TrustedLaunchRoot::open_with_mount_check(&fixture.rom(), immutable).unwrap(); + let artifact = fixture.artifact(fixture.rom().join("libexec/mcp"), b"mcp"); + + // The same configured root admits this file for a Runner-owned launch. + let runner = + verify_owned(&artifact, "test", true, ArtifactOwner::Runner, Some(&root)).unwrap(); + assert!(!sealed(&runner)); + + // A host-assigned executable at the same path stays sealed and hashed. + let assigned = verify_owned( + &artifact, + "test", + true, + ArtifactOwner::HostAssigned, + Some(&root), + ) + .unwrap(); + assert!(sealed(&assigned)); + let mut tampered = artifact; + tampered.sha256 = "sha256:wrong".to_owned(); + assert!(verify_owned( + &tampered, + "test", + true, + ArtifactOwner::HostAssigned, + Some(&root), + ) + .is_err()); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/stdio_mcp.rs b/packages/paperclip-runner/runner/crates/runner-core/src/stdio_mcp.rs index 7f580ea20a3..c530f6a82c8 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/stdio_mcp.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/stdio_mcp.rs @@ -15,7 +15,7 @@ use sha2::{Digest, Sha256}; use crate::durable::QualifiedLaunchArtifact; use crate::local_runner::LocalRunnerError; use crate::process_supervisor::{VerifiedProcessArgument, VerifiedProcessLaunch}; -use crate::qualified_launch::verify_executable_launch_artifact; +use crate::qualified_launch::verify_assigned_executable_launch_artifact; pub const BINDINGS_ENV: &str = "PAPERCLIP_NATIVE_MCP_STDIO"; pub const PROXY_ENV: &str = "PAPERCLIP_NATIVE_MCP_PROXY"; @@ -67,7 +67,7 @@ impl StdioMcpBinding { { return Err(invalid("invalid assigned stdio MCP binding")); } - verify_executable_launch_artifact( + verify_assigned_executable_launch_artifact( &QualifiedLaunchArtifact { path: self.command.clone(), sha256: self.sha256.clone(), @@ -271,7 +271,7 @@ pub fn run_proxy(path: &Path, content_images: bool) -> McpResult<()> { .map_err(|_| invalid("invalid stdio MCP configuration"))?; binding.validate()?; let (mut lock, resource_identity) = resource_lock(&binding.serial_resource)?; - let program = verify_executable_launch_artifact( + let program = verify_assigned_executable_launch_artifact( &QualifiedLaunchArtifact { path: binding.command.clone(), sha256: binding.sha256.clone(), diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index d186c8855de..709101ef64a 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -588,6 +588,16 @@ foreign inodes fail before any signal. Native Linux calibration exercises both descriptor launches with synthetic owned processes and no provider credentials; it does not qualify paid Pi behavior or change previous failed grades. +These fixtures run without `paperclip-runnerd --trusted-launch-root`, so the +Runner still hands providers a sealed memfd Node and the memfd assertions above +are unchanged. Under a trusted launch root (the agent computer's read-only Runner +ROM) the Runner instead holds the ROM's own Node file. Kimi admission then takes +its existing linked-descriptor branch: one link, the same inode as the parent's +`/proc//exe`, and the complete bytes hashed against `nodeSha256`. It never +accepts that file as a memfd. Pi admission binds to the native distribution +snapshot's Node, which the ACPX closure copies whether or not a trusted root is +set. + The private resource-admission marker and raw cleanup results control recovery-state retention independently of evidence packaging. A worker crash after admission keeps the owner-only recovery database; a confirmed pre-allocation bootstrap failure does not. Neither the marker nor the database enters published evidence. ## Task workspace fixture source