Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

99 changes: 99 additions & 0 deletions crates/astrid-capsule-install/src/furniture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion crates/astrid-capsule-install/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
78 changes: 77 additions & 1 deletion crates/astrid-capsule/src/engine/wasm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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());
Expand Down
Loading
Loading