Skip to content

feat(capsule): install-time capability-approval gate — manual capsules inert until approved#1066

Open
joshuajbouw wants to merge 9 commits into
mainfrom
test/install-trust-gaps-995
Open

feat(capsule): install-time capability-approval gate — manual capsules inert until approved#1066
joshuajbouw wants to merge 9 commits into
mainfrom
test/install-trust-gaps-995

Conversation

@joshuajbouw

Copy link
Copy Markdown
Contributor

Linked Issue

Closes #1065. Refs #995, #991, #964. (The cryptographic distro-blessed exemption via OFFICIAL_KEYS remains 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

  • FingerprintCapabilityFingerprint, a serde-transparent newtype = BLAKE3 over the canonical security-relevant declared set (full CapabilitiesDef + sorted effective IPC publish/subscribe patterns). Any escalation changes it → re-approval required. (astrid-capsule/src/security/approval.rs)
  • Approval store — per principal at <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)
  • Load consult (enforcement) — the engine consults the store immediately before constructing ManifestSecurityGate; unapproved → manifest.capabilities zeroed in place so the gate, capability_names, has_uplink, and the net_bind socket listener all see the empty set. Extracted as a pure effective_capabilities(declared, approved) for unit testing. Audit-tagged warning on inert load. (astrid-capsule/src/engine/wasm/mod.rs)
  • Auto vs gated writes — manual astrid capsule install displays 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)
  • Grandfather migration — one-time, marker-guarded (etc/.capability-approvals-migrated, outside the home:// 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 a CapsuleId so a traversal name cannot escape the approvals dir; nudges a daemon hot-reload. (astrid-cli/.../approve.rs)
  • Uninstall hygieneastrid capsule remove clears the approval record so trust cannot carry to a different capsule reinstalled under the same id. (astrid-cli/.../remove.rs)
  • Defense in depthManifestSecurityGate hard-denies any guest fs read/write resolving inside .config/approvals/, regardless of the manifest allowlist.
  • RED regression tests from the investigation are retained (trust::regression_995_no_compiled_in_blessed_trust_root deliberately stays — OFFICIAL_KEYS is still empty; manifest_gate test 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 one model_discovery failure is a pre-existing network test that cannot bind under the build sandbox; passes with network access / in CI; unrelated to this change).
  • New unit tests: fingerprint stability/sensitivity, store approve/is_approved/remove (+ corrupt/mismatch/absent), effective_capabilities, grandfather migration (approves pre-existing, idempotent, re-approval on change), the .config/approvals hard-deny even with a home:// write scope, furniture auto-approve, uninstall clears approval, and approve arg path-traversal rejection.

Adversarial review

Reviewed by an independent agent + inline. Findings addressed in this PR: wired approval::remove at uninstall; sanitized the approve <name> argument; added kv to the operator disclosure; corrected the migration-marker doc.

Follow-ups (not in this PR)

  • Gate IPC publish/subscribe patterns for inert capsules (currently only capabilities is zeroed) — verify the load-readiness interaction.
  • Extend the operator-config hard-deny to the whole .config/ tree (incl. .config/env secrets) and across all principal homes (a capsule with broad absolute fs_write could otherwise reach another principal's config).
  • Cryptographic distro-blessed exemption via populating OFFICIAL_KEYSInstall-time trust model: distro-blessed vs permission-required manual installs (blocked on signing) #995.

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.
Copilot AI review requested due to automatic review settings June 24, 2026 21:10
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, 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

  • Install-time Approval Gate: Implemented a fail-secure gate where capsules load in an inert state (zero host-fn access) until an operator explicitly approves their declared capabilities.
  • Capability Fingerprinting: Introduced CapabilityFingerprint using BLAKE3 to track security-relevant capability definitions and IPC patterns, ensuring that any escalation requires fresh approval.
  • Approval Store: Created an operator-owned, capsule-unreachable store for approval records located in the principal's .config/ directory to prevent forgery or unauthorized modification.
  • Grandfather Migration: Added a one-time, marker-guarded migration to automatically approve existing capsules, preventing service disruption during the upgrade.
  • CLI Management: Added astrid capsule approve <id> to allow operators to activate inert capsules and updated the install flow to handle interactive and batch approval prompts.
Using Gemini Code Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/astrid-capsule/src/security/approval.rs
Comment thread crates/astrid-capsule/src/security/approval.rs Outdated
Comment thread crates/astrid-capsule/src/engine/wasm/mod.rs Outdated
Comment thread crates/astrid-kernel/src/kernel_router/install.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/astrid-cli/src/commands/capsule/approve.rs
Comment thread crates/astrid-capsule/src/security/approval.rs
Comment thread crates/astrid-capsule/src/security/approval.rs
Comment thread crates/astrid-capsule/src/security/approval.rs
Comment thread crates/astrid-kernel/src/lib.rs Outdated
Comment thread crates/astrid-kernel/src/kernel_router/install.rs
Comment thread crates/astrid-cli/src/commands/distro/trust.rs Outdated
Comment thread crates/astrid-cli/src/commands/capsule/remove.rs
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.
@joshuajbouw

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews. Addressed in 69bc5d3:

Path-traversal hardening (Copilot ×3, library-level). approval::{approve, is_approved, remove} now validate capsule_id as a CapsuleId before building any path. The engine consults is_approved(manifest.package.name) before CapsuleId validation in the load path, so the store functions must self-defend rather than trust callers: is_approved fails secure (invalid id → unapproved, no path built), approve/remove return InvalidInput. Added approve_rejects_traversal_capsule_id, is_approved_false_for_invalid_capsule_id, remove_rejects_traversal_capsule_id.

Key alignment (Copilot, install.rs). Every approval write — admin/gateway install, furniture seeding, grandfather migration, CLI install + approve — now keys on manifest.package.name, the exact id the engine reads at load, instead of the on-disk directory name. They can no longer diverge.

spawn_blocking (Gemini ×2). The load-time is_approved read and the admin-install auto-approve now run off the async executor (blocking std::fs), matching the surrounding unpack_and_install/migration pattern. Both fail secure on a task error.

Diagnostics. is_approved now logs unexpected (non-NotFound) read errors instead of swallowing them (Gemini), and the grandfather migration logs a JoinError on panic like sync_all_principal_furniture (Copilot).

Minor. Bound the capsule-reload argument to a local before the await (Copilot); reworded the trust-root test comment's #964 reference to #995/#991, which is where the blessed-trust-root work actually lives (Copilot).

Not applied — already mitigated: Gemini's .. guard for path_is_in_approval_store. The suggested form returns false (not-in-store → allow) for a .. path, which is fail-open for a deny check. More importantly, the sole caller (ManifestSecurityGate::check_fs_permission) rejects any path with a ParentDir component before this helper runs, so there is no bypass. I documented that precondition on the function instead.

fmt/clippy/tests green (the model_discovery failure under the build sandbox is a pre-existing network test that binds a local server; it passes with network access / in CI).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Install-time capability-approval gate: manual capsules inert until approved

2 participants