Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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.

94 changes: 94 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,68 @@ 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);
if let Err(e) =
astrid_capsule::security::approval::approve(home, target, &capsule_id, 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 +336,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};
60 changes: 59 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,59 @@ 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) => crate::security::approval::is_approved(
&approval_home,
&ctx.principal,
&manifest.package.name,
&capability_fp,
),
Comment thread
joshuajbouw marked this conversation as resolved.
Outdated
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