RFC: Host ABI#22
Conversation
55 host functions across 13 domain interfaces, 4 guest exports. Capability-gated, audited, per-principal scoped. Canonical WIT spec at core/wit/astrid-capsule.wit.
51 host functions across 12 interfaces (not 55/13). All function names and per-interface counts verified against astrid-capsule.wit.
…solved questions The WIT file is the canonical spec — the RFC now references it instead of duplicating function tables that go stale. Added: capability model summary, VFS scheme table, KV scoping explanation, 5 substantive unresolved questions.
Adds the three coordinated rules that make the host ABI evolvable post-1.0 without breaking third-party capsules on every WIT touch: 1. Per-domain packages — replace monolithic astrid:capsule@0.1.0 with one package per domain (astrid:ipc, astrid:net, astrid:kv, ...) plus a minimal astrid:core for cross-cutting types only. 2. Multi-version kernel registration — kernel explicitly registers every supported (package, version) pair into the wasmtime CM linker, with host-side shims translating between version shapes. 3. Frozen WIT files per version — once shipped, interfaces/<name>@X.Y.Z.wit is immutable; shape changes ship as new files at new versions, enforced by CI lint in unicity-astrid/wit. Also updates the RFC to reflect that Component Model is the current transport (Extism/JSON wire-format framing removed), points the canonical spec at unicity-astrid/wit, scopes the discipline to kernel<->capsule (capsule<->capsule is IPC-bus territory, not WIT), resolves the prior 'ABI versioning independence' and 'deprecation path' unresolved questions, and adds new open questions on minimal- core vs zero-shared and resource-typed caller-context. Tracking issue astrid#750 added alongside astrid#573.
…re, add astrid:guest Follow-up to the per-domain split in unicity-astrid/wit#feat/per-domain-split: - Frozen-file path is host/<name>@<version>.wit, not interfaces/<name>@<version>.wit. The interfaces/ directory in the wit repo is already capsule-to-capsule IPC contract territory; the host ABI lives at host/. - astrid:core is dropped. Auditing the actual types in the monolithic astrid-capsule.wit showed nothing genuinely cross-cutting: caller-context is sys-only, principal is option<string> (a primitive), error shapes are result<T, string>. Zero-shared is purer and cheaper. Open question and drawback updated to reflect the decision. - astrid:guest@1.0.0 added for the lifecycle export contract (astrid-hook-trigger, run, astrid-install, astrid-upgrade). Named 'guest' rather than 'hook' to avoid collision with the unrelated capsule-to-capsule astrid:hook@1.0.0 in interfaces/hook.wit. Symmetric with the 'host' interfaces — host is kernel-side, guest is capsule-side. - Rule 1 package list reordered to match the actual file order in the wit repo for grep-friendliness; cron removed since it does not exist in the WIT (it was an RFC table artefact, not a real interface). - Rule 3 lint description tightened: 'modifies, deletes, or renames a file matching *@X.Y.Z.wit that exists on the base ref' — new files matching the pattern are the legitimate evolution path.
Reflect the resolution adopted in unicity-astrid/wit@feat/per-domain-split: `astrid:guest@1.0.0` exposes four per-export worlds (`interceptor`, `background`, `installable`, `upgradable`) rather than a single `exports` world bundling all four entry points. The rationale comes from a concrete kernel cost: the wasm32-wasip2 toolchain auto-stubs mandatory exports the source crate does not implement, which forced the kernel into binary-parsing stub detection to avoid treating every interceptor-only capsule as a run-loop daemon (astrid-capsule's STUB_PRONE_EXPORTS hack). Per-export worlds eliminate the cause — exports appear in the binary only when implemented. Updates the rule-1 narrative, the worked example, and folds the now- resolved open questions on world naming and `astrid:capsule@0.1.0` cleanup into a single retrospective note.
…folding
Three changes, single commit:
- Add a 'Versioning rules' subsection under the evolution discipline.
Minor for purely orthogonal additions (new top-level functions on
existing interfaces only). Major for any record/enum shape touch,
signature change, or removal. Worked table of changes and rationale.
Notes Capsule.toml's Cargo-style ^constraints and how the kernel's
multi-version registration reconciles them with the linker's exact-
match semantics.
- Add a 'WASI primitives also available' subsection. The kernel adds
wasmtime_wasi::p2 to the linker, so capsule authors should reach for
wasi:random, wasi:clocks/monotonic-clock, wasi:clocks/wall-clock, and
wasi:io/streams directly. astrid:* exists where Astrid layers
capability gating, principal scoping, or audit on top of a syscall;
where no such layering is needed, WASI is the right surface.
- Correct the host-interfaces table: 65 functions across 11 packages
plus astrid:guest (was 51 functions across 12 with a non-existent
'cron' row). Update capability list to drop 'cron'.
- Strip retrospective scaffolding ('earlier draft proposed', 'previous
open questions resolved', 'STUB_PRONE_EXPORTS carries that hack
today'). Leaves the doc reading as a specification rather than a
changelog.
Synthesizes the entire freeze-prep refactor that landed on unicity-astrid/wit#feat/per-domain-split into the spec. Summary: rewritten to introduce typed resources, per-package error variants, and the astrid-bus:* namespace split alongside the existing per-domain / multi-version / frozen-file rules. Function counts dropped in favour of describing the surface shape (resources + factories + typed errors), since the count is now meaningfully different for each package (resource methods vs flat functions). Host interfaces table rewritten: lists each package's surface character (file-handle resource + path ops, tcp-listener / tcp-stream / udp-socket resources, etc.), the per-package error story, and the capability sets including the new net_udp and net_tcp_bind allowlists. WASI subsection replaced. The 'WASI primitives also available' table that promoted wasi:random / wasi:clocks / wasi:io to capsule authors turned out to be wrong policy — those primitives bypassed Astrid's capability gates, principal scoping, and audit chain. New 'Foundation primitives — minimal WASI retention' subsection explains the narrow retention (wasi:io/poll + wasi:io/streams only) for the pollable composition story, and that random/clocks/sleep/wall-clock live under astrid:sys instead. New 'Capsule-to-capsule contracts live in a distinct astrid-bus:* namespace' subsection documents the namespace split between kernel ABI (astrid:*) and IPC bus event schemas (astrid-bus:*). Capsule.toml example covers both prefixes. Guest exports section rewritten around per-export worlds (interceptor / background / installable / upgradable). Capsules include only what they implement; toolchain emits exports only when their world is included; kernel uses plain export-presence checks (no stub detection). Error handling section rewritten around per-package error-code variants with specific arms + unknown(string) catch-all. Documents the constraint that error strings never leak host real-paths / IPs / UUIDs / capability names; the enumerated arms are the contract. Drawbacks updated — surface description matches the new resource-heavy shape; result<_, string> mentions in the rule-1 narrative corrected. Capability model gains net_udp and net_tcp_bind. No structural changes — same RFC sections, just brought current with the implementation.
Splits the monolithic `astrid:capsule@0.1.0` host ABI into per-domain frozen packages and applies the full pre-1.0 audit-driven hardening. Resolves [astrid-runtime/astrid#750](astrid-runtime/astrid#750) and aligns with [rfcs#22](astrid-runtime/rfcs#22) (host_abi RFC). This PR is the 1.0 freeze candidate for the WIT contract. Every shape decision here is irreversible after merge (per the frozen-file rule the PR also lands), so the contract has been audited against Rust `std`, WASI Preview 2, and capability-based security guidance. ## 12 host packages — `astrid:*` (kernel ABI) Each is a frozen versioned file under `host/`. Every host call is gated, principal-scoped, and audited. | Package | Surface | |---|---| | `astrid:fs@1.0.0` | `file-handle` resource (open/read-at/write-at/sync-data/sync-all/stat/set-len) + path ops (exists/mkdir/mkdir-all/readdir/stat/stat-symlink/unlink/read-file/write-file/append/copy/rename/remove-dir-all/canonicalize/read-link/hard-link/open). `file-type` enum, `datetime` record. | | `astrid:ipc@1.0.0` | `subscription` resource (poll/recv/subscribe-readiness) + publish/publish-as. `principal-attribution` variant on `ipc-message` distinguishing kernel-verified from uplink-claimed. | | `astrid:uplink@1.0.0` | uplink-register/send. `uplink-profile` enum. | | `astrid:kv@1.0.0` | get/set/delete/cas/list-keys/list-keys-page/clear-prefix. | | `astrid:net@1.0.0` | `unix-listener`, `tcp-listener` (inbound TCP), `tcp-stream` (full I/O surface + TCP options), `udp-socket` (unconnected + connected modes). Factories: bind-unix/bind-tcp/connect-tcp/udp-bind/lookup-host. | | `astrid:http@1.0.0` | `http-stream` resource (status/headers/read-chunk/close/subscribe-readable). `http-method` variant. SSRF airlock. | | `astrid:sys@1.0.0` | log, config, get-caller, signal-ready, clock-ms, clock-monotonic-ns, sleep-ns, random-bytes (u64 length), check-capsule-capability. | | `astrid:process@1.0.0` | `process-handle` resource (read-logs/write-stdin/close-stdin/signal/kill/wait/wait-with-output/os-pid/subscribe-exit/subscribe-logs). Factories: spawn/spawn-background. `exit-info { exit-code, signal }`. Typed `process-signal` enum. | | `astrid:elicit@1.0.0` | elicit/has-secret. Typed `elicit-type` enum + `elicit-response` variant. Install/upgrade-only. | | `astrid:approval@1.0.0` | request-approval. Typed `approval-decision` enum. | | `astrid:identity@1.0.0` | resolve/link/unlink/create-user/list-links. Per-operation response types (no JSON-in-WIT). | | `astrid:guest@1.0.0` | Guest exports as separate per-export worlds (`interceptor` / `background` / `installable` / `upgradable`). Capsules `include` only what they implement. | ## Namespace split — `astrid-bus:*` for capsule-to-capsule Capsule-to-capsule IPC event schemas moved from `astrid:*` to `astrid-bus:*` to resolve collisions (host's `astrid:approval` and `astrid:elicit` collided with bus equivalents) and codify the layering. 17 `interfaces/*.wit` files renamed, internal cross-refs updated, `Capsule.toml` imports split: ```toml [imports.astrid] # kernel ABI — direct wasmtime CM linker calls fs = "1.0.0" ipc = "1.0.0" [imports.astrid-bus] # IPC bus event schemas llm = "^1.0" session = "^1.0" ``` ## Three coordinated evolution rules (from #750) 1. **Per-domain packages.** Each interface is its own `astrid:<name>@X.Y.Z` package so bumping `astrid:net` doesn't recompile capsules that only import `astrid:kv`. 2. **Multi-version kernel registration.** Kernel registers every supported `(package, version)` pair explicitly into the wasmtime CM linker. (Kernel work, separate PR.) 3. **Frozen WIT files per version.** `host/<name>@X.Y.Z.wit` is immutable after main. Shape changes ship as new files. Enforced by `scripts/lint-wit-immutability.sh`. ## Audit-driven hardening (across three agent audits) **Typed errors** — every `result<_, string>` replaced with per-package `error-code` variants (fs/ipc/net/kv/http/sys/process/approval/elicit/identity/uplink). Catch-all `unknown(string)` arm keeps escape hatch. **Resource types** — every long-lived handle migrated from forgeable `u64` to typed CM resources (`file-handle`, `unix-listener`, `tcp-listener`, `tcp-stream`, `udp-socket`, `http-stream`, `process-handle`, `subscription`). Type safety across interfaces, automatic Drop, per-instance ownership. **Pollable foundation** — `wasi:io@0.2.0` (poll + streams) retained in the linker. `subscribe-readiness` / `subscribe-readable` / `subscribe-exit` / `subscribe-logs` methods return `wasi:io/poll.pollable` on subscription, unix-listener, tcp-listener, tcp-stream, udp-socket, http-stream, process-handle. Bridge / fan-out capsules can multiplex bus + sockets + HTTP + child-process events via `wasi:io/poll.poll` without embedding a runtime purely for that. **`ipc-publish-as` spoofing closed** — `principal-attribution` variant on `ipc-message` makes verified vs claimed explicit; downstream capability checks on sensitive actions MUST require `verified`. The kernel audit-logs both the uplink's true principal and the claimed principal on publish-as. **Doc-as-contract** — VFS-scheme path return rule (no host real-paths leak through canonicalize / read-link / errors), TOCTOU re-validation contract (kernel re-resolves every path on every call), charset rules for topics/keys/paths, per-fn audit policy, cumulative byte quotas, `get-interceptor-bindings` scoped to caller's own bindings (no cross-capsule capability inference). **WASI alignment** — `shutdown-how::receive/send/both` (was read/write/both); `hop-limit` (was TTL); `datetime` record for file timestamps (was `mtime-ns: option<u64>`, fixes 2554 overflow + supports pre-1970); `random-bytes: u64` length; `http-method` variant matching `wasi:http`. **WASI no longer exposed broadly** — the kernel's `add_to_linker_sync` will narrow to `wasi:io/poll` + `wasi:io/streams` only (kernel work, separate PR). `astrid:*` becomes the single host ABI. Random / clocks / sleep now live on `astrid:sys` so every host call goes through the same audit/principal layer. `wasi:filesystem` and `wasi:sockets` no longer exposed (capsules go through `astrid:fs` / `astrid:net` for capability-gated access). ## Late additions before freeze (rest of the 1.x candidate list) - **Inbound TCP listener** (`tcp-listener` resource + `bind-tcp`) — for self-hosted webhook receivers, gRPC endpoints, Prometheus scrape ports. Gated by `net_tcp_bind`. - **Hard-link creation** (`fs-hard-link`) — atomic publish patterns, content-addressed stores. Same-VFS-scheme only. - **`fs-mkdir` strict / `fs-mkdir-all` recursive** split. - **UDP connected mode** (`udp-socket.connect/disconnect/send/recv/peer-addr`) — QUIC, DNS-over-UDP, syslog-over-UDP. Faster syscall path and built-in spoofed-peer filtering. - **`SO_REUSEADDR`** on `tcp-stream` for listener restart cycles. - **`process-handle.os-pid()`** for cgroup / /proc correlation. - **`process-handle.wait-with-output()`** closes the read-logs race on short-lived children. - **`subscribe-readable` on http-stream** completes the pollable composition story. ## Intentionally deferred (each clean minor bump if/when demand emerges) - Symlink creation (`fs-symlink`) — sandbox-escape risk outweighs demand. Read-only `fs-read-link` / `fs-stat-symlink` retained. - File locking — `kv-cas` covers in-runtime concurrency; cross-process locks aren't useful in a sandboxed runtime. - UDP multicast — niche; mDNS / SSDP can drive future addition. - HTTP outgoing body streaming — needs a properly-designed `outgoing-body` resource (mirroring `wasi-http`); 1.x package addition. - Additional `SO_*` socket options beyond keepalive/linger/reuseaddr — defaults work for the typical agent capsule. ## Tooling - `deps/wasi-io/` — vendored `wasi:io@0.2.0` (poll, streams, error, world) at the official release tag. - `scripts/validate-wit.sh` — stages each `host/*.wit` with `deps/` + sibling host/ files into a tempdir before parsing, so cross-package `use` clauses resolve. (`wasm-tools` 1.x can't walk deps trees multi-pass.) - `scripts/lint-wit-immutability.sh` — fails any PR that modifies / deletes / renames a published `*@X.Y.Z.wit` file. - `.github/workflows/lint.yml` runs both on every PR + push to main. ## Parity vs the pre-split monolithic `astrid:capsule@0.1.0` Verified during the per-domain split: 65/65 original host functions present (identical names, sorted-diff clean), 30/30 referenced types preserved, 4/4 guest exports preserved verbatim. 4 dead WIT records dropped (`capsule-context`, `tool-input`, `tool-output`, `tool-definition` — never referenced in any WIT signature). Subsequent commits added capabilities (UDP, DNS, TCP listener, hard-link, process surface extensions, etc.) without removing any. ## Test plan - [x] CI `wit-immutability` lint passes (no published-file modifications). - [x] CI `wit-parses` job passes — `scripts/validate-wit.sh` validates every `host/*.wit` with proper deps staging. - [x] Per-export-world `include` semantics validated against synthetic consumer worlds (interceptor-only, all-four-worlds, realistic interceptor + ipc/host import). - [x] Lint negative-case sweep on a scratch branch: modify / delete / rename of a frozen file → exit 1 with the right marker; adding a new versioned file → exit 0. - [x] Resource types and use clauses parse end-to-end via the staged `deps/wasi-io/` resolution. ## Follow-ups in dependent repos - `unicity-astrid/astrid` — kernel implementation: narrow `add_to_linker_sync` to `wasi:io/poll` + `wasi:io/streams`, implement all the new `astrid:sys` / `astrid:fs` / `astrid:kv` / `astrid:net` / `astrid:process` fns + resources, wire up pollable on the relevant resources, drop `trigger_hook` impl, error-code translation in host impls. Keep cron plumbing alone. - `unicity-astrid/sdk-rust` — regen bindings against the new shape, expose pollable wrappers, error-code → SysError translation, `fan_out_collect` helper alongside existing `request_response`. - `unicity-astrid/sdk-js` — mirror. - First-party capsule migrations — prompt-builder + hook-bridge off `trigger_hook` (to bus-side fan-out); other capsules migrate to `astrid_sdk::ipc::request_response` for correlation-id patterns. Refs: [astrid-runtime/astrid#750](astrid-runtime/astrid#750), [rfcs#22](astrid-runtime/rfcs#22)
Folds the reattachable-background-process design into the still-draft astrid:process@1.0.0 interface (no version evolution — nothing has launched): a persistent tier alongside the ephemeral spawn/spawn-background, where spawn-persistent returns an opaque principal-scoped process-id backed by a host-owned registry that survives instance churn (attach/read/signal/wait/ write/stop by id, list-processes/status, durable watch events). Motivated by the pooled-instance reattach gap (a background process is reaped when its spawning instance returns to the pool, so the split-tool pattern is impossible); generalized beyond shell to MCP-stdio hosts, dev-server supervisors, and log tailers. Per-principal isolated (id is a capability token re-checked against the creator on every call; spawn refuses owner-fallback principals), quota-bounded, sandbox-contained (macOS fail-closed: no PID namespace), auditable, fail-secure. watch publish-authority added as an open question (ties to topic-grammar). Updates the interfaces table row.
…/SIGCONT, env/secret semantics to process Rounds out the astrid:process contract before 1.0 freezes the record/enum shapes (adding fields/variants post-launch would be a major break): - resource-limits record on spawn-request (max-memory-bytes / max-cpu-ms / max-pids / max-open-files) — declared now, enforcement NOT YET SUPPORTED. - cpu-ms / mem-bytes-peak on process-info — usage readback, NOT YET POPULATED. - process-signal gains stop (SIGSTOP) and cont (SIGCONT) for pause/resume. - Documents env/secret semantics: env is the only path in (sandbox strips the rest); secrets go via stdin, never args (audited). The remaining open question is whether env values may themselves carry secrets vs an explicit secret channel — no shape impact either way.
## Summary Adds the **persistent process tier** to `host/process@1.0.0.wit` — the canonical WIT for the design folded into the host-ABI RFC ([rfcs#22](astrid-runtime/rfcs#22), "Process: ephemeral and persistent tiers"). No version bump: `astrid:process` is still draft (pre-launch), so the persistent surface belongs *in* `@1.0.0` rather than an evolution file. ## Why Under the dynamic instance pool a `spawn-background` `process-handle` is owned by the spawning instance and reaped when that instance resets — so the start-then-read-then-stop split-tool pattern (shell's `spawn_background_process`/`read_process_logs`/`kill_process`) is impossible. The persistent tier returns an opaque, principal-scoped `process-id` backed by a host-owned registry that survives instance churn, reattachable from any later invocation. Generalizes beyond shell to MCP-stdio hosts, dev-server supervisors, and log tailers. ## What's added Alongside the frozen ephemeral `spawn` / `spawn-background` / `process-handle`: - `spawn-persistent` → `process-id`; `attach` (re-materialise the handle, **detach**-on-drop); id-keyed `read-logs`/`read-since`/`write-stdin`/`close-stdin`/`signal`/`wait`/`stop`/`release-process`; `list-processes`/`status`/`status-many`; durable `watch`/`unwatch`. - New types: `process-id`, `process-phase`, `log-cursor`, `log-stream`, `log-chunk`, `process-info`, `overflow-policy`, `resource-limits`. - `spawn-request` gains `limits` + persistent-only fields (label, keep-stdin-open, overflow, log-ring-bytes, max-lifetime-ms, idle-timeout-ms, exit-retention-ms). - `error-code` gains `no-such-process` / `registry-full` / `invalid-id` / `persist-unsupported`; `process-signal` gains `stop`/`cont` (SIGSTOP/SIGCONT); `process-info` carries `cpu-ms`/`mem-bytes-peak`. ## Contract vs implementation The file is the **full contract**; deferred behaviour is tagged `(NOT YET IMPLEMENTED)` / `(NOT YET ENFORCED)` / `(NOT YET POPULATED)` so the **shapes are fixed before 1.0 freezes**. The host stubs the persistent functions, stdin capture, real pollables, resource-limit enforcement, and usage population until they land (core → SDKs → shell follow). ## Validation `scripts/validate-wit.sh` parses every `host/*.wit` clean (incl. this file). One genuine WIT catch: `stream` is a reserved keyword, so `read-since`'s stream selector is named `which-stream`. Open question carried from the RFC: whether `watch`'s host-published `astrid.process.v1.*` topics need a manifest `[publish]` or are a kernel-authored class (ties to topic-grammar #809); `watch` may be cut for `status`+bounded-`wait` polling.
…e as an ungated view Resolves the open Unresolved Question. Adds enumerate-capabilities() to astrid:sys: argument-free, returns the calling capsule's own EFFECTIVE capabilities (what the kernel enforces, not manifest text). Ungated. Capability posture is structural metadata, not a secret: knowing capsule X holds host_process grants nothing, because capabilities are enforced, not concealed — a caller can only exercise what it was itself granted. So introspection (self-enumerate + the existing per-name check, self or other) stays a read-only VIEW, ungated on sys. It adds no capability; it is just a view. Records the rejected alternative: gating cross-capsule introspection is security-by-obscurity (the name space is small and published, UUIDs are discoverable, and posture is not sensitive in the first place) and runs against Astrid's enforce-don't-conceal stance — no gate capability, no migration. enumerate-capabilities underpins the Capability delegation future possibility. Extends astrid:sys (sys@1.0.0); WIT to follow on review.
…nifest-scoped, real checker, reuse-not-blocker)
Q1 return shape: enumerate-capabilities returns capability NAMES/categories (host_process, net_connect, fs_read), the dual of check-capsule-capability; scoped args ("claude", host:port, paths) stay kernel-enforcement detail, not in the view.
Q3 semantics: at the capsule level effective == manifest-declared — capsule capabilities are not revoked at runtime; the grant/revoke model is principal-scoped, a separate axis. Dropped the misleading 'runtime-revoked grant does not appear' phrasing.
Q2 scope: check-capsule-capability is brought onto the same kernel-registered capability registry enumerate reads (it answers only a subset today) so the two are consistent — one manifest-derived source of truth.
Q4 motivation: clarified enumerate is a robustness/reuse primitive (a reusable supervisor deployed under varying manifests, and anti-drift), NOT a gate — a fixed-manifest capsule already knows its own caps statically, so cap-derived governance can proceed with static declaration and does not block on this.
## Linked Issue Closes #866 ## Summary Implements the **persistent-process tier** of `astrid:process@1.0.0` (merged contract: astrid-runtime/wit#12; design: astrid-runtime/rfcs#22). A capsule can now spawn a background child that **outlives the pooled, stateless WASM instance** that started it — previously an ephemeral `process-handle` is reaped when its instance resets on return to the dynamic pool, so the split `spawn → read → stop` pattern across tool invocations was impossible. The core is a host-owned `PersistentProcessRegistry`, cloned into every pooled `HostState` exactly like the cancellation `ProcessTracker`, so a `process-id` survives instance churn. It owns the child (spawned on the daemon runtime under the same `bwrap`/Seatbelt sandbox as the ephemeral tier), its per-stream log rings, and its stdin pipe. ## Changes - **`wit` submodule → merged main** (`8946e98`); committed `wit-staging/` ships the contract on the published-crate path (zero drift; `host/` byte-identical). - **`PersistentProcessRegistry`** (`engine/wasm/host/process/persistent/`, split into `mod`/`ring`/`entry`/`ids`/`config` + tests): - `spawn-persistent` — 256-bit host-minted CSPRNG `process-id`, lowercase base32 (doubles as an IPC topic suffix); registry stores only a **keyed BLAKE3 hash**, never the raw token. Refuses the **owner-fallback** principal (`persist-unsupported`). - `status` / `status-many` / `list-processes` (non-draining), `read-logs` (drain) + `read-since` (non-draining, cursor-addressed, byte-faithful `list<u8>`), `signal` (incl. `stop`/`cont`), bounded `wait`, `stop` (SIGTERM→grace→SIGKILL, frees the slot), `release-process`, `write-stdin` / `close-stdin` (via `keep-stdin-open` capture). - **Security:** every id-keyed call re-resolves the live `(principal, capsule)` vs the recorded creator; unknown / wrong-owner / wrong-capsule / reaped all collapse to `no-such-process` (no oracle). - **Lifecycle:** per-principal concurrent + retained-id caps; idle / max-lifetime / exit-retention TTLs (guest values clamped DOWN to host ceilings); per-capsule reaper task; kill-all on capsule unload / daemon graceful shutdown. A per-process monitor task owns the `Child` (`kill_on_drop` backstop); reaping happens outside the registry lock. - **`HostState` gains `persistent_processes`**, wired through the engine (one registry per `WasmEngine`, cloned into the pool + the lifecycle/hooks construction sites). - **macOS works by default** — the only weaker property is orphan-on-daemon-*hard-crash* (Seatbelt has no `die-with-parent`); a graceful shutdown / unload reaps. Documented, not gated behind `persist-unsupported`. ## Deferred (honest) - `attach` (resource-handle materialisation — needs dual-typed resource-table dispatch; the id-keyed ops ARE its documented equivalent). - `watch` / `unwatch` — host-published lifecycle events raise an OPEN publish-authority question in RFC host_abi; `status` + bounded `wait` is the working polling alternative. - WIT `(NOT YET …)` items: resource-limit enforcement, `cpu-ms` / `mem-bytes-peak`, instance-local pollables. ## Test Plan ### Automated - [x] `cargo test --workspace` passes — **1990 passed, 0 failed**, incl. 3 new integration tests that spawn **real** processes through the registry (spawn/status/wait/read + owner-isolation; concurrent-cap reject + stop-reaps + slot-free; non-draining cursor `read-since`) and pure-logic unit tests for the log ring, cursor, and id minting. - [x] No new clippy warnings — the diff is `astrid-capsule` + `astrid-hooks`, both clean; the pre-existing `sys.rs:365` / `astrid-workspace` lints are untouched. ### Manual - `cargo build --workspace` green; `cargo fmt --all --check` clean. ## Checklist - [x] Linked to an issue - [x] CHANGELOG.md updated under `[Unreleased]`
Adds `enumerate-capabilities() -> result<list<string>, error-code>` to `astrid:sys` — argument-free, ungated, returns the **calling capsule's own held capability names** (categories like `host_process` / `net_connect` / `fs_read`, not the scoped arguments within them). The list dual of `check-capsule-capability`. Manifest-derived: capsule capabilities are not runtime-revoked (the grant/revoke model is principal-scoped, a separate axis), so the declared set == the usable set at the capsule level. Ungated because capability posture is structural metadata, not a secret (enforce-don't-conceal). Resolves the `capability-introspection-depth` unresolved question in the host-ABI RFC (astrid-runtime/rfcs#22, which the RFC author resolved as 'self-enumerate as an ungated view'). Pre-1.0 DRAFT edit-in-place to `sys@1.0.0`. `check-capsule-capability` doc clarified as the per-name dual (self or other). Host impl + SDK surfaces follow in core / sdk-rust / sdk-js.
…ss spawn Adds an optional file-injections list to spawn-request: the host materializes host-verified, read-only bytes at a chosen absolute path inside the child's existing OS sandbox. Agent-neutral (host never parses the bytes or interprets target); motivating consumer is un-overridable per-spawn agent governance. Specifies the write-protection invariant (unwritable by the child AND the principal's capsule fs surface), the integrity contract (snapshot to a host-owned path outside every VFS mount, BLAKE3 content-hash pinned + verified before exposure, hash recorded in the spawn audit, never a live bind of source), and the per-OS mechanism (Linux --ro-bind; macOS Seatbelt deny file-write* with target required outside the principal's VFS surface). No new capability — injection is permitted only into the caller's own child and is strictly a restriction.
Replaces the single source/target/read-only shape with content bytes plus an injection-placement variant: - env-pointer(env-var): host materializes the verified snapshot at a host-owned path, exposes it read-only, and sets the named env var to it. OS-agnostic (Linux + macOS); the capsule supplies only the env-var name. 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): host ro-binds the snapshot at an absolute in-sandbox path. Linux-only (bwrap remap); rejected on macOS (a host write to a caller-named path would be an escalation). For agents with a fixed enforced path and no env redirect (Codex /etc/codex/requirements.toml). content bytes (not a source VFS path) removes the host-side read, the read gate, and the home-staged intermediate file. The host owns placement in both modes, so the macOS arbitrary-write escalation is gone. Drops the always-true read-only bool; a writable bind is a separate future capability.
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).
## 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]`
## Summary Adds the `file-injection` record + `injection-placement` variant and a `file-injections` field (honored by every tier) to `spawn-request` in `astrid:process@1.0.0`. 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.
…uploads Document the http contract's first post-1.0 minor evolution: an all-optional request-options record (caller timeouts, redirect policy, response/decompression size caps, https-only, subresource integrity), response-meta, a streaming request body (upload), and response trailers. Records the layer split (host-internal connection reuse and an SDK-side SSE framing helper are deliberately NOT contract changes), the SSRF exclude-list plus per-hop redirect re-validation, the per-call-vs-client-resource and mTLS forks, and the minor-bump classification as the first worked example of the evolution discipline.
There was a problem hiding this comment.
Pull request overview
This PR introduces RFC 0000-host-abi, formalizing Astrid’s host ABI as a typed WASM Component Model contract between the kernel and capsule guests, including the per-domain astrid:* package split, the astrid-bus:* namespace split for bus schemas, and the pre/post-1.0 ABI evolution discipline (per-domain packages, multi-version registration, frozen WIT files).
Changes:
- Adds a full RFC describing the 1.0 host ABI structure (packages, resources, error-code variants), capability gating/scoping semantics, and minimal WASI retention.
- Documents ABI evolution/versioning rules and the operational consequences of wasmtime Component Model structural typing.
- Specifies namespace layering between kernel ABI (
astrid:*) and capsule-to-capsule bus schema packages (astrid-bus:*).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Every host function checks the calling capsule's declared capabilities before | ||
| executing. A capsule without `fs_read` capability cannot call `astrid_fs_read`. | ||
| Violations are logged to the audit chain and return an error to the guest. |
There was a problem hiding this comment.
Fixed in 28f23d5 — switched the example to the Component Model form astrid:fs/host.read-file.
| **capsule ↔ capsule** communication, which travels over the IPC bus as | ||
| typed events on string topics (e.g. `tool.v1.execute.*`). The bus is not WIT- | ||
| typed; capsule-to-capsule shape changes manifest at runtime as deserialization | ||
| errors or unmatched subscriptions, not at load time as linker errors. |
There was a problem hiding this comment.
Fixed in 28f23d5 — the astrid-bus:* schemas are WIT-defined in interfaces/*.wit, so I reworded this to say they aren't enforced by the wasmtime linker (incompatibilities surface at runtime) rather than "not WIT-typed".
| | World | Export | Required if included | | ||
| |---|---|---| | ||
| | `interceptor` | `astrid-hook-trigger(action, payload)` returning `capsule-result` | Most capsules — handles routed events | | ||
| | `background` | `run()` | Capsules with a long-lived IPC subscriber loop | | ||
| | `installable` | `astrid-install()` | One-time install hook (may use `elicit` for secrets/config) | | ||
| | `upgradable` | `astrid-upgrade()` | Version-upgrade hook | |
There was a problem hiding this comment.
Left as-is: astrid-hook-trigger is the actual export name in the canonical astrid:guest WIT — guest@1.0.0.wit:62 declares export astrid-hook-trigger: func(action: string, payload: list<u8>) -> capsule-result. The table matches the contract exactly, so this isn't legacy naming.
| opt-in with a best-effort reaper or is gated on a stronger macOS reaper first. | ||
| [future-possibilities]: #future-possibilities | ||
|
|
There was a problem hiding this comment.
Fixed in 28f23d5 — added the missing # Future possibilities header above the anchor so it resolves.
| - **Resource-typed caller-context.** Replace the payload-carried `principal` | ||
| field with a host-owned resource handle exposed via accessor methods. This | ||
| removes a cross-cutting record type from `astrid:core` entirely, letting | ||
| the host evolve principal representation without any WIT payload change. |
There was a problem hiding this comment.
Fixed in 28f23d5 — corrected to astrid:sys/host, where caller-context actually lives (the standalone astrid:core package was dropped).
Component Model form for the fs capability-gating example (astrid:fs/host.read-file, not the legacy astrid_fs_read symbol); clarify that astrid-bus schemas are WIT-defined but not linker-enforced (not 'not WIT-typed'); add the missing '# Future possibilities' section header so its anchor resolves; fix a stale 'astrid:core' package reference to 'astrid:sys/host' where caller-context actually lives. The astrid-hook-trigger export name was left as-is: it matches the canonical astrid:guest WIT (guest@1.0.0.wit).
Formalizes the host ABI as the typed contract between the Astrid kernel and WASM capsule guests, and locks in the pre-1.0 evolution discipline that makes the ABI safe to change post-1.0 without breaking third-party capsules.
Scope
Final 1.0 contract shape:
astrid:*(fs,ipc,uplink,kv,net,http,sys,process,elicit,approval,identity,guest).file-handle,unix-listener,tcp-listener,tcp-stream,udp-socket,http-stream,subscription,process-handle. Drop is automatic; no manual close.error-codevariants replace stringly-typedresult<_, string>. Specific arms (not-found,capability-denied,would-block,boundary-escape,airlock-rejected, etc.) + catch-allunknown(string).principal-attributionvariant onipc-messagedistinguishes kernel-verified from uplink-claimed principals. Closes theipc-publish-asambient-spoofing vector.subscribe-readiness/subscribe-readable/subscribe-exit/subscribe-logsmethods returningwasi:io/poll.pollable. Bridge and fan-out capsules multiplex bus + sockets + HTTP + child processes viawasi:io/poll.poll.astrid:guest@1.0.0(interceptor/background/installable/upgradable). Capsulesincludeonly what they implement.Namespace split: capsule-to-capsule IPC event schemas moved to a distinct
astrid-bus:*namespace (interfaces/*.wit) to resolve collisions with the kernel ABI and codify the layering.Capsule.tomlimports split between[imports.astrid](host) and[imports.astrid-bus](bus contracts).WASI exposure narrowed. Only
wasi:io/poll+wasi:io/streamsretained in the kernel linker — these are CM foundation types backing the pollable composition story. Everything else from WASI (filesystem, sockets, clocks, random, cli) is no longer exposed: those operations would bypass Astrid's capability gates, principal scoping, and audit chain. Random / monotonic clock / sleep / wall-clock live underastrid:sysinstead.Pre-1.0 evolution discipline (the three coordinated rules from astrid#750):
astrid:netdoesn't recompile capsules that only importastrid:kv.(package, version)registered explicitly into the wasmtime CM linker, with host-side shims between versions.host/<name>@X.Y.Z.witis immutable aftermain. New shapes ship as new files at new versions. Enforced by CI lint.Scope boundary: governs the kernel↔capsule WIT contract. Capsule↔capsule lives on the IPC bus under
astrid-bus:*, governed separately by topic-versioning convention (*.v1.*→*.v2.*).Implementation status
The 1.0 WIT contract landed across 14 commits on unicity-astrid/wit#feat/per-domain-split:
astrid:guesttrigger-hook; addrandom-bytes/clock-monotonic-ns/sleep-nstoastrid:sysfs-append/copy/rename/remove-dir-all, UDP set, process stdin/env/cwd/signal,kv-casis-file/is-symlink/mode/mtime-ns/ctime-ns/atime-ns; HTTP body bytes; signed exit-code; option semanticswasi:io@0.2.0;validate-wit.shfor deps/-staged parsingastrid:*(host) /astrid-bus:*(bus)principal-attributionvariantget-interceptor-bindingsscope restrictiontcp-listener, hard-link creation,fs-mkdir/fs-mkdir-allsplit, UDP connected mode,SO_REUSEADDR, processos-pid/wait-with-output, http-stream pollableAll commits GPG-signed; CI's
wit-immutability+wit-parsesboth green.Deferred to 1.x (intentional, each a clean minor bump)
fs-read-link/fs-stat-symlinkretained.kv-cascovers in-runtime concurrency; cross-process locks aren't useful in a sandboxed runtime.outgoing-bodyresource design.async func) — 2.0 path; deferred so 1.0 stays sync.Audit basis
Pre-freeze contract validated against three external references (commits referenced in the wit PR):
std— everystd::fs/std::net/std::process/std::timeprimitive that wraps a syscall, mapped against the host ABI.wasi-filesystem,wasi-sockets,wasi-http,wasi-clocks,wasi-random,wasi-io,wasi-clisource.witfiles reviewed for naming / shape / resource conventions.Each surfaced finding either landed in the WIT or has a documented reason for being deferred (security risk, niche, needs design pass).
What changed in this RFC update sequence
unicity-astrid/wit(host/<name>@<version>.wit).astrid:guestfor lifecycle exports (wasastrid:hook, collided with the businterfaces/hook.wit); minimalastrid:coreproposal dropped; frozen-file path corrected tohost/<name>@<version>.wit.astrid:guestand its toolchain-stub motivation.wasi:ioretention and the rationale for not exposing the rest.astrid-bus:*namespace" subsection.error-codevariants.net_udpandnet_tcp_bind.astrid:capsule@0.1.0cleanup.caller-contextrecord onastrid:sys); support-window length (decision deferred until first post-1.0 minor bump).Replaces closed PR #18 which described the initial syscall set.
Tracking: astrid#573, astrid#750.