Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions astrid-sdk/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Comment on lines +119 to +135

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

Deriving PartialEq and Eq on InjectionPlacement makes it much easier for library consumers to compare placement options and write assertions in tests. Additionally, implementing From<InjectionPlacement> for wit_process::InjectionPlacement encapsulates the WIT conversion logic, keeping the builder's into_wit method clean and idiomatic.

#[derive(Debug, Clone, PartialEq, Eq)]
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),
}

impl From<InjectionPlacement> for wit_process::InjectionPlacement {
    fn from(placement: InjectionPlacement) -> Self {
        match placement {
            InjectionPlacement::EnvPointer(v) => Self::EnvPointer(v),
            InjectionPlacement::FixedPath(p) => Self::FixedPath(p),
        }
    }
}
References
  1. To improve maintainability and reduce duplication, refactor repeated blocks of code into a single helper function.


/// 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
Expand All @@ -120,6 +145,8 @@ pub struct Command {
stdin: Option<Vec<u8>>,
env: Vec<(String, String)>,
cwd: Option<String>,
/// Read-only files injected into the child, honored by all spawn modes.
file_injections: Vec<(Vec<u8>, InjectionPlacement)>,
// Persistent-tier knobs — honored ONLY by [`spawn_persistent`], ignored
// by [`spawn`] / [`spawn_background`] (per the WIT contract).
label: Option<String>,
Expand Down Expand Up @@ -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<String>,
content: impl Into<Vec<u8>>,
) -> 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<String>, content: impl Into<Vec<u8>>) -> 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<Vec<u8>>,
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]
Expand Down Expand Up @@ -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(),
Comment on lines +331 to +345

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

With the From implementation in place, we can simplify the file_injections mapping in into_wit by calling placement.into(), which improves readability and maintainability.

            file_injections: self
                .file_injections
                .into_iter
                .map(|(content, placement)| wit_process::FileInjection {
                    content,
                    placement: placement.into(),
                })
                .collect(),
References
  1. To improve maintainability and reduce duplication, refactor repeated blocks of code into a single helper function.

limits: self.limits.map(ResourceLimits::into_wit),
label: self.label,
keep_stdin_open: self.keep_stdin_open.then_some(true),
Expand Down Expand Up @@ -781,3 +866,39 @@ pub fn status_many(ids: &[ProcessId]) -> Result<Vec<ProcessInfo>, 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"
));
Comment on lines +885 to +897

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

Since InjectionPlacement now implements PartialEq and Eq, we can replace the verbose matches! assertions with cleaner and more readable assert_eq! calls.

Suggested change
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"
));
assert_eq!(
cmd.file_injections[0].1,
InjectionPlacement::EnvPointer("CLAUDE_CODE_MANAGED_SETTINGS_PATH".to_string())
);
assert_eq!(cmd.file_injections[0].0, b"{}");
assert_eq!(
cmd.file_injections[1].1,
InjectionPlacement::FixedPath("/etc/codex/requirements.toml".to_string())
);
assert_eq!(
cmd.file_injections[2].1,
InjectionPlacement::EnvPointer("GEMINI".to_string())
);

}

#[test]
fn no_injection_by_default() {
assert!(Command::new("agent").file_injections.is_empty());
}
}
91 changes: 84 additions & 7 deletions astrid-sys/wit-staging/deps/astrid-process/process@1.0.0.wit
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,66 @@ interface host {
max-open-files: option<u32>,
}

/// 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<u8>,
/// 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 <snapshot> <path>` 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.
Expand All @@ -177,6 +237,13 @@ interface host {
/// Per-child OS resource ceilings. Applies to EVERY tier.
/// (NOT YET ENFORCED — see `resource-limits`.)
limits: option<resource-limits>,
/// 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<file-injection>,

// ---- the fields below are honored ONLY by `spawn-persistent`
// and ignored by `spawn` / `spawn-background`. ----
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -449,9 +516,11 @@ interface host {
spawn-background: func(request: spawn-request) -> result<process-handle, error-code>;

// ================================================================
// 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
Expand Down Expand Up @@ -504,10 +573,18 @@ interface host {
read-since: func(id: process-id, which-stream: log-stream, cursor: log-cursor, max-bytes: u32) -> result<log-chunk, error-code>;

/// `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<u8>) -> result<u32, error-code>;

/// `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.
Expand Down
2 changes: 1 addition & 1 deletion contracts
Loading