feat(capsule): install-time capability-approval gate — manual capsules inert until approved#1066
feat(capsule): install-time capability-approval gate — manual capsules inert until approved#1066joshuajbouw wants to merge 9 commits into
Conversation
Two characterization tests pin the current behaviour behind #995's install-time trust model and serve as the flip-point for the eventual gate: - trust::regression_995_no_compiled_in_blessed_trust_root — OFFICIAL_KEYS is empty, so no install source is blessed; is_official() is false for every key. - manifest_gate::regression_995_declared_capabilities_effective_without_approval — a capsule's self-declared net/fs capabilities are honoured verbatim with no operator approval and no install-source input (the gate has no trust parameter). Both pass today (the gaps are real). Each carries a doc comment describing the behaviour the fix must enforce, at which point the assertions flip. Refs #995, #991, #964.
…e approve verb (#995) Foundation for the install-time capability-approval gate: - PrincipalHome::approvals_dir() -> .config/approvals/, mirroring env_dir() (operator-owned, never mounted into a guest, not carried by furniture copy). - From<&PrincipalId> for PrincipalId so generic impl Into<PrincipalId> params accept a borrowed principal without caller-side .clone(). - Add "approve" to RESERVED_CAPSULE_VERBS so a capsule cannot shadow the new built-in verb. Refs #995
…r approval (#995) A capsule manifest is untrusted input; its declared [capabilities] must not become effective at load without an out-of-band operator approval. - security::approval: CapabilityFingerprint newtype (serde-transparent BLAKE3 hex over the canonical CapabilitiesDef + sorted IPC publish/subscribe patterns); a per-principal on-disk approval store under .config/approvals/<id>.json (atomic temp+rename); is_approved (exact fingerprint match, fail-secure on missing/corrupt/mismatch); approve/remove; effective_capabilities(declared, approved) pure decision helper; migrate_grandfather_approvals (one-time, etc/ marker-guarded). - Engine load consult: before building ManifestSecurityGate, resolve the fingerprint and consult the store for the load-time owner principal; if unapproved, zero manifest.capabilities IN PLACE so every downstream derivation (gate, capability_names, has_uplink, net_bind listener) sees the empty set and the capsule loads inert. Emits an audit-tagged warn. Home resolve failure -> inert (fail-secure). - ManifestSecurityGate hard-denies any guest read/write resolving inside .config/approvals/ regardless of the manifest allowlist (defense in depth: an approved capsule cannot forge or escalate its own approval). Public API is generic (impl AsRef<Path>/AsRef<str>/Into<PrincipalId>/ Into<CapabilityFingerprint>). The pre-existing manifest_gate regression test is re-pointed at the new upstream enforcement (the gate honours exactly the set it is built from; the unapproved set is zeroed before construction). Refs #995
…ncipal (#995) Furniture is the blessed introspection set, but the mirror copies the capsule DIRECTORY and never the approval store (which lives under .config/, the hard-excluded secret boundary). A freshly-seeded principal would therefore see every furniture capsule as unapproved -> inert, regressing onboarding. materialize_principal_furniture now approves each mirrored capsule for the target principal at its current fingerprint after the mirror. Best-effort per capsule (an unreadable manifest is skipped, never failing the sync). Also re-export install_principal so the kernel admin handler can record approvals for the install principal. Refs #995
…load (#995) - handle_install_capsule: the admin/gateway install path is trusted, so after a successful install it records a capability approval for the install principal at the capsule's current fingerprint. Without this the freshly-installed capsule would load inert on the subsequent load_all_capsules. Best-effort: failure only means inert-until-approved (fail-secure). - load_all_capsules: run the one-time grandfather migration BEFORE the load loop (offloaded to the blocking pool, awaited so the records exist before any capsule consults them). Marker-guarded under etc/, so it is a cheap no-op stat after first boot — existing setups are not stripped of capabilities on upgrade. Refs #995
…approve` (#995) - finish_install records the install-time approval for the install principal: batch / non-interactive (astrid init, offline distro) auto-approves; an interactive manual install displays the declared capability surface (fs/net/host_process/identity/uplink/ipc) and prompts. A decline leaves the capsule inert and prints how to approve it later (fail-secure default: NO). - `astrid capsule approve <id>` activates an installed-but-inert capsule: loads its manifest, shows the declared surface, records the approval, and best-effort nudges a running daemon to hot-reload so it takes effect without a restart. Refs #995
…lose kv (#995) Adversarial-review follow-ups before the capability-approval gate ships: - Uninstall now clears the approval record (astrid-capsule-install::install_principal) so a removed capsule's trust cannot carry over to a different capsule reinstalled under the same id. The approval::remove fn was implemented but unwired. - 'astrid capsule approve <name>' now validates the name as a CapsuleId (lowercase alphanumeric + hyphens) before it becomes a path component, so a traversal name cannot write an approval record outside the approvals dir. - The operator approval disclosure now lists declared kv scopes (was omitted though included in the fingerprint). - Corrected the grandfather-migration marker doc comment to match actual behaviour. Refs #995.
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces a critical security hardening feature that enforces operator approval for all capsule capabilities at install time. By ensuring that untrusted author-provided manifests cannot automatically gain host access, the system moves to a more secure, opt-in model. The implementation includes robust fingerprinting to detect capability changes, a secure on-disk approval store, and a migration path for existing installations to ensure backward compatibility. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Capsules loaded, silent and still, Waiting for the operator's will. With a fingerprint check and a gate so tight, Security shines in the morning light. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request implements an install-time capability approval gate for capsules to ensure that manifest-declared capabilities are inert until approved by an operator. It introduces capability fingerprinting, an on-disk approval store, a grandfather migration for existing capsules, and the astrid capsule approve CLI command. The feedback highlights critical security and performance improvements, including addressing a path traversal vulnerability in the approval store path check, logging unexpected I/O errors during approval checks, and wrapping synchronous file operations in tokio::task::spawn_blocking within async contexts to prevent thread starvation.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
Pull request overview
Implements an install-time capability approval gate so manually installed capsules load inert (zero capabilities) until an operator explicitly approves the manifest-declared capability surface, while trusted install flows auto-approve and existing installations are grandfather-migrated to avoid breakage on upgrade.
Changes:
- Adds capability fingerprinting + per-principal approval records under
.config/approvals/, with fail-secure load-time enforcement (unapproved → empty capabilities). - Introduces
astrid capsule approve <id>plus install-time prompting/auto-approval behavior across CLI, admin/gateway installs, and furniture seeding. - Adds one-time grandfather migration + defense-in-depth hard-deny of guest access to the approvals store, and uninstall hygiene to remove approvals.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/astrid-kernel/src/lib.rs | Runs one-time grandfather migration before capsule discovery/load. |
| crates/astrid-kernel/src/kernel_router/install.rs | Auto-approves capability fingerprint for admin/gateway installs. |
| crates/astrid-core/src/principal.rs | Adds From<&PrincipalId> for PrincipalId to ease Into<PrincipalId> usage. |
| crates/astrid-core/src/kernel_api/mod.rs | Reserves approve verb to prevent capsule CLI shadowing. |
| crates/astrid-core/src/dirs.rs | Adds PrincipalHome::approvals_dir() path helper. |
| crates/astrid-cli/src/dispatch.rs | Wires the new capsule approve command. |
| crates/astrid-cli/src/commands/distro/trust.rs | Adds regression test asserting no compiled-in blessed trust root. |
| crates/astrid-cli/src/commands/capsule/remove.rs | Removes approval record on uninstall + tests. |
| crates/astrid-cli/src/commands/capsule/mod.rs | Exposes new approve command module. |
| crates/astrid-cli/src/commands/capsule/install.rs | Hooks install-time approval prompting/auto-approval into CLI installs. |
| crates/astrid-cli/src/commands/capsule/approve.rs | Implements astrid capsule approve <id> and install-time prompt rendering. |
| crates/astrid-cli/src/cli.rs | Adds clap surface for astrid capsule approve. |
| crates/astrid-capsule/src/security/mod.rs | Exports new security::approval module. |
| crates/astrid-capsule/src/security/manifest_gate.rs | Hard-denies guest fs access to .config/approvals/ (defense in depth). |
| crates/astrid-capsule/src/security/manifest_gate_tests.rs | Adds tests for approval-store hard-deny + regression wiring. |
| crates/astrid-capsule/src/security/approval.rs | Adds fingerprinting, approval store (approve/is_approved/remove), migration, and helpers. |
| crates/astrid-capsule/src/security/approval_tests.rs | Unit tests for fingerprinting, store behavior, and migration. |
| crates/astrid-capsule/src/engine/wasm/mod.rs | Enforces approval at load by zeroing capabilities before gate construction. |
| crates/astrid-capsule-install/src/lib.rs | Re-exports install_principal. |
| crates/astrid-capsule-install/src/furniture.rs | Auto-approves mirrored furniture capsules for new principals + tests. |
| CHANGELOG.md | Documents the new approval gate and capsule approve command. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Bot review (Gemini + Copilot) follow-ups:
- Path-traversal defense in depth: approval::{approve,is_approved,remove} now validate capsule_id as a CapsuleId before building a path (is_approved fails-secure to unapproved; approve/remove return InvalidInput). The engine consults is_approved(manifest.package.name) before CapsuleId validation, so the library must self-defend. +tests.
- Key alignment: every approval WRITE (admin install, furniture, grandfather, CLI install/approve) now keys on manifest.package.name — the exact id the engine reads at load — instead of the on-disk directory name, so they can never diverge.
- spawn_blocking: the load-time approval read and the admin-install auto-approve now run off the async executor (blocking std::fs), matching the surrounding install pattern; both fail-secure on a task error.
- Diagnostics: is_approved logs unexpected (non-NotFound) read errors; the grandfather migration logs a JoinError on panic.
- Bind the capsule-reload arg to a local before the await; reword a test comment's #964 reference to #995/#991.
Gemini's path_is_in_approval_store '..' suggestion was NOT applied: it is fail-open (treats a '..' path as not-in-store=allow), and the gate already rejects '..' components before this helper runs; documented the precondition instead.
Refs #995.
|
Thanks for the reviews. Addressed in Path-traversal hardening (Copilot ×3, library-level). Key alignment (Copilot,
Diagnostics. Minor. Bound the capsule-reload argument to a local before the Not applied — already mitigated: Gemini's
|
Linked Issue
Closes #1065. Refs #995, #991, #964. (The cryptographic distro-blessed exemption via
OFFICIAL_KEYSremains tracked on #995; hardening follow-ups are listed below.)Summary
A capsule's manifest-declared
[capabilities]are untrusted author input. Before this change they 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 (this is what the RED regression tests on the first commit demonstrated).This PR adds a fail-secure install-time capability-approval gate: a manually-installed capsule loads inert (zero host-fn capabilities) until an operator approves it. Operator-driven flows —
astrid init/offline distro, admin/gateway install, furniture seeding, and pre-existing capsules via a one-time grandfather migration — auto-approve, so onboarding does not regress and nothing bricks on upgrade. This is the permission-required half of #995's model; the cryptographic distro-blessed exemption stays on #995.Changes
CapabilityFingerprint, a serde-transparent newtype = BLAKE3 over the canonical security-relevant declared set (fullCapabilitiesDef+ sorted effective IPC publish/subscribe patterns). Any escalation changes it → re-approval required. (astrid-capsule/src/security/approval.rs)<principal_home>/.config/approvals/<capsule_id>.json(atomic temp+rename), operator-owned and capsule-unreachable (under.config/, not the copyable/guest-reachable capsule dir). Every failure mode (missing/unreadable/unparseable/mismatch/home-unresolved) is fail-secure → unapproved. (approval.rs,astrid-core/src/dirs.rs::approvals_dir)ManifestSecurityGate; unapproved →manifest.capabilitieszeroed in place so the gate,capability_names,has_uplink, and the net_bind socket listener all see the empty set. Extracted as a pureeffective_capabilities(declared, approved)for unit testing. Audit-tagged warning on inert load. (astrid-capsule/src/engine/wasm/mod.rs)astrid capsule installdisplays the declared surface and prompts (default NO); batch/distro, admin/gateway install, and furniture seeding auto-approve. (astrid-cli/.../install.rs,approve.rs;astrid-kernel/.../install.rs;astrid-capsule-install/src/furniture.rs)etc/.capability-approvals-migrated, outside thehome://VFS), runs before the load loop so existing setups are not stripped. (astrid-capsule/.../approval.rs,astrid-kernel/src/lib.rs)astrid capsule approve <id>— activates an installed-but-inert capsule; validates its argument as aCapsuleIdso a traversal name cannot escape the approvals dir; nudges a daemon hot-reload. (astrid-cli/.../approve.rs)astrid capsule removeclears the approval record so trust cannot carry to a different capsule reinstalled under the same id. (astrid-cli/.../remove.rs)ManifestSecurityGatehard-denies any guest fs read/write resolving inside.config/approvals/, regardless of the manifest allowlist.trust::regression_995_no_compiled_in_blessed_trust_rootdeliberately stays —OFFICIAL_KEYSis still empty;manifest_gatetest re-pointed to the enforcement).No WIT / host-ABI change — kernel-internal load consult + per-principal on-disk store + CLI verb. No RFC required.
Test Plan
cargo fmt --all -- --check— clean.cargo clippy --workspace --all-features --all-targets -- -D warnings— clean.cargo test -p astrid -p astrid-capsule— green (the onemodel_discoveryfailure is a pre-existing network test that cannot bind under the build sandbox; passes with network access / in CI; unrelated to this change).effective_capabilities, grandfather migration (approves pre-existing, idempotent, re-approval on change), the.config/approvalshard-deny even with ahome://write scope, furniture auto-approve, uninstall clears approval, andapprovearg path-traversal rejection.Adversarial review
Reviewed by an independent agent + inline. Findings addressed in this PR: wired
approval::removeat uninstall; sanitized theapprove <name>argument; addedkvto the operator disclosure; corrected the migration-marker doc.Follow-ups (not in this PR)
capabilitiesis zeroed) — verify the load-readiness interaction..config/tree (incl..config/envsecrets) and across all principal homes (a capsule with broad absolutefs_writecould otherwise reach another principal's config).OFFICIAL_KEYS— Install-time trust model: distro-blessed vs permission-required manual installs (blocked on signing) #995.