feat(capsule): read-only file-injection for sandboxed spawns#890
Conversation
Summary of ChangesHello, 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
Using Gemini Code AssistThe 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
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 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
|
There was a problem hiding this comment.
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.
| #[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(()) | ||
| } |
There was a problem hiding this comment.
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
- To prevent path traversal vulnerabilities, explicitly check for and reject paths containing parent directory components (
..) before usingPath::starts_withfor validation, asstarts_withdoes not normalize paths.
| 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(()) | ||
| } |
There was a problem hiding this comment.
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
- Handle
Resultfrom file system operations to prevent silent failures or panics, especially in security-sensitive contexts such as creating sandbox profiles.
| // 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)?; |
There was a problem hiding this comment.
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.
| // 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
- 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.
e1bf259 to
fd674a7
Compare
|
Addressed the review (61ac328): The other two findings were already resolved in the amended commit: |
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).
61ac328 to
26fafc7
Compare
## 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.
…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
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 (
bwrapon 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
contentbytes 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 Seatbeltfile-read*allow + a trailingfile-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 (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).Changes
astrid-workspacesandbox: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 towrap); non-empty injections on an unsandboxed platform fail secure.astrid-capsulehost/process/inject.rs: BLAKE3-hashcontent, snapshot to a privateTempDir(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 (forenv-pointer) the host-set env var. RAII guard (theTempDir) cleans up on the child's lifetime owner: a local forspawn, theManagedProcessforspawn-background, the registry entry (reaped by value) forspawn-persistent. The spawn audit recordsenv:<var>=<hash>/path:<p>=<hash>. The host sets the env-pointer var authoritatively on the child (not via the inert guestspawn-request.env).wit/submodule is bumped to the file-injection commit andwit-stagingis regenerated, sobindgen!producesSpawnRequest.file_injections,FileInjection { content, placement }, andInjectionPlacement.env-pointervar name non-empty / no=/ no NUL;fixed-pathabsolute / no forbidden chars / no..; per-injection content cap (1 MiB →too-large); emptyfile-injectionsis a zero-overhead no-op.Test Plan
Automated
cargo test --workspacepassescargo clippy --workspace --all-features -- -D warnings)wrap_with_injectionsunsafe-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-injectionsis 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:
wit/submodule from the pre-merge branch commitb8fdb6fto thewit@mainmerge commit, then merge this.The CI submodule ↔
wit-stagingdirty-check enforces the pin stays consistent.Checklist
[Unreleased]