Skip to content

feat(process): read-only file-injection in spawn-request#15

Merged
joshuajbouw merged 1 commit into
mainfrom
feat/process-file-injection
Jun 16, 2026
Merged

feat(process): read-only file-injection in spawn-request#15
joshuajbouw merged 1 commit into
mainfrom
feat/process-file-injection

Conversation

@joshuajbouw

@joshuajbouw joshuajbouw commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the file-injection record + injection-placement variant and a file-injections field (honored by every tier) to spawn-request in astrid:[email protected]. The host exposes host-verified, read-only bytes to a spawned child's existing OS sandbox. content is 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: Claude CLAUDE_CODE_MANAGED_SETTINGS_PATH, Gemini GEMINI_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 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.

Guarantees

  • Write-protection invariant: the bytes are unwritable by the child AND by the spawning principal's capsule fs_* surface.
  • Integrity contract: snapshot → host-owned path outside every VFS mount, BLAKE3 hash pinned + verified before exposure, hash recorded in the spawn audit, never a live bind of the source bytes.
  • No new capability / no escalation: rides 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

  • Specified in the Host ABI RFC: RFC: Host ABI rfcs#22 ("Read-only file injection").
  • scripts/validate-wit.sh passes.
  • Consumed by the core implementation (feat(capsule): read-only file-injection for sandboxed spawns astrid#890); core pins this commit as its wit/ submodule.
  • Supersedes the earlier {source, target, read-only} shape (force-pushed): content bytes remove the host-side read / read-gate and the home-staged file, and the two-mode placement makes the capsule OS-agnostic in env-pointer mode while removing a macOS arbitrary-write escalation present in the prior shape.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread host/[email protected] Outdated
Comment on lines +203 to +205
/// Absolute path at which the child reads the injected bytes. See the
/// per-OS mechanism above for the platform constraints on `target`.
target: string,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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,

Comment thread host/[email protected] Outdated
Comment on lines +233 to +238
/// 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>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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).
@joshuajbouw joshuajbouw force-pushed the feat/process-file-injection branch from f55da50 to b8fdb6f Compare June 10, 2026 19:17
joshuajbouw added a commit to astrid-runtime/astrid that referenced this pull request Jun 16, 2026
## 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]`
@joshuajbouw joshuajbouw merged commit 0a50cfc into main Jun 16, 2026
1 check passed
@joshuajbouw joshuajbouw deleted the feat/process-file-injection branch June 16, 2026 01:01
joshuajbouw added a commit that referenced this pull request Jul 2, 2026
[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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant