diff --git a/CHANGELOG.md b/CHANGELOG.md index bf12d2da1..cd2f758b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ### Added +- **`astrid capsule approve ` activates an installed-but-inert capsule.** A manually-installed capsule's declared capabilities are inert until an operator approves them (see the Security entry below); this verb shows the declared capability surface (fs / net / host_process / identity / uplink / ipc), records the approval for the install principal at the capsule's current capability fingerprint, and best-effort nudges a running daemon to hot-reload so the now-approved capabilities take effect without a restart. The verb is added to `RESERVED_CAPSULE_VERBS` so a capsule cannot shadow it. Refs #995. - **`DevicePubkey` now exposes the same additive string/serde trait surface as the adjacent device-key newtypes.** The public typed wrapper for canonical ed25519 device public keys now supports `TryFrom`, `FromStr`, generic `PartialEq` comparisons, and serde serialization/deserialization while preserving the existing string-shaped profile and management API structs. Closes #1056. - **Registered and implemented `astrid:http@1.1.0` host-side — the per-request control surface for the HTTP client — alongside the frozen `@1.0.0`.** Both versions are now registered in the wasmtime linker and backed by ONE shared implementation, so existing capsules are untouched and new ones opt in package-by-package. A capsule importing `@1.1.0` gets caller-set controls via an all-optional `request-options` record (an empty value reproduces `@1.0.0` exactly): four-phase timeouts (`connect` / `first-byte` / `between-bytes` / `total`), redirect policy (`follow` / `error` / `manual`) with mandatory per-hop SSRF re-validation, response and decompressed-body size caps, an `auto-decompress` toggle, `https-only` scheme enforcement, and subresource integrity (`sha256` / `sha384` / `sha512`); plus `response-meta` (final URL after redirects, redirect count, elapsed ms, wire bytes) on every buffered response. The `@1.0.0` `http-request` / `http-stream-start` are thin shims that reproduce prior behaviour exactly (follow ≤10 redirects, 30 s timeout, 10 MB cap) and delegate to the same backend. **Security:** redirects on BOTH the buffered and streaming paths are now followed MANUALLY by the host, so every hop re-runs the full airlock — scheme check, the async per-capsule allow-list gate, and the egress airlock — with `Authorization` / `Cookie` stripped on a cross-origin hop and IP-literal targets blocked. This closes a redirect-SSRF gap on the streaming path where the per-capsule gate previously ran only on the initial URL (the IP airlock already ran per hop). `astrid:http` is the first host package to ship two versions, so this also threads the multi-version WIT staging (`build.rs` keys each staged `deps/` dir by `name@version`) and the dual-trait registration. Deferred to follow-ups (honest stubs, not silent gaps): the `http-upload` streaming request body, response `trailers` extraction, and un-stubbing the stream pollable / `body-stream`. No `@1.0.0` shape change — it is frozen and untouched. Closes #1049. Part of #1012. - **Operators can now tune the `astrid:http` host's seven previously-hardcoded limits via a new `[http]` config section, defaulting to today's exact constants so an absent section changes nothing.** The host's outbound HTTP ceilings — buffered-path default total timeout (`default_timeout_secs`, 30), streaming connect timeout (`stream_connect_timeout_secs`, 30), per-chunk streaming read timeout (`stream_read_timeout_secs`, 120), time-to-first-byte header-deadline floor (`header_deadline_secs`, 120), max redirect hops (`max_redirects`, 10), per-capsule concurrent-stream cap (`max_concurrent_streams`, 4), and the buffered-body default/ceiling (`max_response_bytes`, 10 MiB) — were fixed module constants. Each is now an operator knob resolved once by the daemon from `[http]` into a typed `HttpLimits` value (seconds pre-converted to `Duration` off the request hot path), stored on the kernel, and snapshotted onto every pooled `HostState` at load — a **global** value, the same for every capsule (it threads through the `WasmEngine`/loader like the runtime concurrency limits, not per-capsule like the local-egress allowlist). The request path reads its defaults and ceilings off `HostState.http_limits` instead of the constants: a per-request `request-options` value may still **tighten** a limit (a shorter timeout, fewer redirects, a smaller body cap) but a configured ceiling can only *lower* the bound, never let a caller exceed it — and `max_response_bytes` is additionally hard-clamped to the shared absolute `MAX_GUEST_PAYLOAD_LEN` host payload limit, so config can lower but never raise it above the host hard cap. **Operator config only:** like `[security.capsule_local_egress]`, a project/workspace config layer cannot set or widen `[http]` (`merge::restrict` reverts the whole section to the operator baseline as a unit; regression-tested), so untrusted project config can never relax the host's HTTP limits. New unit + config-merge + host tests cover the defaults reproducing the constants, partial-section parsing, the operator-only merge guard, `config show` surfacing the section, and that a non-default config actually changes behaviour (`max_concurrent_streams = 2` rejects the 3rd stream with `Quota`, `default_timeout_secs = 5` drives the resolved default total, `max_redirects = 3` clamps a larger caller value). No WIT / host-ABI change — kernel-internal threading + config only. @@ -27,6 +28,7 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ### Security +- **A capsule's manifest-declared capabilities are now inert at load until an operator approves them, distinguishing a manually-installed capsule from a distro/admin/furniture/pre-existing one (#995).** Previously a capsule's `[capabilities]` block (untrusted author input) became effective at load with no operator approval and no install-source distinction: a capsule dropped on disk could name an arbitrary egress host or a broad write scope and the security gate honoured it verbatim. The fix is a fail-secure install-time approval gate. **(1) Fingerprint.** A stable BLAKE3 hex `CapabilityFingerprint` (a serde-transparent newtype) is computed over the canonical security-relevant declared set — the full `CapabilitiesDef` plus the sorted effective IPC publish/subscribe patterns — so a re-install that escalates ANY declared capability or IPC pattern changes the fingerprint and requires fresh approval. **(2) Store.** Approvals live per principal at `/.config/approvals/.json` (atomic temp+rename), operator-owned and capsule-unreachable: under `.config/` (NOT the copyable, guest-reachable capsule dir), so a furniture-copy of a capsule dir does not carry approval and a runtime capsule cannot write its own. `is_approved` is true only when a record exists AND its fingerprint matches exactly; a missing, unreadable, unparseable, or mismatched record is fail-secure (unapproved). **(3) Load consult (the enforcement).** Immediately before constructing the `ManifestSecurityGate`, the engine consults the store for the load-time owner principal at the current fingerprint; if not approved it zeroes `manifest.capabilities` to the empty `CapabilitiesDef::default` IN PLACE so every downstream derivation (the gate, `capability_names`, `has_uplink`, the net_bind socket listener) sees zero capabilities — the capsule loads INERT (present and discoverable, no host-fn access) and an audit-tagged warning is emitted. The decision is extracted as a pure `effective_capabilities(declared, approved)` helper so it is unit-tested without a daemon. A home that cannot be resolved is treated as unapproved (inert), never approved. **(4) Auto vs gated writes.** Manual interactive `astrid capsule install` displays the declared capabilities and prompts; a decline leaves the capsule inert and prints how to approve later. Batch/non-interactive installs (`astrid init`, offline distro install) auto-approve. The kernel-side admin/gateway install handler auto-approves for the install principal (the admin path is trusted). Furniture seeding (`materialize_principal_furniture`) auto-approves each blessed capsule for the target principal after mirroring (the approval store is NOT mirrored, so this is required). **(5) Grandfather migration.** On the first `load_all_capsules` after upgrade, a one-time sweep (guarded by an `etc/.capability-approvals-migrated` marker, outside the `home://` VFS) approves every already-installed capsule for its principal at its current fingerprint, so existing setups are not bricked; after migration, "no approval record" genuinely means unapproved. **Defense in depth:** the `ManifestSecurityGate` now hard-denies any guest read/write that resolves inside `.config/approvals/`, regardless of the manifest allowlist, so an approved capsule with a broad `home://` write scope still cannot forge or escalate an approval. Only `capabilities` is zeroed for an inert capsule; gating its IPC patterns is a documented v1 limitation (follow-up). **Uninstall hygiene:** `astrid capsule remove` now clears the approval record so a removed capsule's trust cannot silently carry over to a different capsule later reinstalled under the same id; and `astrid capsule approve ` validates its argument as a `CapsuleId` (lowercase alphanumeric + hyphens) before it is used as a path component, so a traversal name cannot write a record outside the approvals directory. The operator approval prompt discloses declared `kv` scopes alongside fs/net/host_process/identity/uplink/ipc. No WIT / host-ABI change — kernel-internal load consult + per-principal on-disk store + CLI verb. Refs #995. - **Made capsule-session's per-principal KV isolation an explicit, tested invariant (closes #977).** `capsule-session` keys its store as the principal-less `session.data.{id}`, so cross-principal isolation comes entirely from the host KV namespace (`{principal}:capsule:{capsule_id}`), not from the capsule's keys — correct today, but previously implicit. `HostState::effective_kv` now documents this contract and the exact boundary of its owner-store fallback (correct only when no caller is in scope or the caller is the owner), and new regression tests pin it: two principals writing the identical `session.data.default` key resolve to different namespaces and never collide, the accessor prefers the per-invocation store and falls back only as specified, and the absent-principal in-scope case falls back to the owner store (the documented edge that holds safe because every producer of a principal-scoped topic stamps an authenticated principal). The `debug_assert_invocation_field_set` doc now records why a host-wide fail-closed assert on the absent-principal case is intentionally NOT added: principal-less system/lifecycle handlers (capsule-react's `astrid.v1.watchdog.tick`, capsule-registry's `astrid.v1.capsules_loaded`) legitimately fall back to the owner/global store, so such an assert would fire on sound paths. No WIT / host-ABI change — docs + tests only. Closes #977. - **A capsule can now reach a loopback/private LLM endpoint (LM Studio on `127.0.0.1:1234`, Ollama, a LAN IP) through operator consent instead of a hand-edited `config.toml`, without weakening the SSRF airlock (closes #1028).** Two composed mechanisms ship together. **(A) A host-stamped, capsule-unforgeable transport-origin marker plus runtime elicitation.** A new host-only `origin: MessageOrigin { System | LocalSocket | RemoteGateway }` field on the kernel-internal `IpcMessage` records where a request entered the system — stamped exactly like `device_key_id` (host-derived internal-bus metadata, **no WIT change**; the guest-facing `record ipc-message` is unchanged and `to_wit_message()` keeps dropping it, so it stays invisible to capsules). The kernel stamps `LocalSocket` only for a handshake-bound Unix-socket connection (an unbound/unauthenticated local connection stays `System` — fail-closed, non-local — parallel to the `anonymous` principal), `RemoteGateway` at the gateway bus-publish sites, and propagates the *originating* request's origin through guest publish/fan-out and `publish-as` — so a `RemoteGateway` request flowing through a fan-out capsule (react → openai-compat) **cannot be elevated to `LocalSocket`**. Absent/unknown/legacy/CLI-proxy-wire frames decode to `System` via `#[serde(default)]` + `#[serde(other)]`. At the airlock-rejected egress arm for an IP-literal local endpoint, `astrid:http` now consults transport origin: a `LocalSocket` request that is not already pre-blessed/granted elicits one-shot operator consent via the existing `request_approval` primitive (`ApprovalRequired` on `astrid.v1.approval`, resource = `host:port` only); on approve/approve_session it caches a **per-principal, per-capsule** session grant (`AllowanceStore`; the grant carries the requesting `capsule_id`, so neither principal A's grant exempts principal B nor capsule A's grant exempts capsule B reaching the same `host:port`), and on approve_always it also persists to that principal's capsule-keyed `profile.network.capsule_egress[]` map on disk (a flat per-principal list would have let a remembered grant for one capsule silently exempt every other capsule that principal runs). Because the in-memory `AllowanceStore` starts empty on every daemon boot, the egress fast path **also consults that persisted `capsule_egress` map for the effective principal and current capsule** before eliciting — so an `approve_always` grant actually survives a restart (the "remember across restarts" contract) instead of being silently forgotten and re-prompted. That persisted consult preserves both isolation axes (it reads only this principal's profile and only this capsule's bucket, so a remembered grant for capsule A / principal X never permits capsule B / principal Y) and is reached only after the upstream origin gate and SSRF airlock, so it short-circuits the elicitation, never the origin/airlock checks. Any non-`LocalSocket` origin (`System`/`RemoteGateway`/unbound) **never elicits and fails closed**, so a remote gateway caller driving the same react/openai-compat egress path can neither see nor answer a local-egress prompt. A consent-granted endpoint re-enters the exempt path, refusing redirects identically to a pre-blessed one. The consent round-trip reuses the shared `astrid.v1.approval[.response.*]` channel rather than a distinct egress namespace — a documented, low-severity residual (only the trusted operator uplink and MCP broker hold publish rights on that response topic; an arbitrary untrusted capsule cannot), deferred because a distinct namespace would require an out-of-repo operator-surface publish-ACL change in lockstep. V1 covers IP-literal local endpoints (`127.0.0.1`, `[::1]`, `192.168.x`, `10.x`, `169.254.x`); hostname endpoints (`localhost:1234`) still require a pre-bless. **(B) A CLI guided pre-bless.** `astrid init` / `astrid capsule config` / capsule install now detect when an entered provider `base_url` points at a loopback/private/link-local address and prompt the operator (`[y/N]`) to add the `[security.capsule_local_egress]` exemption keyed by capsule id — the operator is unambiguously local *by construction* in the CLI process, so this is guided pre-bless, not runtime elicitation. Free-text/non-resolving hosts are skipped. No WIT / host-ABI change — the marker is host-only, threaded through kernel-internal IPC, gateway routes, and CLI config. New unit + regression tests cover the origin serde floor, bound/unbound socket stamping, fan-out origin propagation (no elevation), pool-reset clearing, the per-principal/per-capsule consent fast path / elicit / approve-always disk persist, per-principal AND per-capsule grant isolation (a `react` grant does not exempt `openai-compat` reaching the same endpoint — in-memory session grant AND persisted `capsule_egress`), the persisted-grant fast path surviving an empty store on a fresh daemon while still eliciting for the wrong capsule or wrong principal, the fail-closed non-local paths, `is_local_address` detection, and the operator-config write. **Hardening:** the global SSRF escape hatch is no longer a bypass of the new consent gate. `ASTRID_TEST_ALLOW_LOCAL_IP` is now **gated behind `cfg(test)`** — the daemon's release binary never even reads it, so a stray test env var in production can no longer disable the airlock (and with it the consent gate) for every capsule. `ASTRID_ALLOW_LOCAL_IPS` stays recognized in production for operators/CI but is now **deprecated**, emitting a one-time warning that is honest about its blast radius (it disables the airlock for ALL capsules AND exposes loopback/private endpoints to REMOTE `RemoteGateway` API callers, bypassing the per-capsule consent gate) and directing operators to the per-capsule `[security.capsule_local_egress]` allowlist; it will be removed in a future release. A new end-to-end regression test locks the anti-forge propagation invariant: a `RemoteGateway`-stamped request driven through the real fan-out hop chain (caller_context origin → `publish` → `publish_inner` → republished bus message → downstream caller_context) is observed AS `RemoteGateway` at the egress site and `consent_local_egress` DECLINES — it can never be reset to `System`/`LocalSocket` across a republish. **Revocation caveat (documented, no behaviour change):** neither exemption source is revoked live — the operator `[security.capsule_local_egress]` allowlist is a load-time snapshot and a persisted `approve_always` grant lives in a process-lifetime profile cache with no TTL/watcher — so removing an entry or grant only takes effect after a **daemon restart**. This caveat is now stated at the consent gate (module doc) and printed by the CLI add-path so an operator editing config to revoke is not falsely reassured (a live-revocation verb is tracked separately). Closes #1028. Relates to #961, #1026; references the airlock work #965. - **The host `elicit()` waiter now enforces that an answer comes from the same principal the input is being collected for, closing a cross-principal elicit hijack.** A capsule's lifecycle `elicit()` mints a `request_id` and the prompt SSE stream forwards it verbatim to every connected client, so any authenticated caller who observed an in-flight `request_id` could publish an `ElicitResponse` on the derived reply topic and answer — or cancel — another principal's elicit (now also reachable from the new `POST /api/agent/elicit-response` endpoint). The waiter previously keyed only on the reply-topic suffix and the payload variant, never on who sent the reply. It now stamps the originating principal on the outgoing `ElicitRequest` and only unblocks on a reply carrying a matching principal; a mismatched (or unstamped) reply is logged and rejected, and the wait continues on its original deadline so a flood of forged replies cannot extend or DoS the legitimate one. The in-process CLI install answerer now echoes the request's principal back on its reply so interactive `astrid capsule install` onboarding keeps working; socket-driven answerers (TUI, chat) already carry the host-verified principal via the uplink's `publish-as` self-stamp. diff --git a/crates/astrid-capsule-install/src/furniture.rs b/crates/astrid-capsule-install/src/furniture.rs index 0666efb28..aa33ca220 100644 --- a/crates/astrid-capsule-install/src/furniture.rs +++ b/crates/astrid-capsule-install/src/furniture.rs @@ -65,9 +65,73 @@ pub fn materialize_principal_furniture( mirror_subtree(&src.root().join("wit"), &dst.root().join("wit")) .context("failed to mirror wit/ into principal home")?; + // Furniture = the blessed introspection set. The mirror copies the capsule + // DIRECTORY but never the approval store (which lives under `.config/`, the + // hard-excluded secret boundary), so a freshly-seeded principal would see + // every furniture capsule as unapproved → inert (#995). Auto-approve each + // seeded capsule for the target principal at its current fingerprint, so a + // new principal's introspection tools work exactly as the install + // principal's do. Best-effort per capsule: a single unreadable manifest is + // skipped, never failing the whole sync. + approve_furniture_capsules(home, target, &dst.capsules_dir()); + Ok(()) } +/// Approve every capsule under `capsules_dir` for `target` at its current +/// capability fingerprint. Used to bless the furniture set after it is mirrored +/// into a new principal's home (#995). +fn approve_furniture_capsules(home: &AstridHome, target: &PrincipalId, capsules_dir: &Path) { + let entries = match std::fs::read_dir(capsules_dir) { + Ok(entries) => entries, + // No capsules mirrored (fresh system) → nothing to approve. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, + Err(e) => { + tracing::warn!( + dir = %capsules_dir.display(), + error = %e, + "furniture approval: failed to enumerate mirrored capsules" + ); + return; + }, + }; + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let capsule_id = entry.file_name().to_string_lossy().into_owned(); + let manifest_path = entry.path().join("Capsule.toml"); + let manifest = match astrid_capsule::discovery::load_manifest(&manifest_path) { + Ok(m) => m, + Err(e) => { + tracing::warn!( + capsule = %capsule_id, + %target, + error = %e, + "furniture approval: skipping capsule with unreadable manifest" + ); + continue; + }, + }; + let fingerprint = astrid_capsule::security::approval::capability_fingerprint(&manifest); + // Key on the manifest's package name — the id the engine consults at + // load — not the on-disk directory name, so they can never diverge. + if let Err(e) = astrid_capsule::security::approval::approve( + home, + target, + &manifest.package.name, + fingerprint, + ) { + tracing::warn!( + capsule = %capsule_id, + %target, + error = %e, + "furniture approval: failed to write approval; capsule will load inert for this principal" + ); + } + } +} + /// Replace `dst` with a fresh recursive copy of `src`. /// /// Idempotent and self-healing: `dst` is removed first so capsules dropped @@ -277,6 +341,41 @@ mod tests { assert!(!target_home.capsules_dir().join("bravo").exists()); } + #[test] + fn seeded_furniture_capsule_is_approved_for_target_principal() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = AstridHome::from_path(tmp.path()); + + // The install principal has a capsule with a real manifest (a furniture + // capsule declaring a capability) plus its content. + let install = home.principal_home(&crate::paths::install_principal()); + write_file( + &install.capsules_dir().join("furn").join("Capsule.toml"), + "[package]\nname = \"furn\"\nversion = \"0.1.0\"\n\n[capabilities]\nnet = [\"example.com\"]\n", + ); + + let target = PrincipalId::new("claude-code").expect("principal id"); + materialize_principal_furniture(&home, &target).expect("materialize"); + + // The mirrored capsule is now APPROVED for the target principal at the + // fingerprint of its manifest — so it loads with its capabilities, not + // inert. Without this write, a furniture-seeded capsule would be inert + // (the approval store lives under .config/, which is never mirrored). + let manifest = astrid_capsule::discovery::load_manifest( + &home + .principal_home(&target) + .capsules_dir() + .join("furn") + .join("Capsule.toml"), + ) + .expect("load mirrored manifest"); + let fp = astrid_capsule::security::approval::capability_fingerprint(&manifest); + assert!( + astrid_capsule::security::approval::is_approved(&home, &target, "furn", &fp), + "a furniture-seeded capsule must be auto-approved for the target principal" + ); + } + #[test] fn empty_source_yields_no_mirror() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/astrid-capsule-install/src/lib.rs b/crates/astrid-capsule-install/src/lib.rs index 9d4a8f8f6..22b643b39 100644 --- a/crates/astrid-capsule-install/src/lib.rs +++ b/crates/astrid-capsule-install/src/lib.rs @@ -76,5 +76,5 @@ pub use manifest_check::{ExportConflict, MissingImport, check_export_conflicts, pub use meta::{ CapsuleLocation, CapsuleMeta, InstalledCapsule, read_meta, scan_installed_capsules, write_meta, }; -pub use paths::{resolve_env_path, resolve_target_dir, restore_env_from_backup}; +pub use paths::{install_principal, resolve_env_path, resolve_target_dir, restore_env_from_backup}; pub use wit::{content_address_wit, materialize_wit_mirror}; diff --git a/crates/astrid-capsule/src/engine/wasm/mod.rs b/crates/astrid-capsule/src/engine/wasm/mod.rs index 8a0dd4259..3244a4b28 100644 --- a/crates/astrid-capsule/src/engine/wasm/mod.rs +++ b/crates/astrid-capsule/src/engine/wasm/mod.rs @@ -979,7 +979,12 @@ impl ExecutionEngine for WasmEngine { let workspace_root = ctx.workspace_root.clone(); let kv = ctx.kv.clone(); let event_bus = astrid_events::EventBus::clone(&ctx.event_bus); - let manifest = self.manifest.clone(); + // Owned, mutable: the install-time capability-approval consult (#995) + // zeroes `manifest.capabilities` in place for an unapproved capsule + // BEFORE the security gate and every other capability derivation reads + // it, so an unapproved capsule loads inert. See the consult just above + // `ManifestSecurityGate::new`. + let mut manifest = self.manifest.clone(); let mut wasm_config = std::collections::HashMap::new(); @@ -1144,6 +1149,77 @@ impl ExecutionEngine for WasmEngine { Box::new(upper_vfs), )); + // ── Install-time capability-approval consult (#995) ───────────── + // + // A capsule's manifest is UNTRUSTED INPUT; its declared + // `[capabilities]` must not become effective without an operator + // approval recorded out-of-band. We consult the per-principal + // approval store (under `.config/approvals/`, capsule-unreachable) + // for THIS capsule at the fingerprint of its current declared set. + // If the fingerprint is not approved, we zero `manifest.capabilities` + // IN PLACE so every downstream derivation below — the security gate, + // `capability_names`, `has_uplink`, the net_bind socket listener — + // sees the empty, fail-closed set and the capsule loads INERT: + // present and discoverable, but with zero host-fn access. + // + // Fail-secure: a home that cannot be resolved is treated as "no + // approval" (inert), never as approved. Only `capabilities` is + // zeroed; the IPC publish/subscribe patterns are intentionally left + // intact for an inert capsule — gating those for inert capsules is a + // v1 limitation tracked as a follow-up (the host-fn capability + // surface is the load-bearing boundary and IS gated here). + let capability_fp = crate::security::approval::capability_fingerprint(&manifest); + let approved = match astrid_core::dirs::AstridHome::resolve() { + Ok(approval_home) => { + // The approval record is a small one-shot disk read, but + // `std::fs` is blocking — run it off the async executor so a + // load never starves a worker thread. + let principal = ctx.principal.clone(); + let capsule_name = manifest.package.name.clone(); + let fp = capability_fp.clone(); + tokio::task::spawn_blocking(move || { + crate::security::approval::is_approved( + &approval_home, + &principal, + &capsule_name, + &fp, + ) + }) + .await + .unwrap_or_else(|e| { + tracing::warn!( + error = %e, + "capability-approval check task failed; loading capsule inert (fail-secure)" + ); + false + }) + }, + Err(e) => { + tracing::warn!( + capsule = %manifest.package.name, + principal = %ctx.principal, + error = %e, + "could not resolve home to check capability approval; \ + loading capsule inert (fail-secure)" + ); + false + }, + }; + if !approved { + tracing::warn!( + target: "astrid.audit.capsule.approval", + capsule = %manifest.package.name, + principal = %ctx.principal, + fingerprint = %capability_fp, + "capsule loaded INERT: declared capabilities are not approved for \ + this principal — run `astrid capsule approve` to activate (#995)" + ); + } + manifest.capabilities = crate::security::approval::effective_capabilities( + &manifest.capabilities, + approved, + ); + // Only resolve home:// in the gate if we actually mounted the VFS. // Otherwise the gate would approve paths the VFS can't serve. let gate_home_root = home_mount.as_ref().map(|m| m.root.clone()); diff --git a/crates/astrid-capsule/src/security/approval.rs b/crates/astrid-capsule/src/security/approval.rs new file mode 100644 index 000000000..b0764ee7d --- /dev/null +++ b/crates/astrid-capsule/src/security/approval.rs @@ -0,0 +1,465 @@ +//! Install-time capability approval (#995). +//! +//! A capsule's manifest is UNTRUSTED INPUT: the `[capabilities]` block is +//! whatever the capsule author wrote. Before this gate, those declared +//! capabilities became effective at load with no operator approval and no +//! distinction between a distro/admin/furniture install and a capsule a +//! user dropped on disk manually. This module is the fail-secure floor: +//! +//! 1. [`capability_fingerprint`] hashes the SECURITY-RELEVANT declared set +//! (the full [`CapabilitiesDef`] plus the effective IPC publish/subscribe +//! patterns) into a stable [`CapabilityFingerprint`] (BLAKE3 hex). Any change +//! to a declared capability or IPC pattern changes the fingerprint, so a +//! re-install that escalates requires a fresh approval. +//! +//! 2. The approval store ([`approve`] / [`is_approved`] / [`remove`]) records, +//! per principal, the fingerprint an operator approved for a capsule. The +//! records live under the principal's `.config/approvals/` directory — +//! operator-owned, never mounted into a guest, and NOT carried along when a +//! capsule directory is copied as furniture into another principal's home. +//! A capsule therefore cannot forge, escalate, or relocate its own approval. +//! +//! 3. [`effective_capabilities`] is the pure decision: an approved capsule +//! keeps its declared capabilities; an unapproved one is reduced to +//! [`CapabilitiesDef::default`] (the empty, fail-closed set) so it loads +//! INERT — present and discoverable, but with zero host-fn access. +//! +//! The engine consults this immediately before constructing the +//! [`ManifestSecurityGate`](super::ManifestSecurityGate); the install paths +//! (CLI manual prompt, distro/admin/furniture auto-approve) write the records; +//! [`migrate_grandfather_approvals`] approves everything already installed on +//! the first daemon boot after upgrade so nothing bricks. + +use std::fmt; +use std::path::Path; + +use astrid_core::PrincipalId; +use astrid_core::dirs::AstridHome; +use serde::{Deserialize, Serialize}; + +use crate::manifest::{CapabilitiesDef, CapsuleManifest}; + +/// A stable fingerprint of a capsule's approved capability surface. +/// +/// Wraps the BLAKE3 hex of the canonical security-relevant declared set (see +/// [`capability_fingerprint`]). A newtype rather than a bare `String` so that an +/// approval comparison is type-checked against an approval comparison and can +/// never be confused with another hex value (e.g. a WASM content hash). Serde is +/// `transparent`, so the persisted JSON carries the bare hex string — the +/// on-disk format is identical to storing a `String`. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CapabilityFingerprint(String); + +impl CapabilityFingerprint { + /// Wrap an already-computed fingerprint hex string. + /// + /// Most callers obtain a fingerprint from [`capability_fingerprint`]; this + /// constructor exists for the load/approve paths that round-trip the hex + /// through the CLI or an on-disk record. + #[must_use] + pub fn new(hex: impl Into) -> Self { + Self(hex.into()) + } + + /// The fingerprint as its hex string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CapabilityFingerprint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl From for CapabilityFingerprint { + fn from(hex: String) -> Self { + Self(hex) + } +} + +impl From<&str> for CapabilityFingerprint { + fn from(hex: &str) -> Self { + Self(hex.to_string()) + } +} + +impl From<&CapabilityFingerprint> for CapabilityFingerprint { + fn from(fp: &CapabilityFingerprint) -> Self { + fp.clone() + } +} + +/// The on-disk approval record for a single capsule under a single principal. +/// +/// Stored at `/.config/approvals/.json`. The only +/// field that carries trust is `fingerprint`: an approval matches at load only +/// when its stored fingerprint equals the freshly computed one, so a capsule +/// whose declared capabilities changed since approval is treated as unapproved +/// until re-approved. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ApprovalRecord { + /// BLAKE3 hex of the approved capability set (see [`capability_fingerprint`]). + fingerprint: CapabilityFingerprint, +} + +/// Canonical serialization of the security-relevant declared set, hashed to a +/// stable [`CapabilityFingerprint`]. +/// +/// The input is the full [`CapabilitiesDef`] PLUS the effective IPC publish and +/// subscribe patterns. The IPC pattern vectors are HashMap-backed (their order +/// is unspecified), so they are sorted before serialization — the fingerprint +/// is invariant under reordering of the `[publish]` / `[subscribe]` tables but +/// changes whenever any pattern is added, removed, or edited. `CapabilitiesDef` +/// serializes to a JSON object whose keys serde emits in a fixed order, so the +/// capability half is already canonical. +/// +/// A change to ANY declared capability or IPC pattern changes the fingerprint, +/// which is the whole point: an operator approved a SPECIFIC capability surface, +/// and an upgrade that widens it must be re-approved. +#[must_use] +pub fn capability_fingerprint(manifest: &CapsuleManifest) -> CapabilityFingerprint { + let mut publish = manifest.effective_ipc_publish_patterns(); + publish.sort_unstable(); + let mut subscribe = manifest.effective_ipc_subscribe_patterns(); + subscribe.sort_unstable(); + + // A struct serialized with serde_json has a stable field order, so the + // canonical form is `{capabilities, ipc_publish, ipc_subscribe}` with the + // two vectors pre-sorted. `to_value` cannot fail for these plain types. + let canonical = serde_json::json!({ + "capabilities": manifest.capabilities, + "ipc_publish": publish, + "ipc_subscribe": subscribe, + }); + // `to_vec` on an already-constructed `Value` is infallible; fall back to an + // empty slice rather than panicking so a fingerprint is always produced + // (an empty input still yields a stable, distinct hash). + let bytes = serde_json::to_vec(&canonical).unwrap_or_default(); + CapabilityFingerprint(astrid_crypto::ContentHash::hash(&bytes).to_hex()) +} + +/// The capability set a capsule should load with, given whether its current +/// fingerprint is approved. +/// +/// This is the pure heart of the load-time gate, extracted so the enforcement +/// is unit-testable without a daemon: an approved capsule keeps its declared +/// capabilities verbatim; an unapproved capsule is reduced to the empty, +/// fail-closed [`CapabilitiesDef::default`] and therefore loads INERT. +#[must_use] +pub fn effective_capabilities(declared: &CapabilitiesDef, approved: bool) -> CapabilitiesDef { + if approved { + declared.clone() + } else { + CapabilitiesDef::default() + } +} + +/// Path of the approval record for `capsule_id` under `principal`. +fn record_path(home: &AstridHome, principal: &PrincipalId, capsule_id: &str) -> std::path::PathBuf { + home.principal_home(principal) + .approvals_dir() + .join(format!("{capsule_id}.json")) +} + +/// Whether `capsule_id` is a syntactically valid capsule id, and therefore safe +/// to use as a single path component under the approvals directory. +/// +/// Every store function validates its `capsule_id` before building a path: +/// `is_approved`/`approve`/`remove` are reachable with an id that has NOT yet +/// passed [`CapsuleId`](crate::capsule::CapsuleId) validation (the engine load +/// path consults `is_approved(manifest.package.name)` before validating the +/// name), so a traversal string like `../../evil` must never reach +/// `std::fs`. Invalid ids are rejected here, fail-secure. +fn is_valid_capsule_id(capsule_id: &str) -> bool { + crate::capsule::CapsuleId::new(capsule_id).is_ok() +} + +/// Record an operator approval of `fingerprint` for `capsule_id` under +/// `principal`. +/// +/// Idempotent: re-approving overwrites any existing record (which is how a +/// re-approval after a capability change lands). Writes atomically +/// (temp file + rename) so a crash mid-write never leaves a partial — and +/// therefore unparseable, hence unapproved (fail-secure) — record. +/// +/// # Errors +/// +/// Returns an error if the approvals directory cannot be created or the record +/// cannot be written / renamed into place. +pub fn approve( + home: &AstridHome, + principal: impl Into, + capsule_id: impl AsRef, + fingerprint: impl Into, +) -> std::io::Result<()> { + let principal = principal.into(); + let capsule_id = capsule_id.as_ref(); + if !is_valid_capsule_id(capsule_id) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid capsule id '{capsule_id}': cannot record approval"), + )); + } + let dir = home.principal_home(&principal).approvals_dir(); + std::fs::create_dir_all(&dir)?; + let path = dir.join(format!("{capsule_id}.json")); + + let record = ApprovalRecord { + fingerprint: fingerprint.into(), + }; + let json = serde_json::to_string_pretty(&record) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + + let mut tmp = tempfile::NamedTempFile::new_in(&dir)?; + std::io::Write::write_all(&mut tmp, json.as_bytes())?; + tmp.persist(&path) + .map_err(|e| std::io::Error::other(format!("failed to persist {}: {e}", path.display())))?; + Ok(()) +} + +/// Whether `capsule_id` is approved for `principal` at exactly `fingerprint`. +/// +/// True iff a record exists AND its stored fingerprint matches the argument +/// exactly. A missing record, an unreadable record, an unparseable record, or a +/// fingerprint mismatch all return `false` — every failure mode is fail-secure +/// (treated as unapproved → the capsule loads inert). +#[must_use] +pub fn is_approved( + home: &AstridHome, + principal: impl Into, + capsule_id: impl AsRef, + fingerprint: impl Into, +) -> bool { + let principal = principal.into(); + let fingerprint = fingerprint.into(); + let capsule_id = capsule_id.as_ref(); + // An invalid id can never have been written by an install path (those + // validate before writing) and must never build a traversal read path. + if !is_valid_capsule_id(capsule_id) { + return false; + } + let path = record_path(home, &principal, capsule_id); + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return false, + Err(e) => { + // Fail-secure (unapproved), but a non-NotFound error (permissions, + // I/O) is unexpected — surface it so an operator can diagnose why a + // capsule loads inert. + tracing::warn!( + path = %path.display(), + error = %e, + "failed to read capsule approval record; treating as unapproved" + ); + return false; + }, + }; + let Ok(record) = serde_json::from_slice::(&bytes) else { + tracing::warn!( + path = %path.display(), + "capsule approval record is corrupt; treating as unapproved" + ); + return false; + }; + record.fingerprint == fingerprint +} + +/// Remove the approval record for `capsule_id` under `principal`, if any. +/// +/// Used on uninstall so a removed capsule's approval does not silently carry +/// over to a later, different capsule reinstalled under the same id. Absence is +/// success. +/// +/// # Errors +/// +/// Returns an error only if a record exists but cannot be removed. +pub fn remove( + home: &AstridHome, + principal: impl Into, + capsule_id: impl AsRef, +) -> std::io::Result<()> { + let principal = principal.into(); + let capsule_id = capsule_id.as_ref(); + if !is_valid_capsule_id(capsule_id) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid capsule id '{capsule_id}': refusing to remove"), + )); + } + let path = record_path(home, &principal, capsule_id); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +/// One-time grandfather migration: approve every already-installed capsule for +/// its principal at its CURRENT fingerprint. +/// +/// Run once on daemon startup. Without it, upgrading to the version that ships +/// the load-time gate would strip capabilities from every existing setup (no +/// approval records exist yet → every capsule would load inert). After it runs, +/// "no approval record" genuinely means unapproved, so a capsule dropped on disk +/// AFTER migration still loads inert. +/// +/// Idempotent via a marker file under `etc/` (outside the `home://` VFS, so a +/// capsule cannot delete it to force a re-grandfather). When the marker exists +/// this is a no-op. The migration enumerates the install principal plus every +/// `etc/profiles/*.toml` principal, scans each principal's `capsules_dir()`, and +/// approves each discovered capsule at the fingerprint of its on-disk manifest. +/// +/// Best-effort and non-fatal: a single unreadable manifest or unwritable +/// approval record is logged and skipped — that one capsule stays inert until +/// approved manually, but the sweep continues. The marker is written after the +/// sweep regardless of per-capsule failures; only a failure to create `etc/` or +/// to write the marker itself leaves it absent, in which case the whole +/// migration simply retries next boot (re-approval is idempotent). +pub fn migrate_grandfather_approvals(home: &AstridHome) { + let marker = home.etc_dir().join(".capability-approvals-migrated"); + if marker.exists() { + return; + } + + tracing::info!( + target: "astrid.audit.capsule.approval", + "grandfathering install-time capability approvals for all pre-existing capsules (#995)" + ); + + let principals = migration_principals(home); + + let mut approved_count: usize = 0; + for principal in &principals { + approved_count = approved_count.saturating_add(grandfather_principal(home, principal)); + } + + // Mark complete. If `etc/` is missing or the write fails the migration + // simply re-runs next boot (re-approving is idempotent), which is the + // fail-secure direction. + if let Err(e) = std::fs::create_dir_all(home.etc_dir()) { + tracing::warn!( + path = %home.etc_dir().display(), + error = %e, + "grandfather: failed to create etc dir for migration marker; migration will re-run next boot" + ); + } else if let Err(e) = std::fs::write(&marker, b"1") { + tracing::warn!( + path = %marker.display(), + error = %e, + "grandfather: failed to write migration marker; migration will re-run next boot" + ); + } + + tracing::info!( + target: "astrid.audit.capsule.approval", + approved = approved_count, + principals = principals.len(), + "grandfathered install-time capability approvals (#995)" + ); +} + +/// The principals the grandfather migration covers: the install principal first +/// (always present), then every `etc/profiles/*.toml` principal, de-duplicated. +fn migration_principals(home: &AstridHome) -> Vec { + let mut principals: Vec = vec![PrincipalId::default()]; + let profiles_dir = home.profiles_dir(); + let Ok(entries) = std::fs::read_dir(&profiles_dir) else { + return principals; + }; + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_file()) { + continue; + } + let file_name = entry.file_name(); + let Some(stem) = file_name.to_str().and_then(|n| n.strip_suffix(".toml")) else { + continue; + }; + let Ok(principal) = PrincipalId::new(stem) else { + continue; + }; + if !principals.contains(&principal) { + principals.push(principal); + } + } + principals +} + +/// Approve every capsule already installed under `principal` at its current +/// fingerprint. Returns the number of capsules approved. +fn grandfather_principal(home: &AstridHome, principal: &PrincipalId) -> usize { + let capsules_dir = home.principal_home(principal).capsules_dir(); + let Ok(entries) = std::fs::read_dir(&capsules_dir) else { + return 0; + }; + let mut count: usize = 0; + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let dir = entry.path(); + let capsule_id = entry.file_name().to_string_lossy().into_owned(); + let manifest_path = dir.join("Capsule.toml"); + let manifest = match crate::discovery::load_manifest(&manifest_path) { + Ok(m) => m, + Err(e) => { + tracing::warn!( + capsule = %capsule_id, + %principal, + error = %e, + "grandfather: skipping capsule with unreadable manifest" + ); + continue; + }, + }; + let fingerprint = capability_fingerprint(&manifest); + // Key the approval on the manifest's package name — the exact key the + // engine consults at load — not the on-disk directory name, so the two + // can never diverge. + if let Err(e) = approve(home, principal, &manifest.package.name, fingerprint) { + tracing::warn!( + capsule = %capsule_id, + %principal, + error = %e, + "grandfather: failed to write approval; capsule will load inert until approved" + ); + continue; + } + count = count.saturating_add(1); + } + count +} + +/// Whether `path` resolves inside the approval store under `principal_home_root`. +/// +/// Defense in depth: the approval records live under `home://.config/approvals/`, +/// which is technically within the `home://` VFS mount. A capsule that declared +/// a broad enough `home://` write scope AND was approved could otherwise reach +/// in and forge or escalate another capsule's approval. The security gate calls +/// this to HARD-DENY any guest read/write that lands in the approvals tree, +/// regardless of the manifest allowlist — the store is operator-only. +/// +/// Precondition: `path` must be free of `..` components. The match is lexical +/// (`Path::starts_with` does not resolve `..`), so a traversal path such as +/// `…/capsules/../.config/approvals/x` would not be recognised. The sole caller, +/// `ManifestSecurityGate::check_fs_permission`, rejects any path containing a +/// `ParentDir` component *before* reaching this check, so the precondition holds +/// there; a future caller must uphold it (or normalize first). +#[must_use] +pub fn path_is_in_approval_store( + path: impl AsRef, + principal_home_root: impl AsRef, +) -> bool { + let approvals = principal_home_root + .as_ref() + .join(".config") + .join("approvals"); + path.as_ref().starts_with(&approvals) +} + +#[cfg(test)] +#[path = "approval_tests.rs"] +mod tests; diff --git a/crates/astrid-capsule/src/security/approval_tests.rs b/crates/astrid-capsule/src/security/approval_tests.rs new file mode 100644 index 000000000..0ebaac10f --- /dev/null +++ b/crates/astrid-capsule/src/security/approval_tests.rs @@ -0,0 +1,418 @@ +//! Tests for the install-time capability approval gate (#995). + +use astrid_core::PrincipalId; +use astrid_core::dirs::AstridHome; + +use super::*; +use crate::manifest::CapsuleManifest; + +/// A manifest with the given net hosts and IPC patterns, built via the real +/// TOML deserializer so the fixtures match what `load_manifest` produces. +fn manifest_with(net: &[&str], publish: &[&str], subscribe: &[&str]) -> CapsuleManifest { + let nets = net + .iter() + .map(|n| format!("\"{n}\"")) + .collect::>() + .join(", "); + let mut toml = format!( + "[package]\nname = \"test-capsule\"\nversion = \"0.1.0\"\n\n[capabilities]\nnet = [{nets}]\n" + ); + if !publish.is_empty() { + toml.push_str("\n[publish]\n"); + for p in publish { + toml.push_str(&format!("\"{p}\" = {{ wit = \"opaque\" }}\n")); + } + } + if !subscribe.is_empty() { + toml.push_str("\n[subscribe]\n"); + for s in subscribe { + toml.push_str(&format!("\"{s}\" = {{ wit = \"opaque\" }}\n")); + } + } + toml::from_str(&toml).expect("fixture manifest parses") +} + +// ── Fingerprint ────────────────────────────────────────────────────────── + +#[test] +fn fingerprint_is_stable_across_ipc_pattern_reorder() { + // The `[publish]`/`[subscribe]` tables are HashMap-backed, so their + // enumeration order is unspecified; the fingerprint must not depend on it. + let a = manifest_with( + &["example.com"], + &["astrid.v1.a", "astrid.v1.b", "astrid.v1.c"], + &["client.v1.x", "client.v1.y"], + ); + let b = manifest_with( + &["example.com"], + &["astrid.v1.c", "astrid.v1.a", "astrid.v1.b"], + &["client.v1.y", "client.v1.x"], + ); + assert_eq!( + capability_fingerprint(&a), + capability_fingerprint(&b), + "fingerprint must be invariant under IPC pattern reordering" + ); +} + +#[test] +fn fingerprint_changes_when_a_capability_changes() { + let base = manifest_with(&["example.com"], &[], &[]); + let escalated = manifest_with(&["example.com", "attacker.example.com"], &[], &[]); + assert_ne!( + capability_fingerprint(&base), + capability_fingerprint(&escalated), + "adding an egress host must change the fingerprint (forces re-approval)" + ); + + // A bool capability flip must also change it. + let mut m_uplink = manifest_with(&["example.com"], &[], &[]); + m_uplink.capabilities.uplink = true; + assert_ne!( + capability_fingerprint(&base), + capability_fingerprint(&m_uplink), + "toggling the uplink flag must change the fingerprint" + ); +} + +#[test] +fn fingerprint_changes_when_ipc_pattern_changes() { + let base = manifest_with(&[], &["astrid.v1.a"], &[]); + let widened = manifest_with(&[], &["astrid.v1.a", "astrid.v1.b"], &[]); + assert_ne!( + capability_fingerprint(&base), + capability_fingerprint(&widened), + "adding an IPC publish pattern must change the fingerprint" + ); + + let sub = manifest_with(&[], &[], &["client.v1.x"]); + assert_ne!( + capability_fingerprint(&base), + capability_fingerprint(&sub), + "publish vs subscribe patterns must not collide" + ); +} + +#[test] +fn fingerprint_is_deterministic_hex() { + let m = manifest_with(&["example.com"], &["astrid.v1.a"], &["client.v1.x"]); + let f1 = capability_fingerprint(&m); + let f2 = capability_fingerprint(&m); + assert_eq!(f1, f2, "same input → same fingerprint"); + assert_eq!(f1.as_str().len(), 64, "BLAKE3 hex is 64 chars"); + assert!(f1.as_str().bytes().all(|b| b.is_ascii_hexdigit())); +} + +// ── effective_capabilities (the pure load decision) ────────────────────── + +#[test] +fn effective_capabilities_approved_keeps_declared() { + let declared = CapabilitiesDef { + net: vec!["example.com".into()], + ..Default::default() + }; + let effective = effective_capabilities(&declared, true); + assert_eq!( + effective.net, + vec!["example.com".to_string()], + "an approved capsule keeps its declared capabilities verbatim" + ); +} + +#[test] +fn effective_capabilities_unapproved_is_empty_set() { + // The whole point: an unapproved capsule with a hostile declared set loads + // with the empty, fail-closed set → inert. + let declared = CapabilitiesDef { + net: vec!["attacker.example.com".into()], + fs_write: vec!["*".into()], + host_process: vec!["bash".into()], + uplink: true, + ..Default::default() + }; + let effective = effective_capabilities(&declared, false); + assert!( + effective.held_names().is_empty(), + "unapproved → zero capabilities" + ); + assert!(effective.net.is_empty()); + assert!(effective.fs_write.is_empty()); + assert!(effective.host_process.is_empty()); + assert!(!effective.uplink); +} + +// ── Approval store ─────────────────────────────────────────────────────── + +fn test_home(dir: &std::path::Path) -> AstridHome { + AstridHome::from_path(dir) +} + +#[test] +fn approve_then_is_approved_true() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = test_home(tmp.path()); + let principal = PrincipalId::default(); + let fp = "abc123"; + + assert!( + !is_approved(&home, &principal, "cap", fp), + "absent record → not approved" + ); + approve(&home, &principal, "cap", fp).expect("approve"); + assert!( + is_approved(&home, &principal, "cap", fp), + "after approve with matching fingerprint → approved" + ); +} + +#[test] +fn mismatched_fingerprint_is_not_approved() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = test_home(tmp.path()); + let principal = PrincipalId::default(); + + approve(&home, &principal, "cap", "fingerprint-v1").expect("approve"); + assert!( + !is_approved(&home, &principal, "cap", "fingerprint-v2"), + "a different fingerprint (escalated manifest) must NOT be approved" + ); + assert!( + is_approved(&home, &principal, "cap", "fingerprint-v1"), + "the originally-approved fingerprint still matches" + ); +} + +#[test] +fn absent_record_is_not_approved() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = test_home(tmp.path()); + let principal = PrincipalId::default(); + assert!(!is_approved( + &home, + &principal, + "never-installed", + "anything" + )); +} + +#[test] +fn corrupt_record_is_not_approved() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = test_home(tmp.path()); + let principal = PrincipalId::default(); + let dir = home.principal_home(&principal).approvals_dir(); + std::fs::create_dir_all(&dir).expect("mkdir"); + std::fs::write(dir.join("cap.json"), b"{ not valid json").expect("write"); + assert!( + !is_approved(&home, &principal, "cap", "anything"), + "an unparseable record is fail-secure (treated as unapproved)" + ); +} + +#[test] +fn approve_overwrites_existing_record() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = test_home(tmp.path()); + let principal = PrincipalId::default(); + + approve(&home, &principal, "cap", "old").expect("approve old"); + approve(&home, &principal, "cap", "new").expect("approve new"); + assert!(!is_approved(&home, &principal, "cap", "old")); + assert!(is_approved(&home, &principal, "cap", "new")); +} + +#[test] +fn remove_clears_approval() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = test_home(tmp.path()); + let principal = PrincipalId::default(); + + approve(&home, &principal, "cap", "fp").expect("approve"); + remove(&home, &principal, "cap").expect("remove"); + assert!(!is_approved(&home, &principal, "cap", "fp")); + // Removing again is a no-op success. + remove(&home, &principal, "cap").expect("remove-absent is ok"); +} + +#[test] +fn approval_is_per_principal() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = test_home(tmp.path()); + let alice = PrincipalId::new("alice").expect("principal"); + let bob = PrincipalId::new("bob").expect("principal"); + + approve(&home, &alice, "cap", "fp").expect("approve alice"); + assert!(is_approved(&home, &alice, "cap", "fp")); + assert!( + !is_approved(&home, &bob, "cap", "fp"), + "alice's approval must not leak to bob" + ); +} + +#[test] +fn approval_record_lives_under_config_not_capsule_dir() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = test_home(tmp.path()); + let principal = PrincipalId::default(); + approve(&home, &principal, "cap", "fp").expect("approve"); + + let ph = home.principal_home(&principal); + let record = ph.approvals_dir().join("cap.json"); + assert!(record.exists(), "record is under .config/approvals/"); + // It must NOT be inside the capsule's own (copyable, guest-reachable) dir. + assert!(!ph.capsules_dir().join("cap").join("cap.json").exists()); + assert!(record.starts_with(ph.config_dir())); +} + +// ── path_is_in_approval_store (defense in depth) ───────────────────────── + +#[test] +fn path_in_approval_store_detects_reach_in() { + let home_root = std::path::Path::new("/home/p"); + let approvals = home_root.join(".config").join("approvals"); + assert!(path_is_in_approval_store( + approvals.join("x.json"), + home_root + )); + assert!(path_is_in_approval_store(&approvals, home_root)); + // A sibling config dir (env) is NOT the approval store. + assert!(!path_is_in_approval_store( + home_root.join(".config").join("env").join("x.env.json"), + home_root + )); + assert!(!path_is_in_approval_store( + home_root.join(".local").join("capsules"), + home_root + )); +} + +// ── Grandfather migration ──────────────────────────────────────────────── + +/// Install a minimal on-disk capsule dir with a manifest under `principal`. +fn install_fixture_capsule(home: &AstridHome, principal: &PrincipalId, id: &str, net: &[&str]) { + let dir = home.principal_home(principal).capsules_dir().join(id); + std::fs::create_dir_all(&dir).expect("mkdir capsule"); + let nets = net + .iter() + .map(|n| format!("\"{n}\"")) + .collect::>() + .join(", "); + let toml = format!( + "[package]\nname = \"{id}\"\nversion = \"0.1.0\"\n\n[capabilities]\nnet = [{nets}]\n" + ); + std::fs::write(dir.join("Capsule.toml"), toml).expect("write manifest"); +} + +#[test] +fn migration_approves_preexisting_capsule() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = test_home(tmp.path()); + let principal = PrincipalId::default(); + + install_fixture_capsule(&home, &principal, "preexisting", &["example.com"]); + + // Before migration: the on-disk capsule has no approval → would load inert. + let manifest = crate::discovery::load_manifest( + &home + .principal_home(&principal) + .capsules_dir() + .join("preexisting") + .join("Capsule.toml"), + ) + .expect("load manifest"); + let fp = capability_fingerprint(&manifest); + assert!(!is_approved(&home, &principal, "preexisting", &fp)); + + migrate_grandfather_approvals(&home); + + // After migration: the capsule is approved at its current fingerprint. + assert!( + is_approved(&home, &principal, "preexisting", &fp), + "grandfather migration must approve a pre-existing capsule at its current fingerprint" + ); +} + +#[test] +fn migration_is_idempotent_and_does_not_reapprove_after_change() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = test_home(tmp.path()); + let principal = PrincipalId::default(); + install_fixture_capsule(&home, &principal, "cap", &["example.com"]); + + migrate_grandfather_approvals(&home); + let marker = home.etc_dir().join(".capability-approvals-migrated"); + assert!(marker.exists(), "marker written after first run"); + + // Now the capsule's manifest is escalated on disk (simulating a hostile + // post-migration swap) and migration is run again. Because the marker is + // present, the second run is a no-op: the escalated fingerprint stays + // unapproved (the capsule would load inert until re-approved). + install_fixture_capsule( + &home, + &principal, + "cap", + &["example.com", "attacker.example.com"], + ); + migrate_grandfather_approvals(&home); + + let escalated = crate::discovery::load_manifest( + &home + .principal_home(&principal) + .capsules_dir() + .join("cap") + .join("Capsule.toml"), + ) + .expect("load manifest"); + let escalated_fp = capability_fingerprint(&escalated); + assert!( + !is_approved(&home, &principal, "cap", &escalated_fp), + "idempotent migration must not auto-approve a post-migration escalation" + ); +} + +// ── Capsule-id validation (path-traversal defense in depth) ────────────── + +#[test] +fn approve_rejects_traversal_capsule_id() { + let tmp = tempfile::tempdir().unwrap(); + let home = AstridHome::from_path(tmp.path()); + let principal = PrincipalId::default(); + let fp = CapabilityFingerprint::new("deadbeef"); + + // A traversal id must never be turned into a write path outside the store. + for bad in ["../../evil", "foo/bar", "..", "a/../b"] { + let err = approve(&home, &principal, bad, &fp) + .expect_err("traversal id must be rejected by approve"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput, "id {bad:?}"); + } + // No file escaped the approvals dir into the principal's .config. + assert!( + !home + .principal_home(&principal) + .root() + .join(".config") + .join("evil.json") + .exists() + ); +} + +#[test] +fn is_approved_false_for_invalid_capsule_id() { + let tmp = tempfile::tempdir().unwrap(); + let home = AstridHome::from_path(tmp.path()); + let principal = PrincipalId::default(); + let fp = CapabilityFingerprint::new("deadbeef"); + // Fail-secure: an invalid id is unapproved and never builds a read path. + assert!(!is_approved(&home, &principal, "../../evil", &fp)); + assert!(!is_approved(&home, &principal, "foo/bar", &fp)); +} + +#[test] +fn remove_rejects_traversal_capsule_id() { + let tmp = tempfile::tempdir().unwrap(); + let home = AstridHome::from_path(tmp.path()); + let principal = PrincipalId::default(); + let err = remove(&home, &principal, "../../evil") + .expect_err("traversal id must be rejected by remove"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); +} diff --git a/crates/astrid-capsule/src/security/manifest_gate.rs b/crates/astrid-capsule/src/security/manifest_gate.rs index f0472093d..a962d8899 100644 --- a/crates/astrid-capsule/src/security/manifest_gate.rs +++ b/crates/astrid-capsule/src/security/manifest_gate.rs @@ -141,6 +141,23 @@ impl ManifestSecurityGate { return false; } + // Defense in depth (#995): the install-time capability-approval store + // lives at `/.config/approvals/`, which is technically + // within the `home://` VFS mount. Hard-deny ANY guest read/write that + // lands there, regardless of the manifest allowlist — the store is + // operator-only, and a capsule that could write it could forge or + // escalate its own (or another capsule's) approval. Checked against + // both the per-invocation `principal_home` and the construction-time + // `default_home_root`, whichever apply. + for home in [principal_home, self.default_home_root.as_deref()] + .into_iter() + .flatten() + { + if crate::security::approval::path_is_in_approval_store(path_obj, home) { + return false; + } + } + if statics.iter().any(|p| { if p == "*" { path_obj.starts_with(&self.workspace_root_path) diff --git a/crates/astrid-capsule/src/security/manifest_gate_tests.rs b/crates/astrid-capsule/src/security/manifest_gate_tests.rs index 9a8c0df3b..cbaaa856b 100644 --- a/crates/astrid-capsule/src/security/manifest_gate_tests.rs +++ b/crates/astrid-capsule/src/security/manifest_gate_tests.rs @@ -164,6 +164,59 @@ async fn test_manifest_security_gate_fs() { ); } +/// Defense in depth for #995: even an APPROVED capsule that declared a broad +/// `home://` write/read scope cannot reach the operator-only approval store +/// (`/.config/approvals/`). The hard-deny fires regardless of the +/// manifest allowlist, so a capsule cannot forge or escalate its own approval. +#[tokio::test] +async fn approval_store_is_hard_denied_even_with_home_write_scope() { + let home = home_root(); + // A maximally-broad home scope: read AND write all of home://. + let manifest = make_manifest(vec![], vec!["home://"], vec!["home://"]); + let gate = ManifestSecurityGate::new(manifest, workspace_root(), Some(home.clone())); + + let approval = home.join(".config").join("approvals").join("evil.json"); + let approval_str = approval.to_string_lossy(); + + // A normal home path under the declared scope is allowed... + let ok_path = home.join(".local").join("skills").join("x.md"); + assert!( + gate.check_file_write("evil", &ok_path.to_string_lossy(), None) + .await + .is_ok(), + "a non-approval home path under the declared scope is allowed", + ); + + // ...but the approval store is hard-denied for both write and read. + assert!( + gate.check_file_write("evil", &approval_str, None) + .await + .is_err(), + "#995: a capsule must never be able to WRITE its own approval record", + ); + assert!( + gate.check_file_read("evil", &approval_str, None) + .await + .is_err(), + "#995: the approval store is operator-only — guest reads are denied too", + ); + + // Also denied when the home root is supplied per-invocation rather than at + // construction (the cross-principal serving path). + let gate_no_default = ManifestSecurityGate::new( + make_manifest(vec![], vec![], vec!["home://"]), + workspace_root(), + None, + ); + assert!( + gate_no_default + .check_file_write("evil", &approval_str, Some(&home)) + .await + .is_err(), + "#995: hard-deny applies to the per-invocation principal_home too", + ); +} + #[tokio::test] async fn test_scheme_resolution_workspace() { let manifest = make_manifest(vec![], vec!["cwd://"], vec![]); @@ -599,3 +652,70 @@ async fn check_net_connect_matches_allowlist_entry() { ); assert!(gate.check_net_connect("c", "evil.com", 443).await.is_err()); } + +/// #995 contract: [`ManifestSecurityGate`] honours **exactly** the capability +/// set it is constructed from — it is the enforcement *mechanism*, not the +/// *policy*. The install-time approval decision lives UPSTREAM of the gate. +/// +/// [`ManifestSecurityGate::new`] takes `(manifest, workspace_root, home_root)`: +/// the manifest it receives is authoritative for this gate. That is by design. +/// The #995 fix is in the engine load path +/// (`engine::wasm::WasmEngine::load`): before building the gate it consults the +/// per-principal approval store and, for an UNAPPROVED capsule, zeroes +/// `manifest.capabilities` to [`CapabilitiesDef::default`] via +/// [`crate::security::approval::effective_capabilities`]. So the gate an +/// unapproved capsule gets is built from the EMPTY set and denies everything; +/// an approved capsule's gate is built from its declared set, as asserted here. +/// +/// This test therefore pins the gate's "honours what it is handed" contract — +/// the pure, daemon-free enforcement decision is covered by +/// [`crate::security::approval`]'s `effective_capabilities` tests, which assert +/// that an unapproved manifest collapses to the empty set BEFORE it ever +/// reaches this constructor. +#[tokio::test] +async fn regression_995_gate_honours_exactly_the_capability_set_it_is_built_from() { + // The APPROVED case: a gate built from a declared set honours that set. + // (The unapproved case never reaches here — the engine zeroes the caps + // first, so the gate would be built from `CapabilitiesDef::default`.) + let manifest = make_manifest( + vec!["attacker.example.com"], // arbitrary egress host + vec![], + vec!["*"], // broad write (confined to the workspace root) + ); + let gate = ManifestSecurityGate::new(manifest, workspace_root(), None); + + assert!( + gate.check_http_request("evil", "POST", "https://attacker.example.com/exfil") + .await + .is_ok(), + "#995: a gate built from an approved declared set honours it", + ); + assert!( + gate.check_file_write("evil", "/workspace/anything", None) + .await + .is_ok(), + "#995: a gate built from an approved declared set honours it", + ); + + // The fix proper: an unapproved manifest collapses to the empty set + // BEFORE gate construction, so the resulting gate denies everything. + let empty = make_manifest(vec!["attacker.example.com"], vec![], vec!["*"]); + let inert = crate::security::approval::effective_capabilities(&empty.capabilities, false); + let mut inert_manifest = empty; + inert_manifest.capabilities = inert; + let inert_gate = ManifestSecurityGate::new(inert_manifest, workspace_root(), None); + assert!( + inert_gate + .check_http_request("evil", "POST", "https://attacker.example.com/exfil") + .await + .is_err(), + "#995: an unapproved (inert) capsule's gate denies declared egress", + ); + assert!( + inert_gate + .check_file_write("evil", "/workspace/anything", None) + .await + .is_err(), + "#995: an unapproved (inert) capsule's gate denies declared write", + ); +} diff --git a/crates/astrid-capsule/src/security/mod.rs b/crates/astrid-capsule/src/security/mod.rs index 15017e0ce..7a5138e96 100644 --- a/crates/astrid-capsule/src/security/mod.rs +++ b/crates/astrid-capsule/src/security/mod.rs @@ -7,6 +7,7 @@ use async_trait::async_trait; +pub mod approval; mod manifest_gate; #[cfg(test)] mod test_gates; diff --git a/crates/astrid-cli/src/cli.rs b/crates/astrid-cli/src/cli.rs index 441d09050..a3192af7d 100644 --- a/crates/astrid-cli/src/cli.rs +++ b/crates/astrid-cli/src/cli.rs @@ -364,6 +364,16 @@ pub(crate) enum CapsuleCommands { #[arg(short, long)] verbose: bool, }, + /// Approve an installed capsule's declared capabilities so it can + /// activate. A manually-installed capsule is INERT (no host-fn access) + /// until approved (#995). + Approve { + /// Capsule name to approve + name: String, + /// Approve a workspace-installed capsule instead of user-level + #[arg(long)] + workspace: bool, + }, /// Remove an installed capsule Remove { /// Capsule name to remove diff --git a/crates/astrid-cli/src/commands/capsule/approve.rs b/crates/astrid-cli/src/commands/capsule/approve.rs new file mode 100644 index 000000000..8aad7470e --- /dev/null +++ b/crates/astrid-cli/src/commands/capsule/approve.rs @@ -0,0 +1,229 @@ +//! `astrid capsule approve ` and the install-time approval prompt (#995). +//! +//! A capsule's manifest-declared capabilities are INERT until an operator +//! approves them for the install principal. This module owns the operator-facing +//! side of that gate: +//! +//! * [`record_install_approval`] runs at the end of every CLI install. In +//! batch / non-interactive mode it auto-approves; interactively it shows the +//! declared capability surface and asks the operator to approve. +//! * [`run`] is the standalone `astrid capsule approve ` verb for activating +//! a capsule that was installed-but-declined (or seeded inert). +//! +//! The trust record itself lives in +//! [`astrid_capsule::security::approval`]; this module only renders and prompts. + +use anyhow::{Context, bail}; +use astrid_capsule::manifest::CapsuleManifest; +use astrid_capsule::security::approval; +use astrid_core::dirs::AstridHome; + +/// Record the operator's install-time approval decision for `capsule_id`. +/// +/// `batch` is `true` for distro/offline/non-interactive installs (`astrid init`, +/// `install_offline_capsule`): those auto-approve, because the distro IS the +/// operator's chosen baseline. Interactively, the declared capability surface is +/// shown and the operator is prompted; a decline leaves the capsule inert and +/// prints how to approve it later. +/// +/// # Errors +/// +/// Propagates a failure to write the approval record (a denied prompt is NOT an +/// error — it is a deliberate operator choice). +pub(crate) fn record_install_approval( + home: &AstridHome, + capsule_id: &str, + manifest: &CapsuleManifest, + batch: bool, +) -> anyhow::Result<()> { + let principal = astrid_capsule_install::install_principal(); + let fingerprint = approval::capability_fingerprint(manifest); + + if batch { + approval::approve(home, &principal, &manifest.package.name, fingerprint) + .with_context(|| format!("recording auto-approval for '{capsule_id}'"))?; + return Ok(()); + } + + // Nothing to gate: a capsule that declares no capabilities and no IPC + // patterns is inert by nature. Still record an approval so its (empty) + // fingerprint is pinned and `capsule list` shows it as approved. + let declared = describe_declared_capabilities(manifest); + if declared.is_empty() { + approval::approve(home, &principal, &manifest.package.name, fingerprint) + .with_context(|| format!("recording approval for '{capsule_id}'"))?; + return Ok(()); + } + + eprintln!(); + eprintln!("'{capsule_id}' requests these capabilities:"); + for line in &declared { + eprintln!(" - {line}"); + } + eprintln!(); + + if prompt_yes_no(&format!("Approve and activate '{capsule_id}'?")) { + approval::approve(home, &principal, &manifest.package.name, fingerprint) + .with_context(|| format!("recording approval for '{capsule_id}'"))?; + eprintln!("Approved. '{capsule_id}' will be active when the daemon loads it."); + } else { + eprintln!( + "Declined. '{capsule_id}' is installed but INERT (no capabilities).\n \ + Run `astrid capsule approve {capsule_id}` to activate it later." + ); + } + Ok(()) +} + +/// `astrid capsule approve `: approve an installed-but-inert capsule for +/// the install principal at its current capability fingerprint, then nudge a +/// running daemon to reload it. +/// +/// # Errors +/// +/// Returns an error if the capsule is not installed, its manifest cannot be +/// read, or the approval record cannot be written. +pub(crate) async fn run(name: &str, workspace: bool) -> anyhow::Result<()> { + ensure_valid_capsule_name(name)?; + let home = AstridHome::resolve()?; + let target_dir = super::install::resolve_target_dir(&home, name, workspace)?; + let manifest_path = target_dir.join("Capsule.toml"); + if !manifest_path.exists() { + bail!( + "capsule '{name}' is not installed (no {})", + manifest_path.display() + ); + } + let manifest = astrid_capsule::discovery::load_manifest(&manifest_path) + .with_context(|| format!("reading manifest for '{name}'"))?; + + let principal = astrid_capsule_install::install_principal(); + let fingerprint = approval::capability_fingerprint(&manifest); + + let declared = describe_declared_capabilities(&manifest); + if declared.is_empty() { + eprintln!("'{name}' declares no capabilities — approving (it is inert by nature)."); + } else { + eprintln!("'{name}' requests these capabilities:"); + for line in &declared { + eprintln!(" - {line}"); + } + } + + approval::approve(&home, &principal, &manifest.package.name, fingerprint) + .with_context(|| format!("recording approval for '{name}'"))?; + eprintln!("Approved '{name}'."); + + // Best-effort: if a daemon is running, hot-reload so the now-approved + // capabilities take effect without a restart. Silent when no daemon is up. + let reload = [name.to_string()]; + super::live_load::nudge_daemon_reload(&reload).await; + Ok(()) +} + +/// Human-readable lines describing the SECURITY-RELEVANT declared capabilities +/// (fs / net / `host_process` / identity / uplink / ipc). Empty when the capsule +/// declares none — the signal that there is nothing to gate. +fn describe_declared_capabilities(manifest: &CapsuleManifest) -> Vec { + let caps = &manifest.capabilities; + let mut lines = Vec::new(); + + if !caps.net.is_empty() { + lines.push(format!("network egress: {}", caps.net.join(", "))); + } + if !caps.net_connect.is_empty() { + lines.push(format!("outbound TCP: {}", caps.net_connect.join(", "))); + } + if !caps.net_bind.is_empty() { + lines.push(format!("socket bind: {}", caps.net_bind.join(", "))); + } + if !caps.fs_read.is_empty() { + lines.push(format!("filesystem read: {}", caps.fs_read.join(", "))); + } + if !caps.fs_write.is_empty() { + lines.push(format!("filesystem write: {}", caps.fs_write.join(", "))); + } + if !caps.host_process.is_empty() { + let persist = if caps.allow_persistent { + " (incl. persistent)" + } else { + "" + }; + lines.push(format!( + "host process exec{persist}: {}", + caps.host_process.join(", ") + )); + } + if !caps.identity.is_empty() { + lines.push(format!("identity ops: {}", caps.identity.join(", "))); + } + if !caps.kv.is_empty() { + lines.push(format!("KV store scopes: {}", caps.kv.join(", "))); + } + if caps.uplink { + lines.push("uplink (binds a socket, may publish as other principals)".to_string()); + } + if caps.allow_prompt_injection { + lines.push("system-prompt injection".to_string()); + } + + let mut publish = manifest.effective_ipc_publish_patterns(); + publish.sort_unstable(); + if !publish.is_empty() { + lines.push(format!("IPC publish: {}", publish.join(", "))); + } + let mut subscribe = manifest.effective_ipc_subscribe_patterns(); + subscribe.sort_unstable(); + if !subscribe.is_empty() { + lines.push(format!("IPC subscribe: {}", subscribe.join(", "))); + } + + lines +} + +/// Prompt the operator with a yes/no question on stderr. Defaults to NO (the +/// fail-secure direction) on an empty entry or read error. +fn prompt_yes_no(question: &str) -> bool { + eprint!("{question} [y/N]: "); + let _ = std::io::Write::flush(&mut std::io::stderr()); + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_err() { + return false; + } + matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes") +} + +/// Reject a malformed capsule name before it is used as a path component in the +/// capsule directory or the approval store (#995). +/// +/// Enforces the same identifier rules as install ([`astrid_capsule::capsule::CapsuleId`]): +/// lowercase alphanumeric and hyphens only. Without this, an operator typo like +/// `astrid capsule approve ../../evil` would write an approval record outside the +/// approvals directory. +fn ensure_valid_capsule_name(name: &str) -> anyhow::Result<()> { + astrid_capsule::capsule::CapsuleId::new(name) + .map(|_| ()) + .map_err(|e| anyhow::anyhow!("invalid capsule name '{name}': {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_capsule_names_accepted() { + assert!(ensure_valid_capsule_name("react").is_ok()); + assert!(ensure_valid_capsule_name("openai-compat").is_ok()); + assert!(ensure_valid_capsule_name("a1").is_ok()); + } + + #[test] + fn path_traversal_names_rejected() { + // The security-critical cases: a name that would escape the approvals dir. + assert!(ensure_valid_capsule_name("../../evil").is_err()); + assert!(ensure_valid_capsule_name("foo/bar").is_err()); + assert!(ensure_valid_capsule_name("..").is_err()); + assert!(ensure_valid_capsule_name("a/../../b").is_err()); + assert!(ensure_valid_capsule_name("").is_err()); + } +} diff --git a/crates/astrid-cli/src/commands/capsule/install.rs b/crates/astrid-cli/src/commands/capsule/install.rs index 50dc5f9fe..bd149d0d7 100644 --- a/crates/astrid-cli/src/commands/capsule/install.rs +++ b/crates/astrid-cli/src/commands/capsule/install.rs @@ -852,10 +852,10 @@ fn finish_install(output: &InstallOutput, home: &AstridHome) -> anyhow::Result` verbs it adds so the operator knows what just became - // invocable. Printed adjacent to the other manifest-derived notices. + // Visibility: if the capsule declares any `kind = "cli"` commands, list + // the new top-level `astrid capsule ` verbs it adds so the operator + // knows what just became invocable. Printed adjacent to the other + // manifest-derived notices. let capsule_id = output.target_dir.file_name().map_or_else( || "capsule".to_string(), |n| n.to_string_lossy().into_owned(), @@ -908,6 +908,14 @@ fn finish_install(output: &InstallOutput, home: &AstridHome) -> anyhow::Result`. + super::approve::record_install_approval(home, &capsule_id, &manifest, batch)?; + Ok(capsule_id) } diff --git a/crates/astrid-cli/src/commands/capsule/mod.rs b/crates/astrid-cli/src/commands/capsule/mod.rs index e9a050015..c48dd13c6 100644 --- a/crates/astrid-cli/src/commands/capsule/mod.rs +++ b/crates/astrid-cli/src/commands/capsule/mod.rs @@ -1,5 +1,6 @@ //! Capsule management commands - install, list, and inspect capsules. +pub(crate) mod approve; pub(crate) mod build; pub(crate) mod config; pub(crate) mod deps; diff --git a/crates/astrid-cli/src/commands/capsule/remove.rs b/crates/astrid-cli/src/commands/capsule/remove.rs index bcc230e0a..4a65887b2 100644 --- a/crates/astrid-cli/src/commands/capsule/remove.rs +++ b/crates/astrid-cli/src/commands/capsule/remove.rs @@ -54,6 +54,11 @@ pub(crate) fn remove_capsule( std::fs::remove_dir_all(&target_dir) .with_context(|| format!("failed to remove {}", target_dir.display()))?; + // Clear the install-time capability approval (#995) so a removed capsule's + // approval cannot silently carry over to a different capsule later + // reinstalled under the same id. + clear_capsule_approval(&home, name); + // Only delete user configuration (API keys, env vars) with --purge. // By default, env.json is preserved so reinstall skips prompting. if purge { @@ -79,6 +84,28 @@ pub(crate) fn remove_capsule( Ok(()) } +/// Best-effort removal of the install-time capability approval record for +/// `name` under the install principal (#995). +/// +/// Called on every uninstall so a removed capsule's approval does not silently +/// carry over to a different capsule later reinstalled under the same id. A +/// failure is logged, never propagated: a stale record is non-fatal (the +/// load-time fingerprint check rejects a different capability surface anyway), +/// and the capsule directory is already gone. +fn clear_capsule_approval(home: &AstridHome, name: &str) { + if let Err(e) = astrid_capsule::security::approval::remove( + home, + astrid_capsule_install::install_principal(), + name, + ) { + tracing::warn!( + capsule = %name, + error = %e, + "failed to remove capability approval record during uninstall" + ); + } +} + /// A blocked removal: the target capsule is the sole provider of a capability /// that another capsule requires. struct RemovalBlocked { @@ -363,6 +390,37 @@ mod tests { ); } + #[test] + fn clear_capsule_approval_removes_record_on_uninstall() { + use astrid_capsule::security::approval; + + let tmp = tempfile::tempdir().unwrap(); + let home = AstridHome::from_path(tmp.path()); + let principal = astrid_capsule_install::install_principal(); + let fp = approval::CapabilityFingerprint::new("deadbeef"); + + // Approve, confirm it sticks, then run the uninstall cleanup. + approval::approve(&home, &principal, "gone", &fp).unwrap(); + assert!(approval::is_approved(&home, &principal, "gone", &fp)); + + clear_capsule_approval(&home, "gone"); + + // The approval must NOT survive uninstall: a different capsule later + // reinstalled as "gone" must not inherit the old trust. + assert!( + !approval::is_approved(&home, &principal, "gone", &fp), + "uninstall must clear the capability approval record" + ); + } + + #[test] + fn clear_capsule_approval_is_noop_when_absent() { + let tmp = tempfile::tempdir().unwrap(); + let home = AstridHome::from_path(tmp.path()); + // No record present — must not panic or error. + clear_capsule_approval(&home, "never-approved"); + } + #[test] fn purge_with_no_env_file_is_noop() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/astrid-cli/src/commands/distro/trust.rs b/crates/astrid-cli/src/commands/distro/trust.rs index e93f938e7..ca63fffbc 100644 --- a/crates/astrid-cli/src/commands/distro/trust.rs +++ b/crates/astrid-cli/src/commands/distro/trust.rs @@ -353,4 +353,32 @@ mod tests { .unwrap_err(); assert!(err.to_string().contains("invalid"), "got: {err}"); } + + /// Regression for #995: there is no compiled-in **blessed trust root**. + /// + /// "Distro-blessed" capability acceptance needs a verifiable anchor or it + /// becomes an approval-bypass primitive. Today [`OFFICIAL_KEYS`] is empty by + /// deliberate decision (the cryptographic blessed-trust-root is tracked on + /// #995/#991), so no install source is treated as blessed: `is_official` + /// returns false for every key and even official distros take the TOFU path + /// like any third party. + /// + /// When a real official key is populated, `is_official` must return true for + /// it — update this test to assert that instead of emptiness, and wire the + /// blessed-set check into install-time capability acceptance. + #[test] + fn regression_995_no_compiled_in_blessed_trust_root() { + assert!( + OFFICIAL_KEYS.is_empty(), + "#995: a blessed trust root is now populated — wire the blessed-set \ + check into install-time capability acceptance and update this test", + ); + // A well-formed key is NOT recognised as official: nothing is blessed. + let kp = KeyPair::generate(); + let key = sign::pubkey_to_wire(&kp.export_public_key()); + assert!( + !is_official(&key), + "no key can be official while OFFICIAL_KEYS is empty", + ); + } } diff --git a/crates/astrid-cli/src/dispatch.rs b/crates/astrid-cli/src/dispatch.rs index 3a756a199..8c6d9f18f 100644 --- a/crates/astrid-cli/src/dispatch.rs +++ b/crates/astrid-cli/src/dispatch.rs @@ -306,6 +306,10 @@ async fn dispatch_capsule(command: crate::cli::CapsuleCommands) -> Result { + commands::capsule::approve::run(&name, workspace).await?; + Ok(ExitCode::SUCCESS) + }, CapsuleCommands::Remove { name, workspace, diff --git a/crates/astrid-core/src/dirs.rs b/crates/astrid-core/src/dirs.rs index 7b081a23e..4463e13b4 100644 --- a/crates/astrid-core/src/dirs.rs +++ b/crates/astrid-core/src/dirs.rs @@ -484,6 +484,22 @@ impl PrincipalHome { pub fn env_dir(&self) -> PathBuf { self.root.join(".config").join("env") } + + /// Install-time capability approvals (`.config/approvals/`). + /// + /// Operator-owned, capsule-unreachable trust records: one + /// `.json` per approved capsule, each pinning the + /// capability fingerprint the operator approved. Lives under + /// `.config/` (NOT `.local/capsules/`) for the same reason as + /// `env_dir`: a capsule's own directory is copyable furniture and is + /// reachable from inside the sandbox via `home://`, so an approval + /// stored there could be forged or carried across principals. The + /// `.config/` tree is operator-written and never mounted into a + /// guest, so a runtime capsule cannot write its own approval. + #[must_use] + pub fn approvals_dir(&self) -> PathBuf { + self.root.join(".config").join("approvals") + } } // ── WorkspaceDir (per-project) ─────────────────────────────────────────── diff --git a/crates/astrid-core/src/kernel_api/mod.rs b/crates/astrid-core/src/kernel_api/mod.rs index db359ee74..70f95cebc 100644 --- a/crates/astrid-core/src/kernel_api/mod.rs +++ b/crates/astrid-core/src/kernel_api/mod.rs @@ -211,8 +211,8 @@ impl CommandKind { /// `CapsuleCommands` variant's clap name appears here; if you add a /// built-in `astrid capsule` subcommand, add its name here too. pub const RESERVED_CAPSULE_VERBS: &[&str] = &[ - "new", "install", "update", "list", "remove", "tree", "deps", "build", "config", "show", "run", - "help", + "new", "install", "update", "list", "approve", "remove", "tree", "deps", "build", "config", + "show", "run", "help", ]; /// Information about a registered capsule command. diff --git a/crates/astrid-core/src/principal.rs b/crates/astrid-core/src/principal.rs index 01561345c..689707be4 100644 --- a/crates/astrid-core/src/principal.rs +++ b/crates/astrid-core/src/principal.rs @@ -130,6 +130,15 @@ impl From for String { } } +impl From<&PrincipalId> for PrincipalId { + /// Clone a borrowed `PrincipalId`. Lets generic `impl Into` + /// parameters accept a `&PrincipalId` (which most call sites hold) without + /// the caller writing `.clone()`. + fn from(id: &PrincipalId) -> Self { + id.clone() + } +} + impl TryFrom for PrincipalId { type Error = PrincipalIdError; diff --git a/crates/astrid-kernel/src/kernel_router/install.rs b/crates/astrid-kernel/src/kernel_router/install.rs index 342b47709..8cee4f7cc 100644 --- a/crates/astrid-kernel/src/kernel_router/install.rs +++ b/crates/astrid-kernel/src/kernel_router/install.rs @@ -115,6 +115,25 @@ pub(super) async fn handle_install_capsule( Err(e) => return KernelResponse::Error(format!("install task panicked: {e}")), }; + // The admin/gateway install path is trusted (it required an authenticated + // admin principal to reach this handler), so auto-approve the capsule's + // declared capabilities (#995). Without this the freshly-installed capsule + // would load inert on the `load_all_capsules` call below. Best-effort: a + // failure here only means the capsule loads inert until an operator runs + // `astrid capsule approve`, which is the fail-secure direction. Off the + // async executor — it re-reads the manifest and writes the record (blocking + // `std::fs`), the same way the install above runs in `spawn_blocking`. + { + let home = home.clone(); + let target_dir = output.target_dir.clone(); + if let Err(e) = + tokio::task::spawn_blocking(move || auto_approve_admin_install(&home, &target_dir)) + .await + { + tracing::warn!(error = %e, "admin install: auto-approve task failed"); + } + } + // Pick up the new capsule without a daemon restart. The loader // is idempotent on already-registered IDs. kernel.load_all_capsules().await; @@ -122,6 +141,50 @@ pub(super) async fn handle_install_capsule( KernelResponse::Success(install_output_json(&output)) } +/// Auto-approve the capability fingerprint of the capsule just installed under +/// `target_dir` for the install principal (#995). The admin install path is +/// trusted, so its capsules become active without an interactive prompt. +fn auto_approve_admin_install(home: &astrid_core::dirs::AstridHome, target_dir: &std::path::Path) { + let capsule_id = target_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()); + let Some(capsule_id) = capsule_id else { + tracing::warn!("admin install: target dir has no name; cannot record approval"); + return; + }; + let manifest_path = target_dir.join("Capsule.toml"); + let manifest = match astrid_capsule::discovery::load_manifest(&manifest_path) { + Ok(m) => m, + Err(e) => { + tracing::warn!( + capsule = %capsule_id, + error = %e, + "admin install: could not re-read manifest to record approval; \ + capsule will load inert until approved" + ); + return; + }, + }; + let fingerprint = astrid_capsule::security::approval::capability_fingerprint(&manifest); + let principal = astrid_capsule_install::install_principal(); + // Key on the manifest's package name — the exact id the engine consults at + // load — not the on-disk directory name, so they can never diverge. + if let Err(e) = astrid_capsule::security::approval::approve( + home, + &principal, + &manifest.package.name, + fingerprint, + ) { + tracing::warn!( + capsule = %capsule_id, + %principal, + error = %e, + "admin install: failed to record capability approval; \ + capsule will load inert until approved" + ); + } +} + fn install_output_json(o: &InstallOutput) -> serde_json::Value { serde_json::json!({ "target_dir": o.target_dir.display().to_string(), diff --git a/crates/astrid-kernel/src/lib.rs b/crates/astrid-kernel/src/lib.rs index 18447a4cd..744212666 100644 --- a/crates/astrid-kernel/src/lib.rs +++ b/crates/astrid-kernel/src/lib.rs @@ -646,6 +646,31 @@ impl Kernel { use astrid_capsule::toposort::toposort_manifests; use astrid_core::dirs::AstridHome; + // One-time grandfather migration (#995): approve every already-installed + // capsule at its current fingerprint BEFORE the load loop, so upgrading + // to the install-time capability gate does not strip capabilities from + // existing setups (no approval records exist yet). Guarded by a marker + // file under `etc/`, so this is a cheap no-op stat after the first boot. + // Offloaded to the blocking pool (synchronous directory scans) and + // awaited so the grandfather records exist before any capsule loads and + // consults them. Runs ahead of discovery deliberately. + if let Ok(home) = AstridHome::resolve() { + let home_for_migration = home.clone(); + if let Err(e) = tokio::task::spawn_blocking(move || { + astrid_capsule::security::approval::migrate_grandfather_approvals( + &home_for_migration, + ); + }) + .await + { + tracing::warn!( + error = %e, + "grandfather approval migration task panicked; pre-existing \ + capsules may load inert until approved" + ); + } + } + // Discovery paths in priority order: principal > workspace. let mut paths = Vec::new(); if let Ok(home) = AstridHome::resolve() {