feat(capsule): install-time capability-approval gate — manual capsules inert until approved by joshuajbouw · Pull Request #1066 · astrid-runtime/astrid · GitHub
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
Member

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

@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
Member Author

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