perf(runner): run launch artifacts in place from a trusted read-only root (#20428)

## 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 <dir>`. 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 <dir>`. 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/<pid>/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 <noreply@anthropic.com>
This commit is contained in:
Nicky LeachandClaude Opus 5.5 authored and GitHub committed 2026-11-09 17:46:33 -08:00
1 parent 5570debc5b
commit ff4ba7d319
5 files changed
+559 -5

No files matched your search

@@ -559,6 +559,23 @@ fn apply_oom_score_adj(args: &[String]) -> Result<(), LocalRunnerError> {
Ok(())
}
/// `--trusted-launch-root <dir>`: the host asserts that `<dir>` 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")
@@ -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() {
@@ -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<TrustedLaunchRoot> = 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<bool>,
}
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<Self, String> {
#[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<bool>,
) -> Result<Self, String> {
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<bool> {
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<VerifiedProcessArtifact, DurableRunnerError> {
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<VerifiedProcessArtifact, DurableRunnerError> {
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<VerifiedProcessArtifact, DurableRunnerError> {
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<VerifiedProcessArtifact, DurableRunnerError> {
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<VerifiedProcessArtifact, DurableRunnerError> {
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<bool> {
Ok(true)
}
fn writable(_: &File) -> std::io::Result<bool> {
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());
}
}
@@ -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(),
+10
View File
@@ -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/<pid>/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