Skip to content

feat(capsule): read-only file-injection for sandboxed spawns#890

Merged
joshuajbouw merged 2 commits into
mainfrom
feat/spawn-ro-file-injection
Jun 16, 2026
Merged

feat(capsule): read-only file-injection for sandboxed spawns#890
joshuajbouw merged 2 commits into
mainfrom
feat/spawn-ro-file-injection

Conversation

@joshuajbouw

@joshuajbouw joshuajbouw commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

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-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).

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

  • cargo test --workspace passes
  • 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. RFC: Host ABI rfcs#22 (Host ABI RFC — "Read-only file injection")
  2. feat(process): read-only file-injection in spawn-request 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

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a secure, agent-neutral mechanism for injecting read-only files into sandboxed child processes. By snapshotting and verifying source files before exposing them within the sandbox, the host ensures that injected data remains immutable and protected from tampering by both the child process and the spawning principal's file system surface.

Highlights

  • Read-only File Injection: Added support for host-verified, read-only file injection into sandboxed child processes, allowing for unmodifiable configuration or policy files.
  • Security & Integrity: Implemented snapshotting, BLAKE3 hashing, and verification to close TOCTOU vulnerabilities, ensuring the injected bytes remain immutable.
  • Platform Integration: Integrated with Linux bwrap via --ro-bind and macOS Seatbelt via read-allow and trailing write-deny rules.
  • API Updates: Updated WIT definitions to include file-injections in the spawn-request, supporting all three process spawn tiers.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.


A file is locked within the cage, A hash is checked upon the page. No write can touch the bytes inside, Where secrets and the policies hide.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

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

