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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>`), `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<string>`: 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<string>`, 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<u64>` — 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.
Expand Down
46 changes: 41 additions & 5 deletions crates/astrid-capsule/src/engine/wasm/host/sys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
// Infallible self-introspection (the WIT returns a bare `list<string>`,
// 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.
Expand Down Expand Up @@ -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;

Expand Down
12 changes: 12 additions & 0 deletions crates/astrid-capsule/src/engine/wasm/host_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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
Expand Down
10 changes: 10 additions & 0 deletions crates/astrid-capsule/src/engine/wasm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/astrid-capsule/src/engine/wasm/test_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
152 changes: 152 additions & 0 deletions crates/astrid-capsule/src/manifest/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<String> = fields
.into_iter()
.filter(|(_, value)| Self::value_is_held(value))
.map(|(name, _)| name)
.collect();
names.sort_unstable();
names
}
Comment thread
joshuajbouw marked this conversation as resolved.

/// 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()]);
}
}
Loading
Loading