diff --git a/CHANGELOG.md b/CHANGELOG.md index a6145b723..48b07e0fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,30 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ## [Unreleased] +### Security + +- **`astrid:process@1.0.0` spawn now supports host-verified, read-only file injection — an agent-neutral way to hand a spawned child unmodifiable bytes.** `spawn-request` gains `file-injections: list` (`{content, placement}`), honored by all three tiers (`spawn`, `spawn-background`, `spawn-persistent`). The capsule supplies the bytes plus how the child should find them; the host owns placement, integrity, and exposure. The motivating consumer is un-overridable per-spawn governance (a supervised agent reads a policy file a prompt-injected session cannot rewrite), but the bytes are content-opaque — the host never parses, validates, or filters them. The write-protection invariant is the whole point: the bytes the child reads MUST NOT be writable by **(a)** the child or any subprocess it spawns, **nor (b)** the spawning principal's capsule `fs_*` surface (which runs in capsule space, OUTSIDE the child's OS sandbox). The host therefore SNAPSHOTS `content` to a host-owned path outside every VFS mount (a private `TempDir`), BLAKE3-hashes the snapshot, VERIFIES the materialized bytes against the pin (closing the copy→expose TOCTOU), records the hash in the spawn audit, and exposes only that host-owned snapshot. Two placement modes, both exposing the same verified bytes read-only: **`env-pointer(env-var)`** materializes the snapshot at a host-owned path, exposes it read-only (Linux `--ro-bind P P` inside the `bwrap` namespace; macOS Seatbelt `file-read*` allow plus a trailing `file-write*` deny on the literal), and sets the named env var on the child to that path — OS-agnostic (Linux + macOS), for agents whose un-overridable tier is reachable via an env-redirected file (Claude `CLAUDE_CODE_MANAGED_SETTINGS_PATH`, Gemini `GEMINI_CLI_SYSTEM_SETTINGS_PATH`); **`fixed-path(path)`** `--ro-bind`s the snapshot at an absolute in-sandbox path — Linux-only (the bwrap remap), rejected on macOS with `invalid-input` (no remap; a host write to a caller-named path would be an escalation), for agents whose enforced tier is a fixed path with no env redirect (Codex `/etc/codex/requirements.toml`). On a platform without an OS sandbox a non-empty `file-injections` is refused (fail-secure). Because the host owns the materialized path in both modes, it never writes to a caller-named host path. No new capability — injection rides `host_process`, into the caller's own child only, and is a strict RESTRICTION surface. Empty / absent `file-injections` is a complete no-op (zero overhead). Closes #881. + +- **Capsule audit-feed subscriptions are now scoped to the subscriber's own principal by default.** A capsule that declared `ipc_subscribe = ["astrid.v1.audit.entry"]` (or any `astrid.v1.*` superset) in its manifest previously received *every* principal's audit entries — the cross-principal firehose was the default, gated by no capability and guarded by no reserved namespace (`check_subscribe_acl` matched topics syntactically, and the per-principal bus demux is fairness-only, not access control). An audit-covering subscription is now self-scoped **at enqueue** to the capsule's load-time owner principal, so a foreign principal's entries never enter the route's byte budget and a noisy co-principal can never head-evict the owner's own entries. The catalogued `audit:read_all` capability — resolved against the owner's profile and the live group config, never the self-declared manifest, so a capsule cannot grant itself the firehose — lifts the scope back to the full firehose, matching the gateway SSE model. Unscoped subscriptions are byte-identical to before; wildcard supersets are detected via the route-layer topic matcher. No WIT change, no new capability. Refs #850. + +- **Persistent host-process spawning now requires an explicit operator `allow_persistent` sub-grant.** `astrid:process.spawn-persistent` was gated only on the `host_process` capability plus an authenticated principal, even though the WIT documents it as additionally requiring an operator `allow_persistent` opt-in — the sub-grant did not exist, so the gate the contract promised was never enforced. `CapabilitiesDef` gains an `allow_persistent` manifest field (bool, `#[serde(default)]`, fail-closed): `host_process` alone grants the ephemeral `spawn` / `spawn-background` tiers, while a persistent child — which outlives the pooled instance and, on macOS, has no `die-with-parent` — additionally requires this opt-in. Without it, `spawn-persistent` returns `capability-denied` (audited); the ephemeral tiers are unaffected. The grant is surfaced by `enumerate-capabilities` through the serde-derived `held_names` / `has`, so a capsule can introspect it. No WIT change — the contract already specified the gate; this makes the host conform. Capsules that spawn persistent processes (e.g. `astrid-capsule-shell`) must add `allow_persistent` to their manifest `[capabilities]`. Refs #872. + +### Changed + +- **Guest `ERROR`-level logs now surface in the daemon log, not only the per-capsule log file.** When a run-loop capsule's `run()` returns `Err` before signaling ready, the daemon log showed only a contextless `Capsule run loop exited before signaling ready` — for the sole-socket-owner uplink (the cli proxy) that takes the whole daemon down, and diagnosing it meant hours of guessing. The reason was never actually lost: the SDK `#[astrid::run]` macro logs `run loop exited with error: {e}` at ERROR level before returning, but `sys::log` writes guest logs to a per-capsule file (`effective_capsule_log()`) and only fell back to the daemon's tracing subscriber when there was no per-capsule file — so the reason landed in a separate file operators don't check during a crash. ERROR-level guest logs are now emitted to the daemon's tracing subscriber **in addition** to the per-capsule file (lower levels stay file-only when captured, preserving the daemon log's signal-to-noise). The silent run-loop crash is now a one-line diagnosis (`ERROR …host::sys: run loop exited with error: … plugin=`) sitting right next to the kernel's message. No ABI/WIT change. Refs #884. +- **Publish/subscribe ACL wildcard matching now uses the route-layer subtree semantics — a declared trailing `*` covers the whole namespace.** ACL authorization used the strict `topic::topic_matches` (a `*` matches exactly one segment) while event delivery uses `astrid_events::TopicMatcher` (a trailing `*` is a subtree wildcard matching one or more segments at any depth). That divergence forced capsule manifests to enumerate wildcard depth (`astrid.v1.admin.*` / `*.*` / `*.*.*`) merely to authorize publishing topics whose depth varies or is unknown (uuid suffixes, variable sub-paths) — the same matcher asymmetry behind the cli run-loop subscribe confusion, now on the publish side. The two ACL checks (`publish_inner`, `check_subscribe_acl`) now authorize via a single allocation-free `astrid_events::topic_pattern_matches` — extracted as the one source of truth now used by routed delivery (`TopicMatcher`), broadcast delivery (`EventReceiver`), and ACL authorization alike — so a declared `astrid.v1.admin.*` authorizes every admin topic at any depth and the three paths can never silently diverge again (this also folds away a duplicated matcher and fixes a latent equal-segment-count bug in the broadcast path's hand-rolled iterator form). Scope is the two ACL sites only — interceptor dispatch keeps strict matching, and the runtime "wildcard must be terminal" subscribe gate is unchanged. The change is permissive (authorizes more, denies nothing previously allowed; delivery was already subtree); breadth is the operator's decision at install, not something the matcher enforces by forcing enumeration. Refs #882. +- **The `astrid:process` WIT now documents the id-keyed persistent `write-stdin` / `close-stdin` as implemented.** These landed in #867 — the persistent-process registry retains the child's stdin pipe host-side across pooled-instance resets (1 MiB-capped, backpressured, ownership-checked, audited) — but the WIT still tagged them `(NOT YET IMPLEMENTED)` and carried two stale `PERSISTENT TIER` "stubbed until the registry lands" banners. The tags are dropped and the banners corrected; function signatures are unchanged (doc-only). The ephemeral `ProcessHandle` form, `attach`, and `watch` / `unwatch` remain genuinely deferred and stay tagged. An acceptance test now locks the id-keyed path: deliver bytes → a by-id re-write (needing only registry + id, exactly what a post-reset instance holds) reaches the same child → `close-stdin` yields a clean EOF exit → over-cap returns `too-large` → wrong-owner returns `no-such-process` → post-close returns `closed`. Refs #870. +- **Host-call concurrency is split into separate blocking vs async-I/O semaphores — the LLM-path throughput gate.** A single `host_semaphore` sized at `cores - 2` previously gated *every* host call: both the blocking ones that `block_in_place` + `block_on` and pin a tokio worker for the whole permit-held wait (KV, identity, sys, fs, the net/process security gates, DNS, sockets) **and** the async-I/O ones that `.await` real I/O and free the worker (HTTP request/stream, `ipc::recv`). The `cores - 2` cap is right for the blocking class — it must not approach the worker-pool size or blocking host work starves the scheduler — but it throttles outbound I/O far below what the host sustains, capping the LLM/HTTP path. `HostState` now carries two gates: `blocking_semaphore` (host-derived `cores - 2`, the same ceiling — but no longer contended by I/O calls, so strictly more headroom) and `io_semaphore` (host-derived, cores-scaled and clamped by half the process `RLIMIT_NOFILE` — floored at 1, so on an fd-scarce host the descriptor budget wins and the ceiling can fall below the `IO_MIN` floor, preventing `EMFILE` — since each in-flight async call may hold a socket fd). The four generic `bounded_*` host helpers are unchanged; each call site passes the semaphore matching its class (the `block_on` helpers take blocking, the `await` helpers take I/O). Net stays on the blocking semaphore for now — reclassifying its socket/accept/DNS paths, which do real I/O but currently use the blocking `block_on` helpers, to the async semaphore is a follow-up. Refs #816. +- **Per-Store memory limiting now also meters per-principal peak usage.** The plain `wasmtime::StoreLimits` that capped each capsule instance's linear memory is replaced by a `StoreMemoryMeter` that keeps the same enforcement (the per-invocation `max_memory_bytes` ceiling) **and** records, into a kernel-owned shared `MemoryLedger`, the high-water linear-memory size each invoking principal grows a Store to — the RAM analogue of the per-principal `FuelLedger`. It is keyed by the same invoking principal (caller → owner → default) the fuel ledger charges, and re-targeted per invocation since a pooled Store is leased across principals. Sharded + atomic like the fuel ledger, so recording stays off the lock — and unlike the fuel ledger it is **bounded** (capped at a max principal count, evicting the lowest-peak entry when full) so a flood of ephemeral sub-agent principals cannot grow it without limit (`astrid#827`). Telemetry only — no deny path. Refs #816. + +- **The per-capsule instance pool is now dynamic and host-sized — the fixed `INSTANCE_POOL_SIZE = 16` is gone.** Each non-run-loop capsule used to eagerly instantiate exactly 16 `(Store, Instance)` pairs at load regardless of machine size or actual concurrency — 16× the linear-memory footprint up front even for an idle capsule, and a hard concurrency ceiling of 16 on a large host. The pool now warm-starts at a small `min_idle`, grows lazily (a checkout that finds no warm instance mints a fresh one while holding a permit, so the total never exceeds the max) toward a host-derived max (cores-scaled, replacing the magic 16), and an idle-eviction timer trims the warm set back down to `min_idle` after a burst subsides, reclaiming the memory of instances built under load. Net effect: less resting memory for idle capsules, more peak concurrency on big hosts. The max is operator-overridable via `[capsule].instance_pool_size`, `ASTRID_CAPSULE_INSTANCE_POOL_SIZE`, or `astrid-daemon --instance-pool-size`. Run-loop and `host_process` capsules stay pinned to a single Store — the `host_process` carve-out can never lease a second one (enforced by `allow_grow = false`, belt-and-suspenders over its always-warm single instance). Free-checkout soundness is unchanged: a lazily-grown instance is built by the same `HostState` factory as an eager one and reset identically on return. A RAM-budget-derived max lands with the per-principal memory ledger. Refs #816. + +- **Per-principal interceptor CPU is now summed cross-capsule into one kernel-owned `FuelLedger`.** Each capsule's `WasmEngine` previously kept its own per-principal fuel `HashMap`, fragmenting a principal that drove N capsules into N independent sub-totals; the ledger is now a single kernel-owned `Arc>` cloned into every engine, summing a principal's interceptor CPU across all capsules. Sharded + atomic (never a global mutex), so it does not reserialise the hot interceptor path. Telemetry only — no read/deny path in this change. Refs #819. + +- **Per-capsule WASM instance pool: principals' interceptors now run concurrently instead of serialising through one `Store`.** Each non-run-loop capsule was a single `Store` behind one mutex, so `invoke_interceptor` processed exactly one invocation at a time per capsule — the throughput floor that kept the #813 orchestration cliff open even after the async-Wasmtime work removed worker-pinning (one LLM turn every ~3 s, invariant to concurrency across a 50× range, measured directly as a 2000+ deep invocation backlog on one `Store`). `WasmEngine` now holds a `pool::CapsuleInstancePool` of N `(Store, Instance)` pairs built from one shared `InstancePre`; `invoke_interceptor` leases a free instance (semaphore-gated `checkout().await`) so up to N principals execute on independent `Store`s at once. Lease lifecycle is one RAII guard (`PoolCheckout`) whose `Drop` folds the Phase-3 CLEAR (resetting every `invocation_*` field) **and** the return-to-pool onto every exit path — normal return, `?`, panic-unwind, and future-drop on caller cancellation — replacing the old inline `ClearOnDrop`. Free checkout is sound because the per-capsule pool-safety audit confirmed no pooled capsule relies on in-WASM-memory state surviving across invocations (interceptor capsules use wasmtime resources within a single invocation). Pool size is a host-derived dynamic max (the originally-fixed `INSTANCE_POOL_SIZE = 16` is superseded by the dynamic, host-sized pool — see the dedicated entry above), or **1** for run-loop capsules (they keep their dedicated `Store`, owned by the run loop) and for `host_process` capsules — a fail-secure carve-out for `astrid-capsule-shell`, whose background-process handles live across invocations and must stay single-`Store`; keyed off the existing capability, so no manifest/contract change. `ipc_limiter` is now a shared `Arc` across a capsule's instances so the per-capsule IPC throughput budget is not multiplied by pool size; the resource-table-mirror counters (`net_stream_count`, `subscription_count`) stay per-`Store` (correctly scoped to each instance's table). Refs #816. + +- **Async Wasmtime: guest invocations no longer pin tokio workers (`block_in_place` removed from the WASM hot path).** Component-Model async is now on across the kernel — every guest entry goes through `Linker::instantiate_async` / `TypedFunc::call_async`, and the per-capsule `Store` is now wrapped in a `tokio::sync::Mutex` instead of a `std::sync::Mutex`. The orchestration cliff fix from #813 protected the per-(capsule, topic, principal) routing layer; this finishes the job at the wasmtime layer by ensuring a parallel `invoke_interceptor` waiter `.await`s on the lock rather than holding a worker via `block_in_place` for the full lifetime of the currently-running guest call (#816). Per-capsule serialisation is preserved (one guest call at a time per capsule, same as before), but the executor is free to schedule other capsules while one is queued. `ExecutionEngine::invoke_interceptor` and `Capsule::invoke_interceptor` are now `async fn`; the dispatcher / kernel callers `.await` accordingly. `engine::wasm::run_lifecycle` is `async fn` too, with `astrid-capsule-install::lifecycle` driving it through the available runtime handle. Cancellation safety: the existing `ClearOnDrop` RAII guard already runs on the future-drop path, so a cancelled interceptor still clears `caller_context`, `interceptor_active`, and every `invocation_*` field on `HostState` before releasing the store lock — `tokio::sync::Mutex` has no poisoning to recover from, so the previous "poisoned lock" branches are gone. Host trait impls remain synchronous: this lands the runtime infrastructure for async hosts but does not yet flip the bindgen-side `imports: { default: async }` flag — the slow-blocking host fns (`ipc::recv`, `elicit`, `net.read`/`write`, `http::request`) still use `bounded_block_on_cancellable` internally, scoped to a single host call rather than the entire guest invocation. Migrating those to truly async host fns is a follow-up. Refs #816. + ### Added - **The `astrid mcp serve` shim now elicits interactive user consent when the sage-mcp broker reports an untrusted ingress, replacing the static operator allow-list.** Previously the broker gated its state-mutating `tools/call` front door on an operator-maintained `trusted_ingress_ids` list; an ingress that was not pre-pinned was hard-denied. The broker now replies an `ingress_approval_required` signal for a not-yet-trusted ingress, and the shim turns it into a user consent prompt via the MCP elicitation capability ("Allow Astrid tool calls from this session?"). On accept the shim forwards `astrid.v1.request.mcp.ingress.respond` `{ req_id, accept }`, the broker records trust keyed on the kernel-stamped caller `source_id` (never a body field), and the shim re-sends the original call. Fail-secure: a client without elicitation, a decline / cancel, an elicit error, or a broker that could not persist the grant all return an MCP error to the client and record no trust. Manual end-to-end verification of the live elicit flow is pending (needs an elicitation-capable client, Claude Code ≥2.1.76). Paired with the sage-mcp broker change. Closes #917. diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/inject.rs b/crates/astrid-capsule/src/engine/wasm/host/process/inject.rs new file mode 100644 index 000000000..0fe0b37bc --- /dev/null +++ b/crates/astrid-capsule/src/engine/wasm/host/process/inject.rs @@ -0,0 +1,386 @@ +//! Read-only file injection for sandboxed spawns. +//! +//! At process spawn, the host can expose host-verified, READ-ONLY bytes to the +//! spawned child's OS sandbox (bwrap on Linux, Seatbelt on macOS). This module +//! owns the snapshot / verify / audit work behind the +//! `spawn-request.file-injections` surface. +//! +//! # Invariant +//! +//! The bytes the child reads MUST NOT be writable by (a) the child or any +//! subprocess it spawns, NOR (b) the spawning principal's capsule `fs_*` surface +//! (which runs in capsule space, OUTSIDE the child's OS sandbox). +//! +//! # Integrity contract (per injection, at spawn) +//! +//! 1. BLAKE3-hash `content`. +//! 2. Snapshot it to a host-owned path the child AND the capsule fs surface +//! cannot write (a private `TempDir`, outside every VFS mount), then VERIFY by +//! re-reading the materialized bytes against the pin (closes the copy->expose +//! TOCTOU). +//! 3. Record the hash in the spawn audit. +//! +//! # Placement modes +//! +//! - `env-pointer(var)` — expose the snapshot read-only AT ITS OWN host path +//! (Linux `--ro-bind P P` so it survives the `/tmp` tmpfs overlay; macOS +//! `allow file-read*` + a trailing `deny file-write*` on the literal) and set +//! the env var `var` on the child to that path. The host owns the path, so +//! there is no caller-chosen target and no host write to a caller-named path. +//! OS-agnostic (Linux + macOS). +//! - `fixed-path(path)` — `--ro-bind` the snapshot at the absolute in-sandbox +//! `path`. LINUX ONLY (the bwrap mount-namespace remap); rejected on macOS, +//! where there is no remap and materializing at a caller-named host path would +//! be an arbitrary host write. +//! +//! The host owns the materialized bytes in both modes, so it never live-binds +//! bytes the capsule can still reach. + +use std::path::PathBuf; + +use astrid_crypto::ContentHash; + +use crate::engine::wasm::bindings::astrid::process::host::{ + ErrorCode, FileInjection, InjectionPlacement, +}; + +/// Per-injection content ceiling. Managed-policy files are kilobytes; this +/// bounds a capsule's host-scratch footprint per spawn. Oversize => `too-large`. +const MAX_INJECTION_BYTES: usize = 1024 * 1024; + +/// RAII cleanup for a spawn's injections. Every snapshot lives in `_scratch` +/// (a private `TempDir`) on every platform, removed when it drops. The owner of +/// this guard controls the lifetime of the exposed bytes: it must outlive the +/// child. +pub(super) struct InjectionGuard { + /// Snapshots live here, auto-removed on drop. `None` when there are no + /// injections. + _scratch: Option, +} + +impl InjectionGuard { + /// An empty guard (no injections, nothing to clean). + fn empty() -> Self { + Self { _scratch: None } + } +} + +/// The result of preparing a spawn's injections: the sandbox-layer bind specs, +/// the env vars the host must set on the child (one per `env-pointer` +/// placement), the cleanup guard, and the `(descriptor, blake3-hex)` audit pairs. +pub(super) struct PreparedInjections { + pub(super) sandbox: Vec, + /// `(env-var name, host path)` pairs the host sets on the child so an + /// `env-pointer` placement's bytes are discoverable. + pub(super) env: Vec<(String, String)>, + pub(super) guard: InjectionGuard, + /// `(placement descriptor, blake3 hex)` — recorded in the spawn audit; never + /// the bytes. + pub(super) audit: Vec<(String, String)>, +} + +impl PreparedInjections { + fn empty() -> Self { + Self { + sandbox: Vec::new(), + env: Vec::new(), + guard: InjectionGuard::empty(), + audit: Vec::new(), + } + } +} + +/// Validate an `env-pointer` variable NAME: non-empty, no `=`, no NUL. The host +/// sets it on the child, so a malformed name must not be able to break the env +/// list or smuggle a second assignment. +fn validate_env_name(name: &str) -> Result<(), ErrorCode> { + if name.is_empty() || name.contains(['=', '\0']) { + return Err(ErrorCode::InvalidInput); + } + Ok(()) +} + +/// Validate a `fixed-path` target: absolute, free of the forbidden sandbox +/// characters (double-quote, backslash, null), and free of `..` components (a +/// `..` could make bwrap create the mount point somewhere unintended). +fn validate_fixed_path(target: &str) -> Result<(), ErrorCode> { + use std::path::Component; + let path = std::path::Path::new(target); + if !path.is_absolute() || target.contains(['"', '\\', '\0']) { + return Err(ErrorCode::InvalidInput); + } + if path.components().any(|c| matches!(c, Component::ParentDir)) { + return Err(ErrorCode::InvalidInput); + } + Ok(()) +} + +/// Write `bytes` to `dest` with mode 0600, then VERIFY by re-reading and +/// re-hashing against `pin`. Closes the copy->expose TOCTOU. +fn write_and_verify( + dest: &std::path::Path, + bytes: &[u8], + pin: &ContentHash, +) -> Result<(), ErrorCode> { + write_private(dest, bytes)?; + let reread = std::fs::read(dest) + .map_err(|_| ErrorCode::Unknown("injection snapshot reread failed".into()))?; + if &ContentHash::hash(&reread) != pin { + return Err(ErrorCode::Unknown( + "injection snapshot integrity check failed".into(), + )); + } + Ok(()) +} + +/// Write `bytes` to `dest` creating it fresh with file mode 0600. +/// +/// Uses `create_new` (`O_CREAT | O_EXCL`): the snapshot path is always a +/// unique index under a freshly-minted host-private `TempDir`, so the file +/// never pre-exists on the legitimate path. `O_EXCL` makes that an enforced +/// invariant rather than an assumption — it refuses to open an existing file +/// or follow a symlink planted at `dest`, so a write can never truncate or +/// redirect through a pre-existing entry (defense in depth against a future +/// caller that routes a non-scratch path here). +fn write_private(dest: &std::path::Path, bytes: &[u8]) -> Result<(), ErrorCode> { + use std::io::Write as _; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + opts.mode(0o600); + } + let mut f = opts + .open(dest) + .map_err(|e| ErrorCode::Unknown(format!("injection snapshot write failed: {e}")))?; + f.write_all(bytes) + .map_err(|e| ErrorCode::Unknown(format!("injection snapshot write failed: {e}")))?; + Ok(()) +} + +/// Snapshot `content` into the spawn's scratch `TempDir` (created lazily), +/// verify it, and return the host-owned path. Shared by both placement modes. +fn snapshot( + scratch: &mut Option, + index: usize, + content: &[u8], + hash: &ContentHash, +) -> Result { + if scratch.is_none() { + *scratch = Some( + tempfile::TempDir::new() + .map_err(|e| ErrorCode::Unknown(format!("injection scratch: {e}")))?, + ); + } + let dir = scratch.as_ref().expect("scratch set above"); + let dest = dir.path().join(index.to_string()); + write_and_verify(&dest, content, hash)?; + Ok(dest) +} + +/// Prepare all injections for one spawn. Snapshots + verifies each, and produces +/// the sandbox-layer bind specs, the env vars to set on the child, the cleanup +/// guard, and audit pairs. Empty input is a complete no-op (no scratch dir, no +/// fs work). +pub(super) fn prepare_injections( + injections: &[FileInjection], +) -> Result { + if injections.is_empty() { + return Ok(PreparedInjections::empty()); + } + + let mut prepared = PreparedInjections::empty(); + // One scratch dir for the whole spawn, created lazily on first use. + let mut scratch: Option = None; + + for (index, inj) in injections.iter().enumerate() { + if inj.content.len() > MAX_INJECTION_BYTES { + return Err(ErrorCode::TooLarge); + } + let hash = ContentHash::hash(&inj.content); + + match &inj.placement { + InjectionPlacement::EnvPointer(var) => { + validate_env_name(var)?; + let dest = snapshot(&mut scratch, index, &inj.content, &hash)?; + let dest_str = dest + .to_str() + .ok_or_else(|| ErrorCode::Unknown("injection path is not UTF-8".into()))? + .to_string(); + // Expose the snapshot read-only at its OWN host path: Linux + // `--ro-bind P P` (so it punches through the `/tmp` tmpfs); macOS + // `allow file-read*` + a trailing `deny file-write*` on the + // literal. Then point the child's env var at it. + prepared.sandbox.push(astrid_workspace::RoInjection { + source: dest.clone(), + target: dest, + }); + prepared.env.push((var.clone(), dest_str)); + prepared.audit.push((format!("env:{var}"), hash.to_hex())); + }, + InjectionPlacement::FixedPath(path) => { + materialize_fixed_path( + &mut scratch, + index, + &inj.content, + &hash, + path, + &mut prepared, + )?; + }, + } + } + + prepared.guard._scratch = scratch; + Ok(prepared) +} + +/// `fixed-path` placement: Linux-only ro-bind of the snapshot at `path`. On +/// macOS (and any non-Linux target) there is no mount-namespace remap, so this +/// is rejected — materializing at a caller-named host path would be an arbitrary +/// host write. The path is validated on every platform so a malformed `path` is +/// `invalid-input` regardless of the unsupported-platform rejection. +fn materialize_fixed_path( + scratch: &mut Option, + index: usize, + content: &[u8], + hash: &ContentHash, + path: &str, + prepared: &mut PreparedInjections, +) -> Result<(), ErrorCode> { + validate_fixed_path(path)?; + #[cfg(target_os = "linux")] + { + let dest = snapshot(scratch, index, content, hash)?; + prepared.sandbox.push(astrid_workspace::RoInjection { + source: dest, + target: PathBuf::from(path), + }); + prepared.audit.push((format!("path:{path}"), hash.to_hex())); + Ok(()) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (scratch, index, content, hash, prepared); + Err(ErrorCode::InvalidInput) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_env_name_rejects_empty_and_specials() { + assert!(matches!( + validate_env_name(""), + Err(ErrorCode::InvalidInput) + )); + assert!(matches!( + validate_env_name("FOO=BAR"), + Err(ErrorCode::InvalidInput) + )); + assert!(matches!( + validate_env_name("FOO\0BAR"), + Err(ErrorCode::InvalidInput) + )); + } + + #[test] + fn validate_env_name_accepts_normal() { + assert!(validate_env_name("CLAUDE_CODE_MANAGED_SETTINGS_PATH").is_ok()); + } + + #[test] + fn validate_fixed_path_rejects_relative_and_specials() { + assert!(matches!( + validate_fixed_path("relative/path"), + Err(ErrorCode::InvalidInput) + )); + assert!(matches!( + validate_fixed_path("/etc/ev\"il"), + Err(ErrorCode::InvalidInput) + )); + assert!(matches!( + validate_fixed_path("/etc/ev\\il"), + Err(ErrorCode::InvalidInput) + )); + assert!(matches!( + validate_fixed_path("/etc/ev\0il"), + Err(ErrorCode::InvalidInput) + )); + } + + #[test] + fn validate_fixed_path_rejects_parent_dir_escape() { + // A `..` could make bwrap create the mount point somewhere unintended. + assert!(matches!( + validate_fixed_path("/etc/agent/../../escape"), + Err(ErrorCode::InvalidInput) + )); + } + + #[test] + fn validate_fixed_path_accepts_absolute_clean() { + assert!(validate_fixed_path("/etc/codex/requirements.toml").is_ok()); + } + + #[test] + fn write_and_verify_roundtrip_succeeds() { + let dir = tempfile::TempDir::new().unwrap(); + let dest = dir.path().join("snap"); + let bytes = b"injected policy bytes"; + let pin = ContentHash::hash(bytes); + write_and_verify(&dest, bytes, &pin).expect("verify should pass on a faithful write"); + assert_eq!(std::fs::read(&dest).unwrap(), bytes); + } + + #[test] + fn write_and_verify_detects_mismatch() { + let dir = tempfile::TempDir::new().unwrap(); + let dest = dir.path().join("snap"); + let wrong_pin = ContentHash::hash(b"some other bytes"); + let err = write_and_verify(&dest, b"actual bytes", &wrong_pin).unwrap_err(); + assert!(matches!(err, ErrorCode::Unknown(ref m) if m.contains("integrity check failed"))); + } + + #[test] + fn write_private_sets_0600_on_unix() { + let dir = tempfile::TempDir::new().unwrap(); + let dest = dir.path().join("snap"); + write_private(&dest, b"x").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(&dest).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "snapshot must be owner-only 0600"); + } + } + + #[test] + fn write_private_refuses_existing_path() { + // O_EXCL invariant: the snapshot writer must never open — and so never + // truncate, nor follow a symlink planted at — a path that already + // exists. A pre-existing entry makes the write fail with its content + // left intact, rather than being overwritten through. + let dir = tempfile::TempDir::new().unwrap(); + let dest = dir.path().join("snap"); + std::fs::write(&dest, b"pre-existing").unwrap(); + let err = write_private(&dest, b"new bytes").unwrap_err(); + assert!(matches!(err, ErrorCode::Unknown(_))); + assert_eq!( + std::fs::read(&dest).unwrap(), + b"pre-existing", + "existing content must not be truncated through" + ); + } + + #[test] + fn empty_injections_is_noop() { + let prepared = prepare_injections(&[]).expect("empty is ok"); + assert!(prepared.sandbox.is_empty()); + assert!(prepared.env.is_empty()); + assert!(prepared.audit.is_empty()); + } +} diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/managed.rs b/crates/astrid-capsule/src/engine/wasm/host/process/managed.rs index eaa89309f..54deec687 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/managed.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/managed.rs @@ -32,6 +32,14 @@ pub struct ManagedProcess { #[allow(dead_code)] pub(super) command: String, pub(super) creator: astrid_core::principal::PrincipalId, + /// Cleanup guard for any read-only file injections wired into this child's + /// sandbox. Lives as long as the handle: on Linux it keeps the ro-bind + /// snapshot source alive for the child's lifetime and removes the scratch + /// dir on drop; on macOS it unlinks the materialized target files on drop. + /// `None` when the spawn had no injections. Cleaned by the struct's own + /// drop — no logic needed in `ManagedProcess::Drop`. + #[allow(dead_code)] // Held purely for its Drop; never read after spawn. + pub(super) injection_guard: Option, } /// Synchronously kill a child process group on Unix and start the kill @@ -103,10 +111,18 @@ pub(super) fn spawn_reader_task( } /// Prepare a sandboxed command. Shared between spawn and spawn-background. +/// +/// `injections` exposes host-verified, read-only files inside the child's +/// sandbox (see [`InjectionGuard`](super::inject)); pass `&[]` for none. +/// `inject_env` sets host-controlled env vars on the child (the `env-pointer` +/// placements point an agent at its injected file); pass `&[]` for none. These +/// are set by the HOST authoritatively — not via the guest `spawn-request.env`. pub(super) fn prepare_sandboxed_command( cmd: &str, args: &[String], workspace_root: &std::path::Path, + injections: &[astrid_workspace::RoInjection], + inject_env: &[(String, String)], ) -> Result { let mut inner_cmd = Command::new(cmd); let str_args: Vec<&str> = args.iter().map(String::as_str).collect(); @@ -114,8 +130,11 @@ pub(super) fn prepare_sandboxed_command( inner_cmd.env_remove("ASTRID_SOCKET_PATH"); inner_cmd.env_remove("ASTRID_SESSION_TOKEN"); inner_cmd.env_remove("ASTRID_HOME"); + for (k, v) in inject_env { + inner_cmd.env(k, v); + } - SandboxCommand::wrap(inner_cmd, workspace_root) + SandboxCommand::wrap_with_injections(inner_cmd, workspace_root, injections) .map_err(|e| format!("failed to wrap command in sandbox: {e}")) } diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/mod.rs b/crates/astrid-capsule/src/engine/wasm/host/process/mod.rs index 08ef4ab80..a1d1f9b1c 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/mod.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/mod.rs @@ -18,6 +18,7 @@ //! table is the canonical storage for `ManagedProcess`. mod handle; +mod inject; mod managed; mod persistent; mod tracker; @@ -81,6 +82,48 @@ fn audit_process( } } +/// Audit a spawn that carried read-only file injections. Emits the same +/// `astrid.audit.process` line as [`audit_process`] plus an `injections` field +/// = a formatted `"="` list. Only the target paths and +/// content hashes are logged — never the source path's parent, the bytes, or +/// any token (consistent with the existing audit discipline). +fn audit_process_injections( + state: &HostState, + op: &'static str, + cmd: &str, + audit: &[(String, String)], + result: &Result, +) { + let injections = audit + .iter() + .map(|(target, hash)| format!("{target}={hash}")) + .collect::>() + .join(","); + let capsule_id = state.capsule_id.as_str(); + let principal = state.effective_principal(); + match result { + Ok(_) => tracing::debug!( + target: "astrid.audit.process", + %capsule_id, + %principal, + fn = op, + cmd, + injections = %injections, + "audit", + ), + Err(e) => tracing::debug!( + target: "astrid.audit.process", + %capsule_id, + %principal, + fn = op, + cmd, + injections = %injections, + error = ?e, + "audit", + ), + } +} + /// Audit an id-keyed persistent-process op. Logs a short, non-reversible /// hash of the `process-id` — never the raw token, per the WIT ("never the /// raw id") — plus the op, principal, and capsule. @@ -157,9 +200,17 @@ fn build_persistent_child( request: &SpawnRequest, workspace_root: &std::path::Path, want_stdin: bool, + injections: &[astrid_workspace::RoInjection], + inject_env: &[(String, String)], ) -> Result { - let mut sandboxed = prepare_sandboxed_command(&request.cmd, &request.args, workspace_root) - .map_err(|_| ErrorCode::InvalidInput)?; + let mut sandboxed = prepare_sandboxed_command( + &request.cmd, + &request.args, + workspace_root, + injections, + inject_env, + ) + .map_err(|_| ErrorCode::InvalidInput)?; // `configure_piped` sets the process group + stdout/stderr pipes. configure_piped(&mut sandboxed); if want_stdin { @@ -204,9 +255,30 @@ impl process::Host for HostState { return result; } - let mut sandboxed_cmd = - prepare_sandboxed_command(&request.cmd, &request.args, &workspace_root) - .map_err(|_| ErrorCode::InvalidInput)?; + // Snapshot + verify any read-only file injections before building the + // command. `_injection_guard` is held to the end of this fn so the + // host-owned snapshot lives for the child's lifetime and is cleaned up + // after the child has run. + let prepared = match inject::prepare_injections(&request.file_injections) { + Ok(p) => p, + Err(e) => { + let result: Result = Err(e); + audit_process(self, "astrid:process/host.spawn", &cmd_for_audit, &result); + return result; + }, + }; + let injection_audit = prepared.audit; + let injection_env = prepared.env; + let _injection_guard = prepared.guard; + + let mut sandboxed_cmd = prepare_sandboxed_command( + &request.cmd, + &request.args, + &workspace_root, + &prepared.sandbox, + &injection_env, + ) + .map_err(|_| ErrorCode::InvalidInput)?; sandboxed_cmd.stdout(Stdio::piped()); sandboxed_cmd.stderr(Stdio::piped()); @@ -253,7 +325,17 @@ impl process::Host for HostState { Err(ErrorCode::Cancelled) }, }; - audit_process(self, "astrid:process/host.spawn", &cmd_for_audit, &result); + if injection_audit.is_empty() { + audit_process(self, "astrid:process/host.spawn", &cmd_for_audit, &result); + } else { + audit_process_injections( + self, + "astrid:process/host.spawn", + &cmd_for_audit, + &injection_audit, + &result, + ); + } result } @@ -309,9 +391,33 @@ impl process::Host for HostState { return Err(ErrorCode::Cancelled); } - let mut sandboxed_cmd = - prepare_sandboxed_command(&request.cmd, &request.args, &workspace_root) - .map_err(|_| ErrorCode::InvalidInput)?; + // Snapshot + verify any read-only file injections. The guard is stored + // on the `ManagedProcess` below so it lives as long as the handle and + // cleans up the host-owned snapshot dir when it drops. + let prepared = match inject::prepare_injections(&request.file_injections) { + Ok(p) => p, + Err(e) => { + let result: Result, ErrorCode> = Err(e); + audit_process( + self, + "astrid:process/host.spawn-background", + &cmd_for_audit, + &result, + ); + return result; + }, + }; + let injection_audit = prepared.audit; + let injection_env = prepared.env; + + let mut sandboxed_cmd = prepare_sandboxed_command( + &request.cmd, + &request.args, + &workspace_root, + &prepared.sandbox, + &injection_env, + ) + .map_err(|_| ErrorCode::InvalidInput)?; configure_piped(&mut sandboxed_cmd); // Convert the prepared std::Command into a tokio::Command so the @@ -336,6 +442,7 @@ impl process::Host for HostState { stderr_buf: Arc::clone(&stderr_buf), command: command_str, creator: principal.clone(), + injection_guard: Some(prepared.guard), }; let pid = managed @@ -364,12 +471,22 @@ impl process::Host for HostState { .entry(principal) .or_insert(0) += 1; let result: Result, ErrorCode> = Ok(Resource::new_own(res.rep())); - audit_process( - self, - "astrid:process/host.spawn-background", - &cmd_for_audit, - &result, - ); + if injection_audit.is_empty() { + audit_process( + self, + "astrid:process/host.spawn-background", + &cmd_for_audit, + &result, + ); + } else { + audit_process_injections( + self, + "astrid:process/host.spawn-background", + &cmd_for_audit, + &injection_audit, + &result, + ); + } result } @@ -464,6 +581,24 @@ impl process::Host for HostState { return Err(ErrorCode::Cancelled); } + // Snapshot + verify any read-only file injections. The guard is threaded + // into the registry entry below so it lives as long as the persistent + // process and is cleaned up by `reap_entry` (which consumes the entry by + // value) on every reap path. + let prepared = match inject::prepare_injections(&request.file_injections) { + Ok(p) => p, + Err(e) => { + let result: Result = Err(e); + audit_process( + self, + "astrid:process/host.spawn-persistent", + &cmd_for_audit, + &result, + ); + return result; + }, + }; + let capsule_id_arc: Arc = Arc::from(self.capsule_id.as_str()); let workspace_root = self.workspace_root.clone(); // Per-principal concurrent cap, SHARED with `spawn-background`: subtract @@ -497,7 +632,13 @@ impl process::Host for HostState { } let want_stdin = request.keep_stdin_open.unwrap_or(false) || request.stdin.is_some(); - let mut child = match build_persistent_child(&request, &workspace_root, want_stdin) { + let mut child = match build_persistent_child( + &request, + &workspace_root, + want_stdin, + &prepared.sandbox, + &prepared.env, + ) { Ok(c) => c, Err(e) => { let result: Result = Err(e); @@ -563,6 +704,7 @@ impl process::Host for HostState { }; let command = format!("{} {}", request.cmd, request.args.join(" ")); + let injection_audit = prepared.audit; let result = self.persistent_processes.spawn(persistent::SpawnParams { creator: principal, capsule_id: capsule_id_arc, @@ -579,7 +721,18 @@ impl process::Host for HostState { max_lifetime_ms: request.max_lifetime_ms, idle_timeout_ms: request.idle_timeout_ms, exit_retention_ms: request.exit_retention_ms, + injection_guard: Some(prepared.guard), }); + if !injection_audit.is_empty() { + audit_process_injections( + self, + "astrid:process/host.spawn-persistent", + &cmd_for_audit, + &injection_audit, + &result, + ); + return result; + } audit_process( self, "astrid:process/host.spawn-persistent", diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/entry.rs b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/entry.rs index 942bc85c2..79cc41463 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/entry.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/entry.rs @@ -84,6 +84,12 @@ pub(super) struct PersistentEntry { /// The monitor task owning the `Child`. Aborting it drops the `Child`, /// whose `kill_on_drop(true)` SIGKILLs the process — the reap backstop. pub(super) monitor: tokio::task::JoinHandle<()>, + /// Cleanup guard for any read-only file injections wired into this child's + /// sandbox. Held for the process's lifetime; its `Drop` (Linux scratch dir + /// removal / macOS target unlink) fires when the entry is consumed by + /// `reap_entry`. `None` when the spawn had no injections. + #[allow(dead_code)] // Held purely for its Drop; never read after spawn. + pub(super) injection_guard: Option, } impl PersistentEntry { diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/mod.rs b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/mod.rs index 7d3b9e34d..c63271572 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/mod.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/mod.rs @@ -102,6 +102,12 @@ pub(in crate::engine::wasm::host::process) struct SpawnParams { pub(in crate::engine::wasm::host::process) max_lifetime_ms: Option, pub(in crate::engine::wasm::host::process) idle_timeout_ms: Option, pub(in crate::engine::wasm::host::process) exit_retention_ms: Option, + /// Cleanup guard for any read-only file injections wired into the child's + /// sandbox. Stored on the entry so it lives as long as the persistent + /// process and cleans up when the entry is reaped (`reap_entry` consumes + /// the entry by value on every reap path, so the guard's drop fires then). + pub(in crate::engine::wasm::host::process) injection_guard: + Option, } /// Host-owned registry of a capsule's persistent processes. Cloned (`Arc`) @@ -221,6 +227,7 @@ impl PersistentProcessRegistry { spawn_ring_reader(&self.runtime, p.stderr, Arc::clone(&core), Stream::Err); let (exit_tx, exit_rx) = watch::channel::>(None); let monitor = spawn_monitor(&self.runtime, p.child, Arc::clone(&core), exit_tx); + let injection_guard = p.injection_guard; let mut id = mint_id(); let mut key = self.key_of(&id); @@ -253,6 +260,7 @@ impl PersistentProcessRegistry { core, exit_rx, monitor, + injection_guard, }, ); Ok(id) diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/registry_tests.rs b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/registry_tests.rs index 59765a3e1..9e4b372c1 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/registry_tests.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/registry_tests.rs @@ -67,6 +67,7 @@ fn params( max_lifetime_ms: None, idle_timeout_ms: None, exit_retention_ms: None, + injection_guard: None, } } @@ -295,6 +296,7 @@ async fn write_stdin_delivers_survives_reset_and_close_eofs() { max_lifetime_ms: None, idle_timeout_ms: None, exit_retention_ms: None, + injection_guard: None, }; let id = reg .spawn(spawn_params) diff --git a/crates/astrid-capsule/wit-staging/deps/astrid-process/process@1.0.0.wit b/crates/astrid-capsule/wit-staging/deps/astrid-process/process@1.0.0.wit index 34aaf4de3..495ef003d 100644 --- a/crates/astrid-capsule/wit-staging/deps/astrid-process/process@1.0.0.wit +++ b/crates/astrid-capsule/wit-staging/deps/astrid-process/process@1.0.0.wit @@ -157,6 +157,66 @@ interface host { max-open-files: option, } + /// Host-verified, read-only bytes exposed to a spawned child's OS sandbox. + /// The motivating consumer is un-overridable per-spawn governance (a + /// supervised agent reads a policy file a prompt-injected session cannot + /// rewrite), but the primitive is AGENT-NEUTRAL: the capsule hands over the + /// bytes plus how the child should find them (see `injection-placement`), and + /// the host owns placement, integrity, and exposure. `content` is OPAQUE to + /// the host — it never parses, validates, or filters the bytes. + /// + /// Write-protection invariant: the bytes the child reads MUST NOT be writable + /// by (a) the child or any subprocess it spawns, NOR (b) the spawning + /// principal's capsule `fs` surface (`fs_*` runs in capsule space, OUTSIDE the + /// child's sandbox; a `home://`-spanning `fs` capability could otherwise + /// rewrite a home-staged file between authoring and read). The host therefore + /// SNAPSHOTS `content` into a host-owned path outside every VFS mount, + /// BLAKE3-hashes the snapshot, VERIFIES the exposed bytes against the pin + /// (closing the copy->expose TOCTOU), records the hash in the spawn audit, and + /// exposes ONLY that host-owned snapshot — never a live bind of bytes the + /// capsule can still reach. + /// + /// No new capability: injection rides `host_process` and is permitted only + /// into the caller's OWN child. It is strictly a RESTRICTION surface — a + /// capsule that already dictates the child's `args` / `env` / `cwd` / `stdin` + /// gains nothing from also handing it an UNMODIFIABLE file. The host owns the + /// materialized path (`env-pointer`) or only remaps within the child's own + /// namespace (`fixed-path`), so it never writes to a caller-named host path. + record file-injection { + /// The bytes to expose. The capsule already holds them (it authored the + /// policy), so there is no host-side file read, no read gate, and no + /// home-staged intermediate file the `fs` surface could race. + content: list, + /// How the child is pointed at the bytes. + placement: injection-placement, + } + + /// How an injected file is exposed to the child. Both modes expose the SAME + /// verified bytes read-only; they differ only in how the agent finds them, + /// chosen to match the target agent's config mechanism. + variant injection-placement { + /// The host materializes the verified snapshot at a HOST-OWNED path + /// (outside every VFS mount), exposes it read-only (Linux `--ro-bind P P` + /// in the `bwrap` namespace; macOS Seatbelt `allow file-read*` plus a + /// trailing `deny file-write*` on that literal path), and sets the named + /// environment variable on the child to that path. The host owns the path, + /// so there is no caller-chosen target and no host write to a caller-named + /// path. Works on Linux AND macOS — the OS-agnostic mode. For agents whose + /// un-overridable config tier is reachable via an env-redirected file + /// (Claude `CLAUDE_CODE_MANAGED_SETTINGS_PATH`, Gemini + /// `GEMINI_CLI_SYSTEM_SETTINGS_PATH`). The string is the env-var name; the + /// host supplies its value. + env-pointer(string), + /// The host ro-binds the verified snapshot at this absolute in-sandbox + /// path (`--ro-bind ` in the `bwrap` namespace, which + /// creates the mount point, so `path` need not exist on the host). LINUX + /// ONLY: rejected on macOS with `invalid-input`, since Seatbelt has no + /// mount namespace and materializing at a caller-named host path would be + /// an arbitrary host write (escalation). For agents whose enforced tier is + /// a FIXED path with no env redirect (Codex `/etc/codex/requirements.toml`). + fixed-path(string), + } + /// Request to spawn a host process. record spawn-request { /// Command to execute. @@ -177,6 +237,13 @@ interface host { /// Per-child OS resource ceilings. Applies to EVERY tier. /// (NOT YET ENFORCED — see `resource-limits`.) limits: option, + /// Read-only files the host exposes inside the child's sandbox. Applies + /// to EVERY tier. Each entry hands the host verified, unmodifiable bytes + /// plus how the child should find them (see `file-injection` / + /// `injection-placement`); empty => no injection. The host never parses + /// the bytes; the BLAKE3 hash of each snapshot is recorded in the spawn + /// audit. + file-injections: list, // ---- the fields below are honored ONLY by `spawn-persistent` // and ignored by `spawn` / `spawn-background`. ---- diff --git a/crates/astrid-workspace/src/lib.rs b/crates/astrid-workspace/src/lib.rs index 84a70a937..6bba9513b 100644 --- a/crates/astrid-workspace/src/lib.rs +++ b/crates/astrid-workspace/src/lib.rs @@ -51,4 +51,6 @@ pub mod sandbox; #[allow(dead_code)] pub(crate) mod worktree; -pub use sandbox::{ProcessSandboxConfig, SandboxCommand, SandboxPolicy, SandboxPrefix}; +pub use sandbox::{ + ProcessSandboxConfig, RoInjection, SandboxCommand, SandboxPolicy, SandboxPrefix, +}; diff --git a/crates/astrid-workspace/src/sandbox/bwrap.rs b/crates/astrid-workspace/src/sandbox/bwrap.rs index 85866d0d2..39b4fc294 100644 --- a/crates/astrid-workspace/src/sandbox/bwrap.rs +++ b/crates/astrid-workspace/src/sandbox/bwrap.rs @@ -168,6 +168,19 @@ impl ProcessSandboxConfig { ]); } + // Read-only file injections: bind each host-owned verified snapshot at + // its in-sandbox target. Placed AFTER the writable --bind entries (so a + // later writable bind can't shadow the ro-bind) and BEFORE + // --unshare-all (so it sits within the namespace setup). The namespace + // creates the mount point, so `target` need not exist on the host. + for inj in &self.ro_injections { + args.extend([ + OsString::from("--ro-bind"), + inj.source.as_os_str().into(), + inj.target.as_os_str().into(), + ]); + } + // Drop all namespaces args.push(OsString::from("--unshare-all")); @@ -308,6 +321,55 @@ mod tests { ); } + #[test] + fn test_bwrap_prefix_ro_inject_position() { + // The injection --ro-bind must appear AFTER the writable --bind and + // BEFORE --unshare-all so a later bind cannot shadow it and it sits + // inside the namespace setup. + let config = ProcessSandboxConfig::new("/project").with_ro_inject("/host/snap", "/etc/x"); + let prefix = config.build_bwrap_prefix(); + + let args_str: Vec = prefix + .args + .iter() + .map(|a| a.to_string_lossy().to_string()) + .collect(); + + let writable_bind_pos = args_str + .iter() + .enumerate() + .filter(|(_, a)| *a == "--bind") + .find(|(i, _)| args_str.get(i + 1) == Some(&"/project".to_string())) + .map(|(i, _)| i) + .expect("should have writable --bind for /project"); + + let ro_inject_pos = args_str + .iter() + .enumerate() + .filter(|(_, a)| *a == "--ro-bind") + .find(|(i, _)| args_str.get(i + 1) == Some(&"/host/snap".to_string())) + .map(|(i, _)| i) + .expect("should have --ro-bind for the injection snapshot"); + + let unshare_pos = args_str + .iter() + .position(|a| a == "--unshare-all") + .expect("should have --unshare-all"); + + assert_eq!(args_str[ro_inject_pos + 1], "/host/snap", "ro-bind source"); + assert_eq!(args_str[ro_inject_pos + 2], "/etc/x", "ro-bind target"); + assert!( + ro_inject_pos > writable_bind_pos, + "injection --ro-bind (pos {ro_inject_pos}) must come after the \ + writable --bind (pos {writable_bind_pos})" + ); + assert!( + ro_inject_pos < unshare_pos, + "injection --ro-bind (pos {ro_inject_pos}) must come before \ + --unshare-all (pos {unshare_pos})" + ); + } + #[test] fn test_bwrap_prefix_extra_paths() { let config = ProcessSandboxConfig::new("/project") diff --git a/crates/astrid-workspace/src/sandbox/mod.rs b/crates/astrid-workspace/src/sandbox/mod.rs index c50fcd53a..6735206be 100644 --- a/crates/astrid-workspace/src/sandbox/mod.rs +++ b/crates/astrid-workspace/src/sandbox/mod.rs @@ -40,6 +40,23 @@ fn validate_sandbox_str<'a>(path: &'a Path, label: &str) -> io::Result<&'a str> Ok(s) } +/// A host-verified, read-only file the sandbox materializes inside a spawned +/// child. `source` is the host-owned path the verified snapshot already lives +/// at (the in-sandbox bytes are bound/copied FROM here); `target` is the +/// absolute path inside the child's sandbox at which it reads those bytes. +/// +/// The caller is responsible for ensuring `source` is a host-owned location +/// the child and the spawning principal's capsule fs surface cannot write — +/// the sandbox layer only wires the read-only exposure, it does not snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoInjection { + /// Host-owned path holding the verified snapshot bytes (the bind source on + /// Linux; the materialized literal on macOS — equal to `target` there). + pub source: PathBuf, + /// Absolute path inside the child's sandbox at which it reads the bytes. + pub target: PathBuf, +} + /// Wraps a standard OS command in a native kernel sandbox (bwrap or Seatbelt). /// /// Ensures that agent-executed native tools are restricted from accessing @@ -61,11 +78,38 @@ impl SandboxCommand { /// backslash, or null byte). #[allow(clippy::needless_pass_by_value)] // Moved on the unsupported-OS passthrough arm; borrowed on Linux/macOS. pub fn wrap(inner_cmd: Command, worktree_path: &Path) -> io::Result { + Self::wrap_with_injections(inner_cmd, worktree_path, &[]) + } + + /// Like [`wrap`](Self::wrap), but additionally exposes a set of + /// host-verified, READ-ONLY files at chosen paths inside the child's + /// sandbox. Each [`RoInjection`] binds the host-owned bytes at `source` so + /// the child reads them at `target` but neither it nor a subprocess it + /// spawns can modify them. An empty `injections` slice is byte-for-byte + /// identical to [`wrap`](Self::wrap). + /// + /// # Errors + /// + /// Returns an error if the worktree path or any injection `source` / + /// `target` is not absolute, not valid UTF-8, or contains characters unsafe + /// for SBPL interpolation (double-quote, backslash, or null byte); or, on a + /// platform without an OS-level sandbox, if `injections` is non-empty (the + /// read-only guarantee cannot be enforced without a sandbox — fail-secure). + #[allow(clippy::needless_pass_by_value)] // Moved on the unsupported-OS passthrough arm; borrowed on Linux/macOS. + pub fn wrap_with_injections( + inner_cmd: Command, + worktree_path: &Path, + injections: &[RoInjection], + ) -> io::Result { // Validate on all platforms for defense in depth and API consistency. // On macOS the validated string is needed for SBPL interpolation. // On Linux bwrap passes paths as argv entries (no injection risk), // but we still reject unsafe paths at the API boundary. let _ = validate_sandbox_str(worktree_path, "worktree path")?; + for inj in injections { + let _ = validate_sandbox_str(&inj.source, "injection source")?; + let _ = validate_sandbox_str(&inj.target, "injection target")?; + } #[cfg(target_os = "linux")] { @@ -77,7 +121,18 @@ impl SandboxCommand { .arg("--dev").arg("/dev") // Standard dev mounts .arg("--proc").arg("/proc") // Standard proc mounts .arg("--bind").arg(worktree_path).arg(worktree_path) // Write access to the worktree - .arg("--tmpfs").arg("/tmp") // Disposable tmpfs + .arg("--tmpfs").arg("/tmp"); // Disposable tmpfs + + // Read-only file injections: bind each host-owned verified snapshot + // at its in-sandbox target. Placed AFTER the writable worktree + // --bind so a later bind can't shadow it, and BEFORE --unshare-all + // so the ro-bind sits within the namespace setup. The namespace + // creates the mount point, so `target` need not exist on the host. + for inj in injections { + bwrap.arg("--ro-bind").arg(&inj.source).arg(&inj.target); + } + + bwrap .arg("--unshare-all") // Drop namespaces (network, pid, etc.) .arg("--share-net") // Re-enable network so npm/cargo can fetch .arg("--die-with-parent"); // Prevent orphan processes @@ -117,7 +172,15 @@ impl SandboxCommand { // macOS-15+ `sandbox-exec` incompatibility and papered over by // disabling the sandbox entirely. `sandbox-exec` is deprecated but // still enforces on current macOS. See #855. - let prefix = ProcessSandboxConfig::new(worktree_path).build_seatbelt_prefix()?; + // + // Seatbelt has no mount namespace, so the caller has already + // materialized the verified snapshot AT `target`; the profile + // grants read and a trailing deny-write on that literal path. + let mut config = ProcessSandboxConfig::new(worktree_path); + for inj in injections { + config = config.with_ro_inject(&inj.source, &inj.target); + } + let prefix = config.build_seatbelt_prefix()?; let mut sb_cmd = Command::new(&prefix.program); sb_cmd.args(&prefix.args); @@ -145,6 +208,14 @@ impl SandboxCommand { #[cfg(not(any(target_os = "linux", target_os = "macos")))] { + // Without an OS-level sandbox there is no mechanism to enforce the + // read-only guarantee; refuse rather than expose writable bytes. + if !injections.is_empty() { + return Err(io::Error::other( + "read-only file injection requires an OS sandbox \ + (bwrap/Seatbelt); unavailable on this platform", + )); + } tracing::warn!( "Host-level sandboxing is not supported on this OS. Processes will run unsandboxed." ); @@ -301,6 +372,11 @@ pub struct ProcessSandboxConfig { allow_network: bool, /// Paths to overlay with empty tmpfs (Linux) or exclude (macOS), blocking access. hidden_paths: Vec, + /// Read-only file injections: host-verified bytes exposed at an in-sandbox + /// `target`. On macOS the snapshot is materialized at `target` (so + /// `source == target`) and the profile gets a read-allow plus a trailing + /// write-deny on that literal path. + ro_injections: Vec, /// What to do when OS-level sandboxing is unavailable (see [`SandboxPolicy`]). policy: SandboxPolicy, } @@ -321,6 +397,7 @@ impl ProcessSandboxConfig { extra_write_paths: Vec::new(), allow_network: true, hidden_paths: Vec::new(), + ro_injections: Vec::new(), policy: SandboxPolicy::from_env(), } } @@ -364,6 +441,27 @@ impl ProcessSandboxConfig { self } + /// Add a read-only file injection: expose the host-verified bytes at + /// `source` so the sandboxed process reads them at `target`, with no way + /// for the child (or any subprocess) to write them. + /// + /// On macOS the snapshot must already be materialized at `target`, so + /// `source` and `target` are typically equal there; the generated Seatbelt + /// profile grants `file-read*` on `target` and appends a trailing + /// `file-write*` deny on it. + #[must_use] + pub fn with_ro_inject( + mut self, + source: impl Into, + target: impl Into, + ) -> Self { + self.ro_injections.push(RoInjection { + source: source.into(), + target: target.into(), + }); + self + } + /// Build the sandbox wrapper prefix for this configuration. /// /// Behaviour depends on the active [`SandboxPolicy`]: @@ -459,6 +557,10 @@ impl ProcessSandboxConfig { for p in &self.hidden_paths { validate_sandbox_str(p, "hidden path")?; } + for inj in &self.ro_injections { + validate_sandbox_str(&inj.source, "injection source")?; + validate_sandbox_str(&inj.target, "injection target")?; + } Ok(()) } } @@ -652,6 +754,57 @@ mod tests { ); } + // --- wrap_with_injections() tests --- + + #[test] + fn wrap_with_injections_rejects_unsafe_paths() { + // Relative target rejected as non-absolute; a double-quote in either + // path rejected as a forbidden char (mirrors validate_sandbox_str). + for (source, target, needle) in [ + ("/host/snap", "relative/target", "absolute path"), + ("/host/evil\"snap", "/etc/x", "forbidden characters"), + ("/host/snap", "/etc/ev\"il", "forbidden characters"), + ] { + let inj = [RoInjection { + source: PathBuf::from(source), + target: PathBuf::from(target), + }]; + let result = SandboxCommand::wrap_with_injections( + Command::new("echo"), + Path::new("/tmp/ws"), + &inj, + ); + let msg = result + .expect_err("unsafe injection path must be rejected") + .to_string(); + assert!(msg.contains(needle), "expected {needle:?} in: {msg}"); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn wrap_with_injections_emits_ro_bind_pair() { + let cmd = Command::new("echo"); + let inj = [RoInjection { + source: PathBuf::from("/host/snap"), + target: PathBuf::from("/etc/x"), + }]; + let wrapped = + SandboxCommand::wrap_with_injections(cmd, Path::new("/tmp/ws"), &inj).unwrap(); + let args: Vec = wrapped + .get_args() + .map(|a| a.to_string_lossy().to_string()) + .collect(); + let pos = args + .iter() + .enumerate() + .filter(|(_, a)| *a == "--ro-bind") + .find(|(i, _)| args.get(i + 1) == Some(&"/host/snap".to_string())) + .map(|(i, _)| i) + .expect("wrapped command must carry the injection --ro-bind"); + assert_eq!(args[pos + 2], "/etc/x", "ro-bind target"); + } + // --- ProcessSandboxConfig builder tests --- #[test] diff --git a/crates/astrid-workspace/src/sandbox/seatbelt.rs b/crates/astrid-workspace/src/sandbox/seatbelt.rs index 6e546cc4b..68ca65ad3 100644 --- a/crates/astrid-workspace/src/sandbox/seatbelt.rs +++ b/crates/astrid-workspace/src/sandbox/seatbelt.rs @@ -35,6 +35,33 @@ impl ProcessSandboxConfig { .collect::>>()? .join("\n"); + // Read-only file injections: allow reading the materialized literal + // (it lives AT `target` on macOS, there being no mount namespace), and + // append a trailing write-deny below so the child cannot modify it. + let inject_read_rules: String = self + .ro_injections + .iter() + .map(|inj| { + validate_sandbox_str(&inj.target, "injection target") + .map(|s| format!(" (literal \"{s}\")")) + }) + .collect::>>()? + .join("\n"); + + // Trailing write-deny on each injection target. Emitted AFTER + // `hidden_deny_rules` so it is the last match — in SBPL the last + // matching rule wins, so this denies the write even if an + // allow-write subpath above (e.g. the writable root) covers `target`. + let inject_deny_rules: String = self + .ro_injections + .iter() + .map(|inj| { + validate_sandbox_str(&inj.target, "injection target") + .map(|s| format!("(deny file-write* (literal \"{s}\"))")) + }) + .collect::>>()? + .join("\n"); + // Build deny rules for hidden paths (e.g. ~/.astrid/). // Skip any hidden path that is an ancestor of or equal to the // writable_root — the capsule must be able to access its own @@ -77,6 +104,7 @@ impl ProcessSandboxConfig { (subpath "/var/folders") (literal "/") {extra_read_rules} +{inject_read_rules} ) (allow file-write* (subpath "{writable_root_str}") @@ -85,7 +113,8 @@ impl ProcessSandboxConfig { (literal "/dev/null") {extra_write_rules} ) -{hidden_deny_rules}"# +{hidden_deny_rules} +{inject_deny_rules}"# ); // Pass profile inline via -p to avoid temp file leak. @@ -155,6 +184,40 @@ mod tests { ); } + #[test] + fn test_seatbelt_prefix_ro_inject() { + // The injection target must be read-allowed and write-denied, and the + // trailing write-deny must appear AFTER the allow-write block so the + // last-match-wins SBPL semantics keep the file unmodifiable even + // though the writable root's allow-write covers it. + let config = ProcessSandboxConfig::new("/project") + .with_ro_inject("/snap/policy.json", "/etc/agent/policy.json"); + let prefix = config.build_seatbelt_prefix().unwrap(); + let profile = prefix.args[1].to_string_lossy().to_string(); + + assert!( + profile.contains(r#"(literal "/etc/agent/policy.json")"#), + "profile must read-allow the injection target literal" + ); + let deny = r#"(deny file-write* (literal "/etc/agent/policy.json"))"#; + assert!( + profile.contains(deny), + "profile must write-deny the injection target literal" + ); + + let allow_write_pos = profile + .find("(allow file-write*") + .expect("profile should have an allow file-write* block"); + let deny_pos = profile + .find(deny) + .expect("profile should have the injection write-deny"); + assert!( + deny_pos > allow_write_pos, + "the injection write-deny (offset {deny_pos}) must appear after \ + the allow-write block (offset {allow_write_pos}) so last-match-wins" + ); + } + /// Regression test for the macOS side of #648: when the writable root is /// inside a hidden path, the deny rule for that path must be skipped so /// the capsule directory remains accessible. diff --git a/wit b/wit index 83ebc6c87..b8fdb6f47 160000 --- a/wit +++ b/wit @@ -1 +1 @@ -Subproject commit 83ebc6c87071bd112d8fb4b96edb958d5a97f4a3 +Subproject commit b8fdb6f479f189565cef0d88e1efb8d867bab73d