diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d40da6..db2bf60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ## [Unreleased] +### Added + +- **Read-only file injection on the spawn builder.** `Command::inject_env_file(env_var, content)` + (OS-agnostic: host materializes the bytes at a host-owned path and points the named env var at + it), `Command::inject_file_at(path, content)` (Linux-only ro-bind at an absolute in-sandbox path; + rejected on macOS), and the lower-level `Command::inject_file(content, placement)` with the public + `InjectionPlacement { EnvPointer, FixedPath }`. The injected bytes are exposed read-only and are + unmodifiable by the child, its subprocesses, or the spawning principal's `fs` surface. Honored by + all spawn modes. (`unicity-astrid/astrid#881`/`#890`) + +### Changed + +- **Bumped the `contracts` submodule to `0a50cfc`** (canonical wit `main` with #14 un-stubbed + persistent stdin and #15 process file-injection). `build.rs` re-stages `wit-staging` from + `contracts/host`, so `astrid:process@1.0.0` now carries `spawn-request.file-injections`. This + restores WIT/linker lockstep with core: a process-tier capsule built against the SDK previously + got an older `process@1.0.0` than the host exposes. The `astrid-contracts.wit` events bundle is + unchanged (file-injection is a `host/` contract, not an `interfaces/` one). + ## [0.7.1] - 2026-06-10 This release restores capsule tool discovery and ships the first two post-0.7 additions to the diff --git a/astrid-sdk/src/process.rs b/astrid-sdk/src/process.rs index c826f54..61e24c9 100644 --- a/astrid-sdk/src/process.rs +++ b/astrid-sdk/src/process.rs @@ -109,6 +109,31 @@ impl Signal { } } +/// How an injected read-only file is exposed to the spawned child. +/// +/// Both modes expose the SAME bytes read-only — the child (and any +/// subprocess it spawns) cannot modify them, and neither can the spawning +/// principal's `fs` surface. They differ only in how the child finds the +/// file, chosen to match the target program's config mechanism. See +/// [`Command::inject_env_file`] / [`Command::inject_file_at`]. +#[derive(Debug, Clone)] +pub enum InjectionPlacement { + /// The host materializes the bytes at a host-owned path (outside every + /// VFS mount) and sets the named environment variable on the child to + /// that path. The host owns the path — there is no caller-chosen target. + /// Works on **Linux and macOS** (the OS-agnostic mode); use it for + /// programs whose enforced config tier is reachable via an env-redirected + /// file. The `String` is the env-var name. + EnvPointer(String), + /// The host ro-binds the bytes at this absolute in-sandbox path (the + /// mount point is created, so `path` need not pre-exist). **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. Use it for programs whose enforced tier is a + /// fixed path with no env redirect. + FixedPath(String), +} + /// Builder for the [`spawn`] / [`spawn_background`] request body. The /// pre-migration helper took just `(cmd, args)`; this builder allows /// the new contract's `env`, `cwd`, and `stdin` fields without @@ -120,6 +145,8 @@ pub struct Command { stdin: Option>, env: Vec<(String, String)>, cwd: Option, + /// Read-only files injected into the child, honored by all spawn modes. + file_injections: Vec<(Vec, InjectionPlacement)>, // Persistent-tier knobs — honored ONLY by [`spawn_persistent`], ignored // by [`spawn`] / [`spawn_background`] (per the WIT contract). label: Option, @@ -180,6 +207,49 @@ impl Command { self } + /// Inject `content` into the child as a read-only file the child cannot + /// modify, exposed via the named environment variable pointing at a + /// host-owned path ([`InjectionPlacement::EnvPointer`]). OS-agnostic + /// (Linux and macOS). The host owns the bytes' integrity and exposure; + /// `content` is opaque to it. Honored by all spawn modes. + #[must_use] + pub fn inject_env_file( + mut self, + env_var: impl Into, + content: impl Into>, + ) -> Self { + self.file_injections.push(( + content.into(), + InjectionPlacement::EnvPointer(env_var.into()), + )); + self + } + + /// Inject `content` into the child as a read-only file ro-bound at the + /// absolute in-sandbox `path` ([`InjectionPlacement::FixedPath`]). + /// **Linux only** — rejected on macOS with `invalid-input`. Honored by + /// all spawn modes. + #[must_use] + pub fn inject_file_at(mut self, path: impl Into, content: impl Into>) -> Self { + self.file_injections + .push((content.into(), InjectionPlacement::FixedPath(path.into()))); + self + } + + /// Inject `content` with an explicit [`InjectionPlacement`]. Lower-level + /// form of [`inject_env_file`](Self::inject_env_file) / + /// [`inject_file_at`](Self::inject_file_at) for callers that compute the + /// placement dynamically. + #[must_use] + pub fn inject_file( + mut self, + content: impl Into>, + placement: InjectionPlacement, + ) -> Self { + self.file_injections.push((content.into(), placement)); + self + } + /// Operator-readable label surfaced in [`list`] / [`PersistentProcess::status`]. /// Persistent-tier only. `None` (default) derives a label from `cmd`. #[must_use] @@ -258,6 +328,21 @@ impl Command { .map(|(key, value)| wit_process::EnvVar { key, value }) .collect(), cwd: self.cwd, + file_injections: self + .file_injections + .into_iter() + .map(|(content, placement)| wit_process::FileInjection { + content, + placement: match placement { + InjectionPlacement::EnvPointer(v) => { + wit_process::InjectionPlacement::EnvPointer(v) + } + InjectionPlacement::FixedPath(p) => { + wit_process::InjectionPlacement::FixedPath(p) + } + }, + }) + .collect(), limits: self.limits.map(ResourceLimits::into_wit), label: self.label, keep_stdin_open: self.keep_stdin_open.then_some(true), @@ -781,3 +866,39 @@ pub fn status_many(ids: &[ProcessId]) -> Result, SysError> { .map(|v| v.into_iter().map(ProcessInfo::from_wit).collect()) .map_err(host_err) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inject_builders_accumulate_in_order() { + let cmd = Command::new("agent") + .inject_env_file("CLAUDE_CODE_MANAGED_SETTINGS_PATH", b"{}".to_vec()) + .inject_file_at("/etc/codex/requirements.toml", b"policy".to_vec()) + .inject_file( + b"x".to_vec(), + InjectionPlacement::EnvPointer("GEMINI".into()), + ); + + assert_eq!(cmd.file_injections.len(), 3); + assert!(matches!( + cmd.file_injections[0].1, + InjectionPlacement::EnvPointer(ref v) if v == "CLAUDE_CODE_MANAGED_SETTINGS_PATH" + )); + assert_eq!(cmd.file_injections[0].0, b"{}"); + assert!(matches!( + cmd.file_injections[1].1, + InjectionPlacement::FixedPath(ref p) if p == "/etc/codex/requirements.toml" + )); + assert!(matches!( + cmd.file_injections[2].1, + InjectionPlacement::EnvPointer(ref v) if v == "GEMINI" + )); + } + + #[test] + fn no_injection_by_default() { + assert!(Command::new("agent").file_injections.is_empty()); + } +} diff --git a/astrid-sys/wit-staging/deps/astrid-process/process@1.0.0.wit b/astrid-sys/wit-staging/deps/astrid-process/process@1.0.0.wit index cbc27ee..495ef00 100644 --- a/astrid-sys/wit-staging/deps/astrid-process/process@1.0.0.wit +++ b/astrid-sys/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`. ---- @@ -257,8 +324,8 @@ interface host { } // ================================================================ - // PERSISTENT TIER value types. (NOT YET IMPLEMENTED — the host stubs - // the persistent functions below; the SHAPES are fixed now.) + // PERSISTENT TIER value types. The persistent registry has landed; these + // shapes are in use. The SHAPES are frozen. // ================================================================ /// Opaque, unforgeable, principal-scoped identity for a PERSISTENT @@ -449,9 +516,11 @@ interface host { spawn-background: func(request: spawn-request) -> result; // ================================================================ - // PERSISTENT TIER functions. (NOT YET IMPLEMENTED — the host stubs - // these, returning `persist-unsupported` / `no-such-process` / `unknown` - // until the registry lands. The SHAPES are fixed now.) + // PERSISTENT TIER functions. The host registry has LANDED — these are + // implemented and audited. Still deferred (the host returns a stub + // `unknown`): `attach` (resource-handle materialisation — the id-keyed ops + // below ARE the documented `attach(id)?.method()` equivalent) and `watch` / + // `unwatch` (the lifecycle-event channel, RFC-open). The SHAPES are frozen. // // Every id-keyed function re-resolves the live calling principal + // capsule and checks them against the recorded creator BEFORE touching @@ -504,10 +573,18 @@ interface host { read-since: func(id: process-id, which-stream: log-stream, cursor: log-cursor, max-bytes: u32) -> result; /// `attach(id)?.write-stdin(data)`. Requires `keep-stdin-open = true`. - /// (NOT YET IMPLEMENTED — stdin pipe capture pending.) + /// Caps at 1 MiB/call (`too-large` above that). The retained pipe lives in + /// the host registry, so a later instance that re-attached keeps writing to + /// the same child across pooled-instance resets. A non-reading child + /// backpressures the call until the OS pipe buffer drains. A successful + /// return wrote every byte of `data`, in order, and yields `len(data)`. An + /// error is NOT all-or-nothing: a prefix may already have reached the child, + /// and the stream is then marked closed (`closed` thereafter) — a caller + /// that needs intact framing must treat a failed write as a torn frame, not + /// a no-op. write-stdin: func(id: process-id, data: list) -> result; - /// `attach(id)?.close-stdin()`. (NOT YET IMPLEMENTED.) + /// `attach(id)?.close-stdin()`. Child observes EOF; idempotent. close-stdin: func(id: process-id) -> result<_, error-code>; /// `attach(id)?.signal(sig)`. Fire-and-forget signal delivery. diff --git a/contracts b/contracts index 9742f80..0a50cfc 160000 --- a/contracts +++ b/contracts @@ -1 +1 @@ -Subproject commit 9742f80e83abb92e9ae954ef82664bdd2fcb89cd +Subproject commit 0a50cfc771b32d78a8bc0780917796c3e2a9e699