Copy link
Copy Markdown
Contributor

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 host-verified, read-only file injection for sandboxed child processes across Linux and macOS, updating the WIT definitions, sandbox configurations, and process spawning logic to support this feature. The review feedback highlights critical security improvements in the injection module, specifically addressing a potential symlink bypass on macOS due to uncanonicalized target paths, a symlink overwrite vulnerability when writing snapshots, and a potential Denial of Service (OOM) risk from reading unrestricted file sizes into memory.

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 on lines +283 to +300
#[cfg(target_os = "macos")]
fn reject_target_within_vfs(state: &HostState, target: &str) -> Result<(), ErrorCode> {
let target_path = PathBuf::from(target);
let mut roots: Vec<PathBuf> = vec![state.workspace_root.clone()];
if let Some(home) = state.effective_home() {
roots.push(home.root.clone());
}
if let Some(tmp) = state.effective_tmp() {
roots.push(tmp.root.clone());
}
for root in &roots {
let canon = root.canonicalize().unwrap_or_else(|_| root.clone());
if target_path.starts_with(&canon) || target_path.starts_with(root) {
return Err(ErrorCode::BoundaryEscape);
}
}
Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-high high

The reject_target_within_vfs function checks if the target path starts with any of the VFS roots to prevent the capsule from modifying the injected file. However, it does not canonicalize the target path itself. If the target path contains symlinks that resolve to a location inside the VFS root, the prefix check can be bypassed, allowing the capsule to overwrite the injected file. Canonicalize the target path best-effort to resolve symlinks before performing the prefix check. Additionally, to prevent path traversal vulnerabilities, explicitly check for and reject paths containing parent directory components (..) before using Path::starts_with for validation, as starts_with does not normalize paths.

#[cfg(target_os = "macos")]
fn reject_target_within_vfs(state: &HostState, target: &str) -> Result<(), ErrorCode> {
    let target_path = std::path::Path::new(target);
    if target_path.components().any(|c| c == std::path::Component::ParentDir) {
        return Err(ErrorCode::BoundaryEscape);
    }
    let mut current = target_path.to_path_buf();
    let mut components = Vec::new();
    while !current.exists() {
        if let Some(parent) = current.parent() {
            if let Some(name) = current.file_name() {
                components.push(name.to_owned());
            }
            current = parent.to_path_buf();
        } else {
            break;
        }
    }
    let target_canon = if let Ok(canon) = current.canonicalize() {
        let mut r = canon;
        for c in components.into_iter().rev() {
            r.push(c);
        }
        r
    } else {
        target_path.to_path_buf()
    };

    let mut roots: Vec<std::path::PathBuf> = vec![state.workspace_root.clone()];
    if let Some(home) = state.effective_home() {
        roots.push(home.root.clone());
    }
    if let Some(tmp) = state.effective_tmp() {
        roots.push(tmp.root.clone());
    }
    for root in &roots {
        let canon = root.canonicalize().unwrap_or_else(|_| root.clone());
        if target_canon.starts_with(&canon) || target_canon.starts_with(root) {
            return Err(ErrorCode::BoundaryEscape);
        }
    }
    Ok(())
}
References
  1. To prevent path traversal vulnerabilities, explicitly check for and reject paths containing parent directory components (..) before using Path::starts_with for validation, as starts_with does not normalize paths.

Comment on lines +143 to +158
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(true).truncate(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(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-high high

When writing the injected file, write_private uses create(true).truncate(true). If the target path already exists as a symlink pointing to a critical system file, the host will follow the symlink and overwrite the target file. To prevent this, remove any existing file or symlink at the target path first, and use create_new(true) (which uses O_EXCL) to guarantee that a new file is created without following symlinks. Additionally, handle the result of the file system operations (such as removing the file) to prevent silent failures.

fn write_private(dest: &std::path::Path, bytes: &[u8]) -> Result<(), ErrorCode> {
    use std::io::Write as _;
    if let Err(e) = std::fs::remove_file(dest) {
        if e.kind() != std::io::ErrorKind::NotFound {
            return Err(ErrorCode::Unknown(format!("failed to remove existing file: {e}")));
        }
    }
    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(())
}
References
  1. Handle Result from file system operations to prevent silent failures or panics, especially in security-sensitive contexts such as creating sandbox profiles.

Comment on lines +181 to +184
// Resolve + read-gate the source exactly as a `read-file` would.
let physical =
resolve_source_for_injection(state, &inj.source).map_err(|e| map_source_err(&e))?;
let bytes = std::fs::read(&physical).map_err(|_| ErrorCode::InvalidInput)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-medium medium

The host reads the entire source file into memory using std::fs::read without checking its size. If a capsule specifies an extremely large file as the injection source, this can cause the host to run out of memory and crash. Enforce a reasonable file size limit (e.g., 4 MiB) before reading the file to prevent potential Out-Of-Memory errors and Denial of Service vulnerabilities.

Suggested change
// Resolve + read-gate the source exactly as a `read-file` would.
let physical =
resolve_source_for_injection(state, &inj.source).map_err(|e| map_source_err(&e))?;
let bytes = std::fs::read(&physical).map_err(|_| ErrorCode::InvalidInput)?;
// Resolve + read-gate the source exactly as a `read-file` would.
let physical =
resolve_source_for_injection(state, &inj.source).map_err(|e| map_source_err(&e))?;
let metadata = std::fs::metadata(&physical).map_err(|_| ErrorCode::InvalidInput)?;
if metadata.len() > 4 * 1024 * 1024 {
return Err(ErrorCode::TooLarge);
}
let bytes = std::fs::read(&physical).map_err(|_| ErrorCode::InvalidInput)?;
References
  1. Before reading a file's entire content into memory, check its size against a reasonable limit to prevent potential Out-Of-Memory errors and Denial of Service vulnerabilities.

@joshuajbouw

Copy link
Copy Markdown
Contributor Author

Addressed the review (61ac328): write_private now uses create_new (O_CREAT | O_EXCL) instead of create(truncate), so the snapshot write refuses any pre-existing entry or symlink at the destination rather than truncating through it — with a regression test. In practice the destination is always a fresh unique index under a host-private TempDir (unreachable by a guest), but this makes the freshness an enforced invariant.

The other two findings were already resolved in the amended commit: reject_target_within_vfs (macOS symlink bypass) is gone because non-Linux FixedPath is now rejected outright, and the unbounded-read/OOM concern doesn't apply — injected content is guest-supplied and capped at MAX_INJECTION_BYTES (1 MiB) before any fs work. Build + clippy + inject tests green.

Add an agent-neutral host primitive: at process spawn, expose host-verified,
READ-ONLY bytes to the child's existing OS sandbox (bwrap on Linux, Seatbelt on
macOS). spawn-request gains file-injections (content + placement); honored by all
three tiers (spawn, spawn-background, spawn-persistent). The capsule hands over
the bytes plus how the child should find them; the host owns placement, integrity,
and exposure, and treats content as opaque.

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 (Linux --ro-bind P P; macOS Seatbelt file-read* allow +
  trailing file-write* deny on the literal), and sets the named env var on the
  child to that path. 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).

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 (a
private TempDir), BLAKE3 hash pinned + verified before exposure, hash in the spawn
audit, never a live bind. The host owns the materialized path in both modes, so it
never writes to a caller-named host path. No new capability — injection rides
host_process, into the caller's own child only, a strict restriction surface.
Empty file-injections is a zero-overhead no-op.

Closes #881
The snapshot writer used create(true).truncate(true), which would follow a
symlink planted at the destination and truncate-through to its target. In
practice the destination is always a fresh unique index under a host-private
TempDir, so it is not reachable by a guest — but switching to create_new
(O_CREAT | O_EXCL) makes the freshness an enforced invariant: the write
refuses any pre-existing entry or symlink rather than assuming none exists.
Defense in depth against a future caller that routes a non-scratch path here.

Adds a regression test asserting a pre-existing path is refused with its
content left intact.

Addresses the Gemini review's write_private finding; the macOS
reject_target_within_vfs and unbounded-read findings were already resolved in
the amended commit (non-Linux FixedPath is rejected outright, and injected
content is capped at MAX_INJECTION_BYTES before any fs work).
@joshuajbouw joshuajbouw force-pushed the feat/spawn-ro-file-injection branch from 61ac328 to 26fafc7 Compare June 16, 2026 00:37
@joshuajbouw joshuajbouw merged commit eced020 into main Jun 16, 2026
13 checks passed
@joshuajbouw joshuajbouw deleted the feat/spawn-ro-file-injection branch June 16, 2026 00:46
joshuajbouw added a commit to astrid-runtime/wit that referenced this pull request Jun 16, 2026
## 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-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`.

## 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: astrid-runtime/rfcs#22 ("Read-only file
injection").
- `scripts/validate-wit.sh` passes.
- Consumed by the core implementation (astrid-runtime/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.
joshuajbouw added a commit that referenced this pull request Jul 2, 2026
…store compat with sdk-0.7.x capsules (#1108)

## Linked Issue

Closes #1107

## Summary

The 0.9.0 host cannot instantiate any capsule that imports
`astrid:[email protected]` and was built with the published SDK. wit#15
added `file-injections` to `spawn-request` **in place** on the frozen
`astrid:[email protected]` package; the component-model linker matches host
imports structurally per package version, so every capsule compiled
against the published contract (all shipped capsules — `astrid-sys
0.7.x` embeds the pre-#15 shape) fails to link with `component imports
instance astrid:process/[email protected], but a matching implementation was
not found in the linker`. Found by fresh-install verification of the
released 0.9.0 binary: the sage supervisor fails `astrid init`, and
`astrid-capsule-shell` v0.2.0 fails to load. This is the #890
frozen-WIT-mutation class, recurring.

The fix is the dual-version pattern already used for
`astrid:[email protected]`/`@1.1.0`: restore `@1.0.0` to its published shape
and re-home the injection extension on an additive `@1.1.0`, serving
both off one implementation. Existing capsules load unchanged; new
capsules opt into injection by importing `@1.1.0`.

Pairs with **wit#20** (merged): `[email protected]` restored byte-identical
to its published shape, `[email protected]` added. Submodule pinned to that
commit here.

## Changes

- `bindings.rs`: kernel world imports `astrid:process/[email protected]`
alongside `@1.0.0`; the single `Kernel::add_to_linker` serves both
(engine + lifecycle linkers).
- The existing process backend
(`mod.rs`/`handle.rs`/`persistent/`/`inject.rs`) is re-homed to the
`@1.1.0` (superset) traits unchanged — a rename, not a rewrite; all
spawn/audit/capability/quota internals are shared.
- **New `compat.rs`**: `@1.0.0` `Host`/`HostProcessHandle` impls as thin
shims — convert record/enum shapes, delegate to the `@1.1.0` impl,
convert back. The three spawn entrypoints build a `@1.1.0`
`spawn-request` with empty `file-injections`, so `@1.0.0` spawn ==
spawn-with-no-injections. Error mapping is total (no wildcard: a future
`@1.1.0`-only arm is a compile error, not a silent mis-map).
`process-handle` re-tags between versions over the same rep (the `http`
`HttpStream` precedent).
- wit-staging regenerated for both versions; `wit` submodule bumped to
wit@278dbca; CHANGELOG under `[Unreleased]`.

## Verification

- `cargo build --workspace`, `cargo clippy --workspace --all-features
--all-targets -- -D warnings` — clean.
- `cargo test -p astrid-capsule` — 570 pass. New tests in `compat.rs`:
`both_process_versions_resolve_in_kernel_linker` (minimal components
importing `@1.0.0` and `@1.1.0` both link via
`configure_kernel_linker`), `unknown_process_version_is_not_served`
(negative control),
`v10_spawn_request_becomes_v11_with_empty_injections` (+ compile-time
guard on the restored shape), and four round-trip/totality conversions.
- Post-merge: re-run the 0.9.0 sage fresh-install; the supervisor and
shell should load. Ships in 0.9.1.

## Checklist

- [x] Linked to an issue
- [x] CHANGELOG.md updated (`[Unreleased]`)

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.

host: generic per-spawn read-only file injection (hash-pinned, agent-neutral sandbox primitive)

1 participant