From 0f8d27ebd5f523589ead07616a38ae6ec18312e9 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Sun, 7 Jun 2026 03:30:33 +0400 Subject: [PATCH] feat(capsule): implement enumerate-capabilities, de-stub capability check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the wit submodule to pick up astrid:sys@1.0.0's new infallible enumerate-capabilities() -> list, and implements it on the kernel side. The host fn returns the CALLING capsule's own held capability NAMES — the categories declared in its [capabilities] manifest block (host_process, net_connect, fs_read, …), not the scoped arguments within them. Because the WIT is infallible (a bare list, no result), it reads an owned, lock-free snapshot taken once at load and stored on HostState rather than the capsule_registry — there is no registry-unavailable failure mode to surface, and an empty list is the valid no-capabilities answer. Capsule capabilities are fixed at load (the grant/revoke model is principal-scoped, a separate axis), so the snapshot is correct for the capsule's whole lifetime and across the pooled instances. check-capsule-capability is de-stubbed off the same namespace: it previously answered only allow_prompt_injection and returned false for every other capability. Both host fns now consult CapabilitiesDef, kept in lockstep by a single source of truth — held_names() (the list) and has(name) (the per-name dual), where n appears in held_names() iff has(n) is true. Unknown names fail closed. Both are ungated, read-only, and audit-not-recorded per the WIT contract: capability posture is structural metadata, not a secret (enforce-don't- conceal) — knowing a capability conveys no ability to use it. --- CHANGELOG.md | 1 + .../src/engine/wasm/host/sys.rs | 46 +++++- .../src/engine/wasm/host_state.rs | 12 ++ crates/astrid-capsule/src/engine/wasm/mod.rs | 10 ++ .../src/engine/wasm/test_fixtures.rs | 1 + .../src/manifest/capabilities.rs | 152 ++++++++++++++++++ .../wit-staging/deps/astrid-sys/sys@1.0.0.wit | 33 +++- crates/astrid-hooks/src/handler/wasm.rs | 4 + wit | 2 +- 9 files changed, 253 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3c3034d..1a7c1c227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ### Added - **`astrid:process@1.0.0` persistent-process tier — implemented.** 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 a process started in one tool invocation could not survive to the next — the split `spawn → read → stop` pattern was impossible. A new host-owned `PersistentProcessRegistry` — cloned into every pooled `HostState` exactly like the cancellation `ProcessTracker`, so a `process-id` survives instance churn — 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. **Implemented:** `spawn-persistent` (returns a 256-bit host-minted CSPRNG `process-id`, lowercase base32 so it doubles as an IPC topic suffix; the registry stores only a keyed BLAKE3 hash, never the raw token), `status` / `status-many` / `list-processes`, `read-logs` (drain) + `read-since` (non-draining, cursor-addressed, byte-faithful `list`), `signal` (incl. `stop`/`cont`), bounded `wait`, `stop` (SIGTERM→grace→SIGKILL, frees the slot), `release-process`, and `write-stdin` / `close-stdin` (via `keep-stdin-open` capture). Every id-keyed call re-resolves the live `(principal, capsule)` and checks it against the recorded creator, so a leaked id is inert across the principal/capsule boundary — unknown / wrong-owner / wrong-capsule / reaped all collapse to `no-such-process` with no oracle; `spawn-persistent` refuses the owner-fallback principal (`persist-unsupported`) so tenants never share a `default` namespace. Lifecycle is enforced by a per-capsule reaper task: per-principal concurrent + retained-id caps, idle / max-lifetime / exit-retention TTLs (guest values clamped DOWN to host ceilings), and a kill-all on capsule unload / daemon graceful shutdown. Works on Linux and macOS (the macOS caveat — a daemon *hard crash*, not a graceful shutdown, can orphan a still-sandboxed child because Seatbelt has no `die-with-parent` — is a weaker cleanup guarantee, not a containment gap). **Still deferred, honestly:** `attach` (the resource-handle composition sugar; the id-keyed ops are its documented `attach(id)?.method()` equivalent), `watch` / `unwatch` (host-published lifecycle events — an OPEN publish-authority question in RFC host_abi, with `status` + bounded `wait` polling as the working alternative), and the WIT's own `(NOT YET …)` items (resource-limit enforcement, `cpu-ms` / `mem-bytes-peak`, instance-local pollables). Contract: unicity-astrid/wit#12. Design: unicity-astrid/rfcs#22. Closes #866. +- **`astrid:sys@1.0.0` capability introspection — `enumerate-capabilities` implemented, `check-capsule-capability` completed.** A capsule can now read its OWN held capability names via the new infallible `enumerate-capabilities() -> list`: the capability categories declared in its `[capabilities]` manifest block (`host_process`, `net_connect`, `fs_read`, …) — the names, not the scoped arguments within them (allowlists, `host:port`, paths). This lets a reusable supervisor binary deployed under different manifests ground its behaviour in what it can actually do instead of hard-coding it, and lets any capsule avoid code-vs-manifest drift. Because the WIT is infallible (a bare `list`, no `result`), it reads an owned, lock-free snapshot taken once at load (`CapabilitiesDef::held_names`) and stored on `HostState` rather than the `capsule_registry` — there is no `registry-unavailable` failure mode to surface, and an empty list is the valid "no capabilities" answer; capsule capabilities are fixed at load (the grant/revoke model is principal-scoped, a separate axis), so the snapshot is correct for the capsule's whole lifetime and across pooled instances. In the same change, `check-capsule-capability` — previously a stub that answered only `allow_prompt_injection` and returned `false` for every other capability — is completed onto the same namespace: both host fns (`held_names()` the list, `has(name)` the per-name dual) are DERIVED from the struct's serialized fields rather than a hand-maintained list, so a capability added to `CapabilitiesDef` flows through both automatically and the two cannot drift — `n` appears in `held_names()` iff `has(n)`, and unknown names fail closed. Both are ungated, read-only, and audit-not-recorded: capability posture is structural metadata, not a secret (enforce-don't-conceal) — knowing a capability conveys no ability to use it. Lifecycle hooks and the `astrid-hooks` host, which run outside the capsule manifest/security-gate lifecycle, report an empty set (fail-closed). Contract: unicity-astrid/wit#13. Closes #868. - **Per-principal peak memory in usage reporting.** `ResourceUsage` gains `memory_bytes_peak_total: Option` — the cross-capsule high-water linear memory a principal has driven (max across every capsule it invokes), read from the shared memory ledger and surfaced by the admin `UsageGet`, `GET /api/sys/principals/{id}/usage` (a new field on the OpenAPI `ResourceUsageView`), and `astrid quota show` (a new "memory peak" row). This fills the memory side of per-principal usage, which previously reported only the per-instance ceiling. Under pooled, shared Stores a live "current" total is not cleanly attributable, so the **peak** is the reported signal — the principal that grows a Store owns the peak; `memory_bytes_current_total` stays `None`. Refs #816. - **Operator overrides for capsule runtime sizing (config + env + CLI).** New `[capsule]` config section with `host_blocking_concurrency`, `host_io_concurrency`, and `instance_pool_size` (all optional; unset → the host-derived default), the matching `ASTRID_CAPSULE_HOST_BLOCKING_CONCURRENCY` / `ASTRID_CAPSULE_HOST_IO_CONCURRENCY` / `ASTRID_CAPSULE_INSTANCE_POOL_SIZE` env vars, and `astrid-daemon --host-blocking-concurrency` / `--host-io-concurrency` / `--instance-pool-size` flags. Precedence is CLI flag > config file > env > host-derived default; the daemon resolves the values once at boot and the kernel forwards them, unmodified, to every capsule's `WasmEngine` (the same handle-plumbing shape as `FuelLedger`). A zero override is rejected at config-validation time (it would wedge a host-call class or leave a capsule with no instance to lease, rather than throttle). Refs #816. diff --git a/crates/astrid-capsule/src/engine/wasm/host/sys.rs b/crates/astrid-capsule/src/engine/wasm/host/sys.rs index 19e8f72e9..d85f3aa11 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/sys.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/sys.rs @@ -204,14 +204,26 @@ impl sys::Host for HostState { let Some(capsule) = reg.get(capsule_id) else { return false; }; - match request.capability.as_str() { - "allow_prompt_injection" => capsule.manifest().capabilities.allow_prompt_injection, - _ => false, - } + // The full capability namespace, not just `allow_prompt_injection`: + // `CapabilitiesDef::has` is the per-name dual of the snapshot + // `enumerate-capabilities` returns, so the two host fns agree on + // what "held" means. Unknown names fail closed inside `has`. + capsule.manifest().capabilities.has(&request.capability) }); Ok(CapabilityCheckResponse { allowed }) } + + fn enumerate_capabilities(&mut self) -> Vec { + // Infallible self-introspection (the WIT returns a bare `list`, + // no `result`). The held-capability snapshot is taken once at load + // (`CapabilitiesDef::held_names`) and stored on `HostState`, so this + // never consults `capsule_registry` — there is no `registry- + // unavailable` failure mode to surface, and an empty list is the valid + // "no capabilities" answer. The set is the list dual of a self + // `check-capsule-capability`. + self.capability_names.clone() + } } /// Resolve a secret-typed env value through the file-per-secret store. @@ -358,11 +370,35 @@ mod log_chain_tests { } } +#[cfg(test)] +mod capability_introspection_tests { + use crate::engine::wasm::bindings::astrid::sys::host::Host as SysHost; + use crate::engine::wasm::test_fixtures::minimal_host_state; + + /// `enumerate-capabilities` returns the load-time snapshot and never + /// fails: the empty default is the valid "no capabilities" answer, and a + /// populated snapshot (what `make_state` stores from + /// `CapabilitiesDef::held_names`) round-trips verbatim and in order. + #[tokio::test] + async fn enumerate_returns_load_time_snapshot() { + let mut state = minimal_host_state(tokio::runtime::Handle::current()); + assert!( + state.enumerate_capabilities().is_empty(), + "fail-closed default holds nothing" + ); + + state.capability_names = vec!["host_process".to_string(), "net_connect".to_string()]; + assert_eq!( + state.enumerate_capabilities(), + vec!["host_process".to_string(), "net_connect".to_string()], + ); + } +} + #[cfg(test)] mod get_config_tests { use std::collections::HashMap; - use super::*; use crate::engine::wasm::bindings::astrid::sys::host::Host as SysHost; use crate::engine::wasm::test_fixtures::minimal_host_state; diff --git a/crates/astrid-capsule/src/engine/wasm/host_state.rs b/crates/astrid-capsule/src/engine/wasm/host_state.rs index 9cf88be96..26b287a79 100644 --- a/crates/astrid-capsule/src/engine/wasm/host_state.rs +++ b/crates/astrid-capsule/src/engine/wasm/host_state.rs @@ -297,6 +297,18 @@ pub struct HostState { /// Used to gate `astrid_register_uplink` — only uplink plugins /// are allowed to register uplinks. pub has_uplink_capability: bool, + /// The calling capsule's OWN held capability NAMES, precomputed at load + /// from `manifest.capabilities` via + /// [`CapabilitiesDef::held_names`](crate::manifest::CapabilitiesDef::held_names). + /// + /// Backs `astrid:sys/host.enumerate-capabilities`, which is **infallible** + /// — so it reads this owned, lock-free snapshot rather than the + /// `capsule_registry` (whose lookup can fail with `registry-unavailable`, + /// the failure mode `check-capsule-capability` carries but `enumerate` + /// must not). Capsule capabilities are fixed at load (the grant/revoke + /// model is principal-scoped, a separate axis), so a snapshot taken once + /// is correct for the capsule's whole lifetime and across the pool. + pub capability_names: Vec, /// Whether this capsule's OWNER principal holds `audit:read_all`, /// resolved at LOAD the PRIVILEGED way (against the profile cache + /// live group config — **not** the manifest, unlike diff --git a/crates/astrid-capsule/src/engine/wasm/mod.rs b/crates/astrid-capsule/src/engine/wasm/mod.rs index cb54d7dba..5f56231a4 100644 --- a/crates/astrid-capsule/src/engine/wasm/mod.rs +++ b/crates/astrid-capsule/src/engine/wasm/mod.rs @@ -1175,6 +1175,10 @@ impl ExecutionEngine for WasmEngine { }; // `[capabilities].uplink` bit (binds a socket), gating ipc-publish-as. let has_uplink = manifest.capabilities.uplink; + // Snapshot of the capsule's held capability names, fixed at load — + // backs the infallible `enumerate-capabilities` host fn. Cloned per + // pooled instance inside the `make_state` closure below. + let capability_names = manifest.capabilities.held_names(); // One IPC rate limiter shared by every pooled instance, so the // per-capsule throughput budget is not multiplied by pool size. let ipc_limiter = Arc::new(astrid_events::ipc::IpcRateLimiter::new()); @@ -1343,6 +1347,7 @@ impl ExecutionEngine for WasmEngine { capsule_registry: st_capsule_registry.clone(), runtime_handle: tokio::runtime::Handle::current(), has_uplink_capability: has_uplink, + capability_names: capability_names.clone(), audit_firehose, inbound_tx: tx.clone(), registered_uplinks: Vec::new(), @@ -2354,6 +2359,11 @@ pub async fn run_lifecycle( capsule_registry: None, runtime_handle: tokio::runtime::Handle::current(), has_uplink_capability: false, + // Lifecycle hooks run a restricted, short-lived context and the + // manifest capabilities are not plumbed into `LifecycleConfig`; + // capability introspection is not exposed here (matches the + // hard-coded `has_uplink_capability: false` above). Fail-closed. + capability_names: Vec::new(), // Lifecycle hooks never subscribe to the audit feed; fail-secure. audit_firehose: false, inbound_tx: None, diff --git a/crates/astrid-capsule/src/engine/wasm/test_fixtures.rs b/crates/astrid-capsule/src/engine/wasm/test_fixtures.rs index c929313b5..7139636f1 100644 --- a/crates/astrid-capsule/src/engine/wasm/test_fixtures.rs +++ b/crates/astrid-capsule/src/engine/wasm/test_fixtures.rs @@ -111,6 +111,7 @@ pub(crate) fn minimal_host_state(rt: tokio::runtime::Handle) -> HostState { capsule_registry: None, runtime_handle: rt.clone(), has_uplink_capability: false, + capability_names: Vec::new(), audit_firehose: false, inbound_tx: None, registered_uplinks: Vec::new(), diff --git a/crates/astrid-capsule/src/manifest/capabilities.rs b/crates/astrid-capsule/src/manifest/capabilities.rs index 6a8e0d3e3..0c90a61f5 100644 --- a/crates/astrid-capsule/src/manifest/capabilities.rs +++ b/crates/astrid-capsule/src/manifest/capabilities.rs @@ -67,3 +67,155 @@ pub struct CapabilitiesDef { #[serde(default)] pub allow_prompt_injection: bool, } + +impl CapabilitiesDef { + /// Whether a serialized capability field counts as HELD: a non-empty + /// allowlist (`Vec` → JSON array) or an enabled flag (`bool` → JSON + /// `true`). Any other JSON shape is fail-closed (`false`) — a future + /// capability field whose "held" meaning is neither of those two must opt + /// in here deliberately rather than be silently reported. + fn value_is_held(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Bool(enabled) => *enabled, + serde_json::Value::Array(allowlist) => !allowlist.is_empty(), + _ => false, + } + } + + /// The capability NAMES this capsule declared in its `[capabilities]` + /// manifest block (`host_process`, `net_connect`, `fs_read`, …) — the + /// capability categories, NOT the scoped arguments within them + /// (allowlists, `host:port`, paths). + /// + /// DERIVED from the struct itself, not a hand-maintained list: every field + /// IS a capability, so the names are the struct's serialized field names + /// (which are exactly the manifest TOML keys — no `#[serde(rename)]`), + /// filtered to the ones that are held (a non-empty allowlist or an enabled + /// flag). Adding a field to `CapabilitiesDef` therefore flows through + /// `held_names` AND [`has`](Self::has) automatically — there is no parallel + /// list to drift from the struct, which is the very code-vs-manifest drift + /// this introspection surface exists to prevent. Returned sorted, so the + /// order is deterministic and independent of serde's map ordering. + /// + /// Backs `astrid:sys/host.enumerate-capabilities`; `n` appears here iff + /// [`has(n)`](Self::has) is true. + pub fn held_names(&self) -> Vec { + let serde_json::Value::Object(fields) = + serde_json::to_value(self).unwrap_or(serde_json::Value::Null) + else { + return Vec::new(); + }; + let mut names: Vec = fields + .into_iter() + .filter(|(_, value)| Self::value_is_held(value)) + .map(|(name, _)| name) + .collect(); + names.sort_unstable(); + names + } + + /// Whether this capsule holds the named capability — the per-name dual of + /// [`held_names`](Self::held_names), derived from the same serialized form + /// so the two cannot disagree. `has(n)` is true exactly when `n` is in + /// `held_names()`. Unknown names are fail-closed (`false`), so this backs + /// `astrid:sys/host.check-capsule-capability` directly. + pub fn has(&self, name: &str) -> bool { + let serde_json::Value::Object(fields) = + serde_json::to_value(self).unwrap_or(serde_json::Value::Null) + else { + return false; + }; + fields.get(name).is_some_and(Self::value_is_held) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fully-populated set: every list non-empty, every bool true. Every + /// name must be reported by `held_names` AND answer true to `has`. + #[test] + fn held_names_and_has_agree_when_all_held() { + let caps = CapabilitiesDef { + uplink: true, + net: vec!["example.com".into()], + kv: vec!["scope".into()], + fs_read: vec!["/r".into()], + fs_write: vec!["/w".into()], + host_process: vec!["bash".into()], + net_bind: vec!["127.0.0.1:0".into()], + net_connect: vec!["host:443".into()], + identity: vec!["resolve".into()], + allow_prompt_injection: true, + }; + let names = caps.held_names(); + let expected = [ + "allow_prompt_injection", + "fs_read", + "fs_write", + "host_process", + "identity", + "kv", + "net", + "net_bind", + "net_connect", + "uplink", + ]; + assert_eq!( + names, expected, + "deterministic, sorted order — all 10 fields" + ); + for n in expected { + assert!(caps.has(n), "has({n}) must agree with held_names"); + } + + // Derivation guard: with every field held, `held_names` must report + // EVERY serialized field — not a hand-picked subset. A capability + // added to `CapabilitiesDef` is then surfaced without editing this + // module (and if its JSON shape is not bool/array, `value_is_held` + // fails this on purpose, forcing a deliberate decision). + let serde_json::Value::Object(fields) = serde_json::to_value(&caps).unwrap() else { + panic!("CapabilitiesDef serializes to a JSON object"); + }; + assert_eq!( + names.len(), + fields.len(), + "held_names must cover every serialized capability field" + ); + } + + /// The default (fail-closed) set holds nothing. + #[test] + fn default_holds_nothing() { + let caps = CapabilitiesDef::default(); + assert!(caps.held_names().is_empty()); + for n in [ + "uplink", + "net", + "kv", + "fs_read", + "fs_write", + "host_process", + "net_bind", + "net_connect", + "identity", + "allow_prompt_injection", + ] { + assert!(!caps.has(n), "empty set must not report {n}"); + } + } + + /// Unknown capability names are fail-closed. + #[test] + fn unknown_name_is_false() { + let caps = CapabilitiesDef { + host_process: vec!["bash".into()], + ..Default::default() + }; + assert!(!caps.has("not_a_capability")); + assert!(!caps.has("")); + assert!(caps.has("host_process")); + assert_eq!(caps.held_names(), vec!["host_process".to_string()]); + } +} diff --git a/crates/astrid-capsule/wit-staging/deps/astrid-sys/sys@1.0.0.wit b/crates/astrid-capsule/wit-staging/deps/astrid-sys/sys@1.0.0.wit index af39e065b..138653e18 100644 --- a/crates/astrid-capsule/wit-staging/deps/astrid-sys/sys@1.0.0.wit +++ b/crates/astrid-capsule/wit-staging/deps/astrid-sys/sys@1.0.0.wit @@ -133,10 +133,39 @@ interface host { /// Audit: not recorded (read-only, no side effects). random-bytes: func(length: u64) -> result, error-code>; - /// Check whether a capsule has a specific manifest capability. + /// Check whether a capsule has a specific manifest capability — self or + /// any other capsule (by UUID). The per-name dual of + /// `enumerate-capabilities`. /// /// Fail-closed: returns `allowed: false` for unknown UUIDs and /// unknown capabilities. Returns `registry-unavailable` when the - /// registry itself can't be consulted. + /// registry itself can't be consulted. Ungated: capability posture is + /// structural metadata, not a secret (enforce-don't-conceal) — knowing a + /// capability conveys no ability to use it. Audit: not recorded. check-capsule-capability: func(request: capability-check-request) -> result; + + /// Enumerate the CALLING capsule's OWN held capability NAMES — the + /// categories the kernel enforces at host-call time (e.g. `host_process`, + /// `net_connect`, `fs_read`), NOT the scoped arguments within them (a + /// `host_process` allowlist, `host:port`, paths). It is the list dual of + /// `check-capsule-capability`: the set of names for which a self-`check` + /// returns true. + /// + /// Argument-free — the kernel already knows the caller. The set is the + /// capsule's manifest-declared capabilities as the kernel registered them; + /// capsule capabilities are not revoked at runtime (the grant/revoke model + /// is principal-scoped, a separate axis), so "what I declared" and "what I + /// can do" coincide at the capsule level. + /// + /// Ungated, like `check-capsule-capability`: a capsule grounds its + /// behaviour in what it can actually do — a reusable supervisor binary + /// deployed under different manifests reads its real set instead of + /// hard-coding it; any capsule avoids code-vs-manifest drift by grounding + /// on the registered set. Audit: not recorded (read-only self- + /// introspection). See RFC: host_abi ("Capability introspection"). + /// + /// Infallible: the kernel always knows the caller's own registered set, so + /// there is no failure mode (an empty list is the valid "no capabilities" + /// answer) — like `clock-ms` / `clock-monotonic-ns`, no `result` wrapper. + enumerate-capabilities: func() -> list; } diff --git a/crates/astrid-hooks/src/handler/wasm.rs b/crates/astrid-hooks/src/handler/wasm.rs index 9174f59a3..8fdea8b4c 100644 --- a/crates/astrid-hooks/src/handler/wasm.rs +++ b/crates/astrid-hooks/src/handler/wasm.rs @@ -340,6 +340,10 @@ impl WasmHandler { capsule_registry: None, runtime_handle: tokio::runtime::Handle::current(), has_uplink_capability: false, + // Hooks run outside the capsule manifest/security-gate lifecycle and + // hold no manifest capabilities, so capability introspection reports + // an empty set (fail-closed, like `has_uplink_capability` above). + capability_names: Vec::new(), // Transient hook execution never subscribes to the audit feed; // fail-secure to scoped. audit_firehose: false, diff --git a/wit b/wit index 8946e98d1..9742f80e8 160000 --- a/wit +++ b/wit @@ -1 +1 @@ -Subproject commit 8946e98d149cbd320636138c26a1333f3b16fb66 +Subproject commit 9742f80e83abb92e9ae954ef82664bdd2fcb89cd