feat(process): read-only file-injection in spawn-request#15
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new file-injection record and a file-injections field to the spawn-request record in the host/[email protected] interface, allowing read-only files to be materialized inside a spawned child's OS sandbox. The review comments suggest clarifying the behavior when the parent directory of a target path does not exist, and specifying how duplicate target paths in the file-injections list are handled.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| /// Absolute path at which the child reads the injected bytes. See the | ||
| /// per-OS mechanism above for the platform constraints on `target`. | ||
| target: string, |
There was a problem hiding this comment.
The behavior when the parent directory of the target path does not exist should be explicitly defined, especially given the platform-specific differences (Linux bwrap vs. macOS Seatbelt). Clarifying whether the host implicitly creates missing parent directories or if the spawn fails with invalid-input ensures consistent host implementations.
/// Absolute path at which the child reads the injected bytes. See the
/// per-OS mechanism above for the platform constraints on `target`.
/// Non-absolute paths or paths where the parent directory cannot be
/// created/resolved fail the spawn with `invalid-input`.
target: string,
| /// Read-only files the host materializes inside the child's sandbox. | ||
| /// Applies to EVERY tier. Each entry places host-verified, unmodifiable | ||
| /// bytes at an absolute path (see `file-injection`); empty => no | ||
| /// injection. The host never parses the bytes; the BLAKE3 hash of each | ||
| /// snapshot is recorded in the spawn audit. | ||
| file-injections: list<file-injection>, |
There was a problem hiding this comment.
Specifying how duplicate target paths in the file-injections list are handled is important to prevent conflicting mounts or undefined behavior. If multiple injections specify the same target, the spawn should be explicitly rejected with invalid-input.
/// Read-only files the host materializes inside the child's sandbox.
/// Applies to EVERY tier. Each entry places host-verified, unmodifiable
/// bytes at an absolute path (see `file-injection`); empty => no
/// injection. Duplicate `target` paths are rejected with `invalid-input`.
/// The host never parses the bytes; the BLAKE3 hash of each
/// snapshot is recorded in the spawn audit.
file-injections: list<file-injection>,
Adds the file-injection record + injection-placement variant and a file-injections field (every tier) to spawn-request: the host exposes host-verified, read-only bytes to a spawned child's existing OS sandbox. Agent-neutral — content bytes are opaque to the host. Two placement modes, both exposing the same verified bytes read-only: - env-pointer(env-var): host materializes the snapshot at a host-owned path, exposes it read-only, and sets the named env var to it. OS-agnostic (Linux + macOS). For env-redirectable tiers (Claude CLAUDE_CODE_MANAGED_SETTINGS_PATH, Gemini GEMINI_CLI_SYSTEM_SETTINGS_PATH). - fixed-path(path): host --ro-binds the snapshot at an absolute in-sandbox path. Linux-only (bwrap remap); rejected on macOS with invalid-input. For fixed enforced paths with no env redirect (Codex /etc/codex/requirements.toml). Write-protection invariant: the bytes are unwritable by the child AND the principal's capsule fs surface. Integrity: snapshot to a host-owned path outside every VFS mount, BLAKE3 hash pinned + verified before exposure, hash in the spawn audit, never a live bind. The host owns placement in both modes, so it never writes to a caller-named host path. Tracks the Host ABI RFC (astrid-runtime/rfcs#22).
f55da50 to
b8fdb6f
Compare
## Linked Issue Closes #881 ## Summary Implements the generic, agent-neutral host primitive from #881: at process spawn, the host exposes host-verified, **read-only** bytes to a spawned child's existing OS sandbox (`bwrap` on Linux, Seatbelt on macOS). The motivating consumer is un-overridable per-spawn agent governance (a supervised agent reads a policy file a prompt-injected session cannot rewrite), but the host is **content-opaque** — it never parses the bytes, and it owns placement, so every agent-specific detail stays in the adapter and the kernel stays agent-neutral. The capsule hands the host `content` bytes plus a **placement** describing how the child should find them. Two modes, both exposing the same verified bytes read-only: - **`env-pointer(env-var)`** — the host materializes the snapshot at a host-owned path, exposes it read-only (Linux `--ro-bind P P`; macOS Seatbelt `file-read*` allow + a trailing `file-write*` deny on the literal), and sets the named env var on the child to that path. OS-agnostic (Linux + macOS); the host owns the path, so there's no caller-chosen target and no host write to a caller-named path. 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)`** — the host `--ro-bind`s the snapshot at an absolute in-sandbox path. Linux-only (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`). ## Changes - **`astrid-workspace` sandbox**: `RoInjection` + `ProcessSandboxConfig::with_ro_inject`; Linux `--ro-bind` (after the writable bind, before `--unshare-all`); macOS Seatbelt `(allow file-read* (literal target))` + trailing `(deny file-write* (literal target))`. `SandboxCommand::wrap_with_injections` (no-injection path byte-identical to `wrap`); non-empty injections on an unsandboxed platform fail secure. - **`astrid-capsule` `host/process/inject.rs`**: BLAKE3-hash `content`, snapshot to a private `TempDir` (host-owned, outside every VFS mount), **re-read and verify** against the pin (closes the copy→expose TOCTOU); per placement, emit the sandbox bind spec and (for `env-pointer`) the host-set env var. RAII guard (the `TempDir`) cleans up on the child's lifetime owner: a local for `spawn`, the `ManagedProcess` for `spawn-background`, the registry entry (reaped by value) for `spawn-persistent`. The spawn audit records `env:<var>=<hash>` / `path:<p>=<hash>`. The host sets the env-pointer var authoritatively on the child (not via the inert guest `spawn-request.env`). - **WIT**: the `wit/` submodule is bumped to the file-injection commit and `wit-staging` is regenerated, so `bindgen!` produces `SpawnRequest.file_injections`, `FileInjection { content, placement }`, and `InjectionPlacement`. - **Validation**: `env-pointer` var name non-empty / no `=` / no NUL; `fixed-path` absolute / no forbidden chars / no `..`; per-injection content cap (1 MiB → `too-large`); empty `file-injections` is a zero-overhead no-op. ## Test Plan ### Automated - [x] `cargo test --workspace` passes - [x] No new clippy warnings (`cargo clippy --workspace --all-features -- -D warnings`) - New unit tests: bwrap ro-bind positioning; macOS Seatbelt read-allow + trailing write-deny ordering; `wrap_with_injections` unsafe-path rejection; env-var-name and fixed-path (incl. `..`) validation; write+verify roundtrip + integrity-mismatch detection; 0600 snapshot mode; empty-injections no-op. ### Manual (optional) Not applicable — no runtime behaviour change when `file-injections` is empty (every existing spawn); the injection path is exercised by the unit tests above. ## Dependencies / merge order Third leg of a contract-first change — merge in order: 1. astrid-runtime/rfcs#22 (Host ABI RFC — "Read-only file injection") 2. astrid-runtime/wit#15 (canonical WIT) 3. **Re-pin** this PR's `wit/` submodule from the pre-merge branch commit `b8fdb6f` to the `wit@main` merge commit, then merge this. The CI submodule ↔ `wit-staging` dirty-check enforces the pin stays consistent. ## Checklist - [x] Linked to an issue (#881) - [x] CHANGELOG.md updated under `[Unreleased]`
…[email protected] (#20) Closes #19 #15 mutated the frozen `astrid:[email protected]` in place (`spawn-request` gained `file-injections`). The component-model linker matches package versions structurally, so every capsule built against the published contract fails to instantiate on a host serving the mutated shape — confirmed live on astrid 0.9.0: the sage supervisor fails `astrid init`, and capsule-shell v0.2.0 fails to load, both with `component imports instance astrid:process/[email protected], but a matching implementation was not found in the linker`. `astrid-sys 0.7.1` (crates.io — what every shipped capsule embeds) carries the pre-#15 shape, verified by extracting the artifact. - `host/[email protected]`: restored **byte-identical** to 83ebc6c (the published shape; verified by diff in CI-adjacent check). - `host/[email protected]`: the injection extension as an additive successor — same dual-version pattern as `[email protected]`/`1.1.0`. Header documents the provenance. - README: package-table row for the new version. - `scripts/validate-wit.sh` passes for both (it already stages same-package multi-version since the http split). Core-side dual-version serving is implemented and green (570 tests) on `unicity-astrid/astrid` branch `fix/process-dual-version-host`; its submodule pin bumps to this once merged. Tracked: astrid-runtime/astrid#1107. https://claude.ai/code/session_01NvX2tE7tgXuCRevqqiXTGU
Summary
Adds the
file-injectionrecord +injection-placementvariant and afile-injectionsfield (honored by every tier) tospawn-requestinastrid:[email protected]. The host exposes host-verified, read-only bytes to a spawned child's existing OS sandbox.contentis opaque to the host; the capsule supplies the bytes plus how the child should find them, and the host owns placement, integrity, and exposure.Two placement modes (same verified bytes, read-only)
env-pointer(env-var)— the host materializes the snapshot at a host-owned path, exposes it read-only, and sets the named env var to it. OS-agnostic (Linux + macOS); the host owns the path, so there's no caller-chosen target and no host write to a caller-named path. For agents whose un-overridable tier is reachable via an env-redirected file: ClaudeCLAUDE_CODE_MANAGED_SETTINGS_PATH, GeminiGEMINI_CLI_SYSTEM_SETTINGS_PATH.fixed-path(path)— the host--ro-binds the snapshot at an absolute in-sandbox path. Linux-only (bwrap remap); rejected on macOS withinvalid-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.Guarantees
fs_*surface.host_process, into the caller's own child only; the host owns placement in both modes, so it never writes to a caller-named host path.Notes
scripts/validate-wit.shpasses.wit/submodule.{source, target, read-only}shape (force-pushed):contentbytes remove the host-side read / read-gate and the home-staged file, and the two-mode placement makes the capsule OS-agnostic inenv-pointermode while removing a macOS arbitrary-write escalation present in the prior shape.