From b4e24cd5dd9f53bfee8e3ea1316769dac4f220ad Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 14 Jul 2026 20:59:44 +0400 Subject: [PATCH 1/4] fix(capsule): preserve device attenuation in dispatch --- CHANGELOG.md | 5 + crates/astrid-capsule/src/access.rs | 404 +++++++++++-- crates/astrid-capsule/src/dispatcher.rs | 92 ++- .../src/dispatcher_device_scope_tests.rs | 546 ++++++++++++++++++ crates/astrid-capsule/src/dispatcher_tests.rs | 4 +- 5 files changed, 939 insertions(+), 112 deletions(-) create mode 100644 crates/astrid-capsule/src/dispatcher_device_scope_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc973d83..8eae7b5b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,11 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. kernel construction, and agent modification cannot remove `default` from the built-in `admin` group. The local CLI retains its intentional active-principal default before requests reach the kernel. Closes #1256. +- **Capsule dispatch now preserves device attenuation.** Stamped events resolve + one authority snapshot before matching; invalid, revoked, or disabled + identities fail closed. `capsule:list` controls global describe visibility, + while exact `capsule:access:any` controls unrestricted execution. Closes + #1239. - **Device attenuation now applies to every kernel authority view.** Capsule, agent, and group inventory checks use the authenticating device scope, and a diff --git a/crates/astrid-capsule/src/access.rs b/crates/astrid-capsule/src/access.rs index 716a62a37..2d6c3a015 100644 --- a/crates/astrid-capsule/src/access.rs +++ b/crates/astrid-capsule/src/access.rs @@ -45,6 +45,7 @@ use arc_swap::ArcSwap; use astrid_capabilities::CapabilityCheck; use astrid_core::GroupConfig; use astrid_core::principal::PrincipalId; +use astrid_core::profile::{DeviceKeyId, DeviceScope, PrincipalProfile}; use tracing::warn; use crate::capsule::CapsuleId; @@ -61,6 +62,9 @@ use crate::topic::TOOL_EXECUTE_PREFIX; /// A capsule command invocation is `cli.v1.command.run.`. const CLI_COMMAND_RUN_PREFIX: &str = "cli.v1.command.run."; +/// Exact internal capability for unrestricted capsule execution. +const CAPSULE_ACCESS_ANY: &str = "capsule:access:any"; + /// Is `topic` part of the **user-invocable surface** that the per-principal /// capsule-access filter gates? Co-located with the resolver because it /// defines *which* topics the grant set applies to. @@ -145,6 +149,43 @@ pub struct CapsuleAccessResolver { groups: Arc>, } +/// Immutable authority inputs for one dispatched event. +pub(crate) struct ResolvedCapsuleAccess { + principal: PrincipalId, + profile: Arc, + groups: Arc, + has_global_capsule_view: bool, + has_unrestricted_capsule_access: bool, +} + +impl ResolvedCapsuleAccess { + #[must_use] + pub(crate) fn principal(&self) -> &PrincipalId { + &self.principal + } + + #[must_use] + pub(crate) fn has_global_capsule_view(&self) -> bool { + self.has_global_capsule_view + } + + #[must_use] + pub(crate) fn has_unrestricted_capsule_access(&self) -> bool { + self.has_unrestricted_capsule_access + } + + #[must_use] + pub(crate) fn is_capsule_allowed(&self, capsule: &CapsuleId) -> bool { + if self.has_unrestricted_capsule_access() { + return true; + } + self.profile + .capsules + .iter() + .any(|granted| granted == capsule.as_str()) + } +} + impl std::fmt::Debug for CapsuleAccessResolver { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // The cache and group config are large and not useful here; only @@ -168,86 +209,115 @@ impl CapsuleAccessResolver { } } - /// Return `true` if `principal` may invoke `capsule`. - /// - /// Resolution order: - /// 1. **Fail-closed identity gate** — a `None`, empty, `anonymous`, or - /// syntactically invalid principal yields `false` (no grant set). - /// 2. **Profile resolve** — a resolution error (malformed TOML, IO - /// error) yields `false`. Never default-allow on error. - /// 3. **Admin bypass** — a caller holding `*` (directly, via a grant, - /// or via an `admin`-group `*`) returns `true` for any capsule. - /// 4. **Grant-set membership** — otherwise, `true` iff the capsule id - /// appears in the principal's `capsules` grant set. - /// - /// `principal` is the kernel-stamped value carried on the originating - /// IPC message — never a capsule- or caller-supplied claim — so a - /// principal cannot forge its way past the filter. + /// Resolve one immutable authority snapshot for a dispatched event. #[must_use] - pub fn is_capsule_allowed(&self, principal: Option<&str>, capsule: &CapsuleId) -> bool { - // (1) Identity gate. `anonymous` is the explicit unauthenticated - // sentinel; treat it like an absent principal — no grant set. - let Some(principal_str) = principal else { - return false; - }; + pub(crate) fn resolve_access( + &self, + principal: Option<&str>, + device_key_id: Option<&str>, + ) -> Option { + let principal_str = principal?; if principal_str.is_empty() || principal_str == "anonymous" { - return false; + return None; } - let Ok(pid) = PrincipalId::new(principal_str) else { - warn!( - security_event = true, - principal = %principal_str, - "Capsule-access check: invalid principal string — deny (fail-closed)" - ); - return false; + let pid = match PrincipalId::new(principal_str) { + Ok(pid) => pid, + Err(error) => { + warn!( + security_event = true, + principal = %principal_str, + %error, + "Capsule-access check: invalid principal string — deny (fail-closed)" + ); + return None; + }, }; - - // (2) Profile resolve. Fail-closed on any error. let profile = match self.profile_cache.resolve(&pid) { - Ok(p) => p, - Err(e) => { + Ok(profile) => profile, + Err(error) => { warn!( security_event = true, principal = %pid, - error = %e, + %error, "Capsule-access check: profile resolution failed — deny (fail-closed)" ); - return false; + return None; + }, + }; + if !profile.enabled { + warn!( + security_event = true, + principal = %pid, + "Capsule-access check: disabled principal — deny (fail-closed)" + ); + return None; + } + let device_scope = match device_key_id { + None => None, + Some(raw_key_id) => { + let key_id = match DeviceKeyId::new(raw_key_id) { + Ok(key_id) => key_id, + Err(error) => { + warn!( + security_event = true, + principal = %pid, + key_id = %raw_key_id, + %error, + "Capsule-access check: invalid device key id — deny (fail-closed)" + ); + return None; + }, + }; + let Some(device) = profile.auth.device_by_typed_key_id(&key_id) else { + warn!( + security_event = true, + principal = %pid, + key_id = %raw_key_id, + "Capsule-access check: device key id is not registered — deny (fail-closed)" + ); + return None; + }; + Some(device.scope.clone()) }, }; - // (3) Admin bypass — reuse the SAME machinery `authorize_request` - // uses. A `*` holder (admin) bypasses the per-principal filter. - let groups = self.groups.load(); - let check = CapabilityCheck::new(profile.as_ref(), groups.as_ref(), pid); - if check.has("*") { - return true; + let groups = self.groups.load_full(); + let mut device_check = CapabilityCheck::new(profile.as_ref(), groups.as_ref(), pid.clone()); + if let Some(scope) = &device_scope { + device_check = device_check.with_device_scope(scope); } + let has_global_capsule_view = device_check.has("capsule:list"); + let has_unrestricted_capsule_access = device_check.has(CAPSULE_ACCESS_ANY); - // (4) Grant-set membership. - profile - .capsules - .iter() - .any(|granted| granted == capsule.as_str()) + Some(ResolvedCapsuleAccess { + principal: pid, + profile, + groups, + has_global_capsule_view, + has_unrestricted_capsule_access, + }) + } + + /// Return whether `principal` may invoke `capsule`. + /// Invalid identities fail closed; exact `capsule:access:any` bypasses the + /// principal's exact capsule grant set. + #[must_use] + pub fn is_capsule_allowed(&self, principal: Option<&str>, capsule: &CapsuleId) -> bool { + self.resolve_access(principal, None) + .is_some_and(|access| access.is_capsule_allowed(capsule)) } /// Return true when the caller may bypass principal-view narrowing. #[must_use] pub fn is_admin(&self, principal: Option<&str>) -> bool { - let Some(principal_str) = principal else { - return false; - }; - if principal_str.is_empty() || principal_str == "anonymous" { - return false; - } - let Ok(pid) = PrincipalId::new(principal_str) else { - return false; - }; - let Ok(profile) = self.profile_cache.resolve(&pid) else { - return false; - }; - let groups = self.groups.load(); - CapabilityCheck::new(profile.as_ref(), groups.as_ref(), pid).has("*") + self.resolve_access(principal, None).is_some_and(|access| { + CapabilityCheck::new( + access.profile.as_ref(), + access.groups.as_ref(), + access.principal.clone(), + ) + .has("*") + }) } } @@ -256,15 +326,26 @@ mod tests { use super::*; use astrid_core::dirs::AstridHome; - use astrid_core::groups::{BUILTIN_ADMIN, GroupConfig}; - use astrid_core::profile::PrincipalProfile; + use astrid_core::groups::{BUILTIN_ADMIN, Group, GroupConfig}; + use astrid_core::profile::{AuthConfig, AuthMethod, DeviceKey, PrincipalProfile}; fn fixture() -> (tempfile::TempDir, AstridHome, CapsuleAccessResolver) { + let (dir, home, _cache, resolver) = fixture_with_cache(); + (dir, home, resolver) + } + + fn fixture_with_cache() -> ( + tempfile::TempDir, + AstridHome, + Arc, + CapsuleAccessResolver, + ) { let dir = tempfile::tempdir().expect("tempdir"); let home = AstridHome::from_path(dir.path()); let cache = Arc::new(PrincipalProfileCache::with_home(home.clone())); let groups = Arc::new(ArcSwap::from_pointee(GroupConfig::builtin_only())); - (dir, home, CapsuleAccessResolver::new(cache, groups)) + let resolver = CapsuleAccessResolver::new(Arc::clone(&cache), groups); + (dir, home, cache, resolver) } fn write(home: &AstridHome, principal: &str, profile: &PrincipalProfile) { @@ -276,6 +357,10 @@ mod tests { CapsuleId::from_static(id) } + fn device(seed: char, scope: DeviceScope) -> DeviceKey { + DeviceKey::new(seed.to_string().repeat(64), scope, None, 0) + } + #[test] fn none_principal_denied() { let (_d, _h, r) = fixture(); @@ -344,4 +429,197 @@ mod tests { std::fs::write(&path, "profile_version = 9999\n").unwrap(); assert!(!r.is_capsule_allowed(Some("broken"), &cap("identity"))); } + + #[test] + fn device_scope_attenuates_admin_bypass() { + let (_d, home, r) = fixture(); + let full = device('a', DeviceScope::Full); + let list_only = device( + 'b', + DeviceScope::Scoped { + allow: vec!["capsule:list".into()], + deny: Vec::new(), + }, + ); + let access_any = device( + 'c', + DeviceScope::Scoped { + allow: vec!["capsule:access:any".into()], + deny: Vec::new(), + }, + ); + let access_denied = device( + 'd', + DeviceScope::Scoped { + allow: vec!["*".into()], + deny: vec!["capsule:access:any".into()], + }, + ); + let ids = [ + full.key_id.clone(), + list_only.key_id.clone(), + access_any.key_id.clone(), + access_denied.key_id.clone(), + ]; + write( + &home, + "root", + &PrincipalProfile { + groups: vec![BUILTIN_ADMIN.to_string()], + capsules: vec!["identity".into()], + auth: AuthConfig { + methods: vec![AuthMethod::Keypair], + public_keys: vec![full, list_only, access_any, access_denied], + }, + ..PrincipalProfile::default() + }, + ); + + let no_device = r.resolve_access(Some("root"), None).unwrap(); + let full = r.resolve_access(Some("root"), Some(&ids[0])).unwrap(); + let list_only = r.resolve_access(Some("root"), Some(&ids[1])).unwrap(); + let access_any = r.resolve_access(Some("root"), Some(&ids[2])).unwrap(); + let denied = r.resolve_access(Some("root"), Some(&ids[3])).unwrap(); + assert!(no_device.is_capsule_allowed(&cap("registry"))); + assert!(full.is_capsule_allowed(&cap("registry"))); + assert!(list_only.has_global_capsule_view()); + assert!(!list_only.is_capsule_allowed(&cap("registry"))); + assert!(access_any.has_unrestricted_capsule_access()); + assert!(access_any.is_capsule_allowed(&cap("registry"))); + assert!(!denied.is_capsule_allowed(&cap("registry"))); + assert!(list_only.is_capsule_allowed(&cap("identity"))); + assert!(denied.is_capsule_allowed(&cap("identity"))); + } + + #[test] + fn global_capsule_view_does_not_bypass_capsule_grants() { + let (_d, home, r) = fixture(); + write( + &home, + "operator", + &PrincipalProfile { + grants: vec!["capsule:list".into()], + capsules: vec!["identity".into()], + ..PrincipalProfile::default() + }, + ); + + let access = r.resolve_access(Some("operator"), None).unwrap(); + assert!(access.has_global_capsule_view()); + assert!(access.is_capsule_allowed(&cap("identity"))); + assert!(!access.is_capsule_allowed(&cap("registry"))); + } + + #[test] + fn disabled_principal_has_no_access_snapshot() { + let (_d, home, r) = fixture(); + write( + &home, + "disabled", + &PrincipalProfile { + enabled: false, + groups: vec![BUILTIN_ADMIN.to_string()], + capsules: vec!["identity".into()], + ..PrincipalProfile::default() + }, + ); + assert!(r.resolve_access(Some("disabled"), None).is_none()); + } + + #[test] + fn group_changes_apply_to_the_next_access_snapshot() { + let dir = tempfile::tempdir().expect("tempdir"); + let home = AstridHome::from_path(dir.path()); + let cache = Arc::new(PrincipalProfileCache::with_home(home.clone())); + let groups = Arc::new(ArcSwap::from_pointee(GroupConfig::builtin_only())); + let resolver = CapsuleAccessResolver::new(cache, Arc::clone(&groups)); + write( + &home, + "operator", + &PrincipalProfile { + groups: vec!["ops".into()], + ..PrincipalProfile::default() + }, + ); + + let before = resolver.resolve_access(Some("operator"), None).unwrap(); + assert!(!before.has_global_capsule_view()); + assert!(!before.has_unrestricted_capsule_access()); + + let promoted = GroupConfig::builtin_only() + .insert_custom_group( + "ops".into(), + Group { + capabilities: vec!["*".into()], + description: None, + unsafe_admin: true, + }, + ) + .unwrap(); + groups.store(Arc::new(promoted)); + let promoted = resolver.resolve_access(Some("operator"), None).unwrap(); + assert!(promoted.has_global_capsule_view()); + assert!(promoted.has_unrestricted_capsule_access()); + + groups.store(Arc::new(GroupConfig::builtin_only())); + let demoted = resolver.resolve_access(Some("operator"), None).unwrap(); + assert!(!demoted.has_global_capsule_view()); + assert!(!demoted.has_unrestricted_capsule_access()); + } + + #[test] + fn malformed_unknown_and_revoked_device_ids_fail_closed() { + let (_d, home, cache, r) = fixture_with_cache(); + let full = device('d', DeviceScope::Full); + let full_id = full.key_id.clone(); + let mut profile = PrincipalProfile { + groups: vec![BUILTIN_ADMIN.to_string()], + auth: AuthConfig { + methods: vec![AuthMethod::Keypair], + public_keys: vec![full], + }, + ..PrincipalProfile::default() + }; + write(&home, "root", &profile); + + assert!(r.resolve_access(Some("root"), Some(&full_id)).is_some()); + assert!( + r.resolve_access(Some("root"), Some("not-a-key-id")) + .is_none() + ); + assert!( + r.resolve_access(Some("root"), Some("0000000000000000")) + .is_none() + ); + + profile.auth.public_keys.clear(); + write(&home, "root", &profile); + cache.invalidate(&PrincipalId::new("root").unwrap()); + assert!(r.resolve_access(Some("root"), Some(&full_id)).is_none()); + } + + #[test] + fn one_dispatch_snapshot_cannot_fall_back_after_revocation() { + let (_d, home, cache, r) = fixture_with_cache(); + let full = device('e', DeviceScope::Full); + let full_id = full.key_id.clone(); + let mut profile = PrincipalProfile { + groups: vec![BUILTIN_ADMIN.to_string()], + auth: AuthConfig { + methods: vec![AuthMethod::Keypair], + public_keys: vec![full], + }, + ..PrincipalProfile::default() + }; + write(&home, "root", &profile); + let in_flight = r.resolve_access(Some("root"), Some(&full_id)).unwrap(); + + profile.auth.public_keys.clear(); + write(&home, "root", &profile); + cache.invalidate(&PrincipalId::new("root").unwrap()); + + assert!(in_flight.has_global_capsule_view()); + assert!(in_flight.is_capsule_allowed(&cap("registry"))); + assert!(r.resolve_access(Some("root"), Some(&full_id)).is_none()); + } } diff --git a/crates/astrid-capsule/src/dispatcher.rs b/crates/astrid-capsule/src/dispatcher.rs index 0615b6b11..2ba9fea96 100644 --- a/crates/astrid-capsule/src/dispatcher.rs +++ b/crates/astrid-capsule/src/dispatcher.rs @@ -294,10 +294,14 @@ impl EventDispatcher { // granted capsules — never a caller-supplied claim. let caller_principal: Option<&str> = ipc_message.as_deref().and_then(|m| m.principal.as_deref()); + let caller_device_key_id: Option<&str> = ipc_message + .as_deref() + .and_then(|m| m.device_key_id.as_deref()); let matches = find_matching_interceptors( &self.registry, &topic, caller_principal, + caller_device_key_id, self.access_resolver.as_ref(), &self.event_bus, ) @@ -790,36 +794,15 @@ fn dispatch_single( /// returned so the caller can distinguish an ordered chain (distinct /// priorities) from an independent fan-out (all equal). /// -/// # Per-principal capsule-access filter -/// -/// When an `access_resolver` is wired, principal-stamped non-admin dispatch is -/// first narrowed to the caller's capsule view. When `topic` is also in the -/// **user-invocable surface** (`tool.v1.execute.*`, `cli.v1.command.run.*`), -/// a matched capsule is kept only if `caller_principal` is granted it (or is an -/// admin holding `*`). The grant-on-use filter is keyed on the **topic**, so a -/// dual-role capsule's orchestration interceptors (on non-tool topics) are -/// view-scoped but not grant-gated. -/// -/// The gate engages **only for capsules whose subscription actually matches the -/// dispatched topic**. The cheap, manifest-local interceptor match is evaluated -/// first (using the same [`crate::topic::topic_matches`] delivery uses), and a -/// capsule that provides no interceptor for `topic` never reaches the access -/// gate — so a single tool call cannot storm `GrantRequired` across every -/// ungranted capsule in the view (#1113). Only a capsule that *would* be -/// delivered the call is gated. -/// -/// For an **authenticated, non-admin** caller a denied match is no longer a -/// pure silent drop: before dropping, a [`IpcPayload::GrantRequired`] signal is -/// published on `astrid.v1.approval` (grant-on-first-use, #998) so a broker/shim -/// can elicit consent and, on approve, the kernel grants the capsule. The match -/// is still dropped for THIS call (the capsule never sees the ungranted call); -/// the caller's request simply finds no tool, exactly as if the capsule were not -/// installed. A `None`/empty/`anonymous` caller (no authenticated principal to -/// grant to) is still a pure silent drop with no signal. +/// One resolved identity snapshot controls each dispatch. `capsule:list` +/// expands describe visibility; exact `capsule:access:any` expands and bypasses +/// execution filtering. Invalid identities drop before matching. A valid caller +/// denied an exact capsule receives one `GrantRequired` after topic matching. async fn find_matching_interceptors( registry: &RwLock, topic: &str, caller_principal: Option<&str>, + caller_device_key_id: Option<&str>, access_resolver: Option<&CapsuleAccessResolver>, event_bus: &EventBus, ) -> Vec<(Arc, String, u32)> { @@ -827,24 +810,38 @@ async fn find_matching_interceptors( // dispatch is view-scoped when a resolver is present; grant-on-use only // engages for the narrower user-invocable surface. let gate_surface = crate::access::is_user_invocable_surface(topic); - let view_scoped_surface = access_resolver.is_some() - && (gate_surface - || topic == "tool.v1.request.describe" - || topic == "llm.v1.request.describe"); + let describe_surface = + topic == "tool.v1.request.describe" || topic == "llm.v1.request.describe"; + let view_scoped_surface = access_resolver.is_some() && (gate_surface || describe_surface); + let identity_stamped = caller_principal.is_some() || caller_device_key_id.is_some(); + let resolved_access = if access_resolver.is_some() && identity_stamped { + access_resolver + .and_then(|resolver| resolver.resolve_access(caller_principal, caller_device_key_id)) + } else { + None + }; + if access_resolver.is_some() && identity_stamped && resolved_access.is_none() { + return Vec::new(); + } let registry = registry.read().await; let mut matches: Vec<(Arc, String, u32)> = Vec::new(); // Dedup grant-on-use signals within a single dispatch pass. This stays // tiny in practice, so a Vec keeps the gate path simple. let mut grant_signalled: Vec = Vec::new(); - let caller_pid = caller_principal.and_then(|p| astrid_core::PrincipalId::new(p).ok()); - let view_scoped_admin = view_scoped_surface - && access_resolver.is_some_and(|resolver| resolver.is_admin(caller_principal)); + let caller_pid = resolved_access + .as_ref() + .map(|access| access.principal().clone()) + .or_else(|| caller_principal.and_then(|p| astrid_core::PrincipalId::new(p).ok())); + let expand_global_view = resolved_access.as_ref().is_some_and(|access| { + (gate_surface && access.has_unrestricted_capsule_access()) + || (describe_surface && access.has_global_capsule_view()) + }); let candidate_capsules = candidate_capsules_for_dispatch( ®istry, caller_pid.as_ref(), access_resolver.is_some(), view_scoped_surface, - view_scoped_admin, + expand_global_view, ); for capsule in candidate_capsules { @@ -882,17 +879,14 @@ async fn find_matching_interceptors( // caller drops this capsule's matching interceptors entirely — the // capsule never sees the ungranted call. if gate_surface - && let Some(resolver) = access_resolver - && !resolver.is_capsule_allowed(caller_principal, capsule.id()) + && access_resolver.is_some() + && !resolved_access + .as_ref() + .is_some_and(|access| access.is_capsule_allowed(capsule.id())) { - // Grant-on-first-use (#998): for an authenticated non-admin - // caller, emit a `GrantRequired` signal before dropping. The - // grant TARGET is the kernel-stamped caller + this capsule — - // never any caller-supplied claim. Skip a `None`/empty/ - // `anonymous` principal (no authenticated principal to grant). - if let Some(principal) = caller_principal - && !principal.is_empty() - && principal != "anonymous" + // Valid resolved callers receive one signal for this matching capsule. + if resolved_access.is_some() + && let Some(principal) = caller_principal { let capsule_key = capsule.id().to_string(); if !grant_signalled.contains(&capsule_key) { @@ -930,14 +924,14 @@ fn candidate_capsules_for_dispatch( caller_pid: Option<&astrid_core::PrincipalId>, has_access_resolver: bool, view_scoped_surface: bool, - view_scoped_admin: bool, + expand_global_view: bool, ) -> Vec> { - if view_scoped_surface && !view_scoped_admin { + if view_scoped_surface && !expand_global_view { return caller_pid.map_or_else(Vec::new, |principal| registry.cloned_values_for(principal)); } if has_access_resolver - && !view_scoped_admin + && !expand_global_view && let Some(principal) = caller_pid { return registry.cloned_values_for(principal); @@ -953,3 +947,7 @@ fn candidate_capsules_for_dispatch( #[cfg(test)] #[path = "dispatcher_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "dispatcher_device_scope_tests.rs"] +mod device_scope_tests; diff --git a/crates/astrid-capsule/src/dispatcher_device_scope_tests.rs b/crates/astrid-capsule/src/dispatcher_device_scope_tests.rs new file mode 100644 index 000000000..45ed68634 --- /dev/null +++ b/crates/astrid-capsule/src/dispatcher_device_scope_tests.rs @@ -0,0 +1,546 @@ +use super::*; + +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use arc_swap::ArcSwap; +use astrid_core::dirs::AstridHome; +use astrid_core::groups::{BUILTIN_ADMIN, GroupConfig}; +use astrid_core::profile::{AuthConfig, AuthMethod, DeviceKey, DeviceScope, PrincipalProfile}; +use astrid_events::ipc::{IpcPayload, Topic}; + +use crate::access::CapsuleAccessResolver; +use crate::capsule::{Capsule, CapsuleId, CapsuleState, InterceptResult}; +use crate::context::CapsuleContext; +use crate::error::CapsuleResult; +use crate::manifest::{CapabilitiesDef, CapsuleManifest, PackageDef, SubscribeDef}; +use crate::profile_cache::PrincipalProfileCache; + +struct TestCapsule { + id: CapsuleId, + manifest: CapsuleManifest, + invoked: Arc, +} + +impl TestCapsule { + fn new(name: &str, topics: &[&str]) -> (Self, Arc) { + let invoked = Arc::new(AtomicBool::new(false)); + let subscribes = topics + .iter() + .map(|topic| { + ( + (*topic).to_string(), + SubscribeDef { + wit: "opaque".into(), + version: None, + tag: None, + rev: None, + branch: None, + path: None, + handler: Some("test_action".into()), + priority: Some(100), + }, + ) + }) + .collect(); + ( + Self { + id: CapsuleId::from_static(name), + manifest: CapsuleManifest { + package: PackageDef { + name: name.into(), + version: "0.0.1".into(), + description: None, + authors: Vec::new(), + repository: None, + homepage: None, + documentation: None, + license: None, + license_file: None, + readme: None, + keywords: Vec::new(), + categories: Vec::new(), + astrid_version: None, + publish: None, + include: None, + exclude: None, + metadata: None, + }, + components: Vec::new(), + imports: HashMap::new(), + exports: HashMap::new(), + capabilities: CapabilitiesDef::default(), + env: HashMap::new(), + context_files: Vec::new(), + commands: Vec::new(), + mcp_servers: Vec::new(), + skills: Vec::new(), + uplinks: Vec::new(), + publishes: HashMap::new(), + subscribes, + tools: Vec::new(), + }, + invoked: Arc::clone(&invoked), + }, + invoked, + ) + } +} + +#[async_trait] +impl Capsule for TestCapsule { + fn id(&self) -> &CapsuleId { + &self.id + } + + fn manifest(&self) -> &CapsuleManifest { + &self.manifest + } + + fn state(&self) -> CapsuleState { + CapsuleState::Ready + } + + async fn load(&mut self, _ctx: &CapsuleContext) -> CapsuleResult<()> { + Ok(()) + } + + async fn unload(&mut self) -> CapsuleResult<()> { + Ok(()) + } + + async fn invoke_interceptor( + &self, + _action: &str, + _payload: &[u8], + _caller: Option<&astrid_events::ipc::IpcMessage>, + ) -> CapsuleResult { + self.invoked.store(true, Ordering::SeqCst); + Ok(InterceptResult::Continue(Vec::new())) + } +} + +struct ResolverFixture { + _dir: tempfile::TempDir, + home: AstridHome, + cache: Arc, + resolver: CapsuleAccessResolver, +} + +fn resolver_fixture() -> ResolverFixture { + let dir = tempfile::tempdir().expect("tempdir"); + let home = AstridHome::from_path(dir.path()); + let cache = Arc::new(PrincipalProfileCache::with_home(home.clone())); + let groups = Arc::new(ArcSwap::from_pointee(GroupConfig::builtin_only())); + let resolver = CapsuleAccessResolver::new(Arc::clone(&cache), groups); + ResolverFixture { + _dir: dir, + home, + cache, + resolver, + } +} + +fn write_profile(home: &AstridHome, principal: &str, profile: &PrincipalProfile) { + let principal = astrid_core::PrincipalId::new(principal).unwrap(); + profile.save(home, &principal).expect("save profile"); +} + +fn device(seed: char, scope: DeviceScope) -> DeviceKey { + DeviceKey::new(seed.to_string().repeat(64), scope, None, 0) +} + +fn registry_for(capsule: TestCapsule, principals: &[&str]) -> Arc> { + let mut registry = CapsuleRegistry::new(); + let hash = crate::registry::WasmHash::synthetic( + capsule.id().as_str(), + &capsule.manifest().package.version, + ); + let capsule_id = capsule.id().clone(); + let first = astrid_core::PrincipalId::new(principals[0]).unwrap(); + registry + .register_for(Box::new(capsule), hash.clone(), &first) + .unwrap(); + for principal in &principals[1..] { + registry + .register_existing( + &capsule_id, + &hash, + &astrid_core::PrincipalId::new(*principal).unwrap(), + ) + .unwrap(); + } + Arc::new(RwLock::new(registry)) +} + +async fn matching( + registry: &RwLock, + topic: &str, + principal: &str, + device_key_id: Option<&str>, + resolver: Option<&CapsuleAccessResolver>, + bus: &EventBus, +) -> usize { + find_matching_interceptors( + registry, + topic, + Some(principal), + device_key_id, + resolver, + bus, + ) + .await + .len() +} + +async fn assert_no_grant_required(receiver: &mut astrid_events::EventReceiver) { + assert!( + tokio::time::timeout(Duration::from_millis(25), receiver.recv()) + .await + .is_err() + ); +} + +async fn recv_grant_required(receiver: &mut astrid_events::EventReceiver) -> (String, String) { + let event = tokio::time::timeout(Duration::from_millis(100), receiver.recv()) + .await + .expect("GrantRequired timeout") + .expect("event bus closed"); + match &*event { + AstridEvent::Ipc { message, .. } + if matches!(&message.payload, IpcPayload::GrantRequired { .. }) => + { + let IpcPayload::GrantRequired { + principal, + capsule_id, + .. + } = &message.payload + else { + unreachable!() + }; + (principal.clone(), capsule_id.clone()) + }, + other => panic!("expected GrantRequired, got {other:?}"), + } +} + +#[tokio::test] +async fn inventory_visibility_and_execution_authority_are_separate() { + let fixture = resolver_fixture(); + let full = device('a', DeviceScope::Full); + let list_only = device( + 'b', + DeviceScope::Scoped { + allow: vec!["capsule:list".into()], + deny: Vec::new(), + }, + ); + let access_any = device( + 'c', + DeviceScope::Scoped { + allow: vec!["capsule:access:any".into()], + deny: Vec::new(), + }, + ); + let access_denied = device( + 'd', + DeviceScope::Scoped { + allow: vec!["*".into()], + deny: vec!["capsule:access:any".into()], + }, + ); + let ids = [ + full.key_id.clone(), + list_only.key_id.clone(), + access_any.key_id.clone(), + access_denied.key_id.clone(), + ]; + write_profile( + &fixture.home, + "root", + &PrincipalProfile { + groups: vec![BUILTIN_ADMIN.into()], + auth: AuthConfig { + methods: vec![AuthMethod::Keypair], + public_keys: vec![full, list_only, access_any, access_denied], + }, + ..PrincipalProfile::default() + }, + ); + let (capsule, _) = TestCapsule::new( + "secret-tool", + &["tool.v1.request.describe", "tool.v1.execute.do_thing"], + ); + let registry = registry_for(capsule, &["default", "root"]); + let (global_capsule, _) = TestCapsule::new( + "global-tool", + &["tool.v1.request.describe", "tool.v1.execute.do_thing"], + ); + let global_registry = registry_for(global_capsule, &["default"]); + let bus = EventBus::with_capacity(64); + let mut approval = bus.subscribe_topic("astrid.v1.approval"); + + assert_eq!( + matching( + &global_registry, + "tool.v1.execute.do_thing", + "root", + None, + Some(&fixture.resolver), + &bus, + ) + .await, + 1 + ); + assert_eq!( + matching( + &global_registry, + "tool.v1.execute.do_thing", + "root", + Some(&ids[0]), + Some(&fixture.resolver), + &bus, + ) + .await, + 1 + ); + assert_eq!( + matching( + &global_registry, + "tool.v1.request.describe", + "root", + Some(&ids[1]), + Some(&fixture.resolver), + &bus, + ) + .await, + 1 + ); + assert_eq!( + matching( + &global_registry, + "tool.v1.execute.do_thing", + "root", + Some(&ids[1]), + Some(&fixture.resolver), + &bus, + ) + .await, + 0 + ); + assert_no_grant_required(&mut approval).await; + assert_eq!( + matching( + ®istry, + "tool.v1.execute.do_thing", + "root", + Some(&ids[1]), + Some(&fixture.resolver), + &bus, + ) + .await, + 0 + ); + assert_eq!( + recv_grant_required(&mut approval).await, + ("root".into(), "secret-tool".into()) + ); + assert_eq!( + matching( + &global_registry, + "tool.v1.execute.do_thing", + "root", + Some(&ids[2]), + Some(&fixture.resolver), + &bus, + ) + .await, + 1 + ); + assert_eq!( + matching( + ®istry, + "tool.v1.execute.do_thing", + "root", + Some(&ids[3]), + Some(&fixture.resolver), + &bus, + ) + .await, + 0 + ); + assert_eq!( + recv_grant_required(&mut approval).await, + ("root".into(), "secret-tool".into()) + ); +} + +#[tokio::test] +async fn direct_capsule_access_any_holder_can_execute() { + let fixture = resolver_fixture(); + let device = device( + 'f', + DeviceScope::Scoped { + allow: vec!["capsule:access:any".into()], + deny: Vec::new(), + }, + ); + let device_key_id = device.key_id.clone(); + write_profile( + &fixture.home, + "operator", + &PrincipalProfile { + grants: vec!["capsule:access:any".into()], + auth: AuthConfig { + methods: vec![AuthMethod::Keypair], + public_keys: vec![device], + }, + ..PrincipalProfile::default() + }, + ); + let (capsule, _) = TestCapsule::new("secret-tool", &["tool.v1.execute.do_thing"]); + let registry = registry_for(capsule, &["default"]); + let bus = EventBus::with_capacity(64); + assert_eq!( + matching( + ®istry, + "tool.v1.execute.do_thing", + "operator", + Some(&device_key_id), + Some(&fixture.resolver), + &bus, + ) + .await, + 1 + ); +} + +#[tokio::test] +async fn disabled_caller_dispatches_nothing_and_emits_no_grant() { + let fixture = resolver_fixture(); + write_profile( + &fixture.home, + "disabled", + &PrincipalProfile { + enabled: false, + groups: vec![BUILTIN_ADMIN.into()], + capsules: vec!["disabled-tool".into()], + ..PrincipalProfile::default() + }, + ); + let (capsule, _) = TestCapsule::new( + "disabled-tool", + &["tool.v1.execute.do_thing", "session.v1.append"], + ); + let registry = registry_for(capsule, &["disabled"]); + let bus = EventBus::with_capacity(64); + let mut approval = bus.subscribe_topic("astrid.v1.approval"); + + for topic in ["tool.v1.execute.do_thing", "session.v1.append"] { + assert_eq!( + matching( + ®istry, + topic, + "disabled", + None, + Some(&fixture.resolver), + &bus, + ) + .await, + 0 + ); + } + assert_no_grant_required(&mut approval).await; +} + +#[tokio::test] +async fn invalid_or_revoked_device_never_falls_back_or_emits_grant() { + let fixture = resolver_fixture(); + let full = device('e', DeviceScope::Full); + let full_id = full.key_id.clone(); + let mut profile = PrincipalProfile { + groups: vec![BUILTIN_ADMIN.into()], + capsules: vec!["secret-tool".into()], + auth: AuthConfig { + methods: vec![AuthMethod::Keypair], + public_keys: vec![full], + }, + ..PrincipalProfile::default() + }; + write_profile(&fixture.home, "root", &profile); + let (capsule, _) = TestCapsule::new( + "secret-tool", + &["tool.v1.execute.do_thing", "session.v1.append"], + ); + let registry = registry_for(capsule, &["root"]); + let bus = EventBus::with_capacity(64); + let mut approval = bus.subscribe_topic("astrid.v1.approval"); + assert_eq!( + matching( + ®istry, + "tool.v1.execute.do_thing", + "root", + Some(&full_id), + Some(&fixture.resolver), + &bus, + ) + .await, + 1 + ); + + profile.auth.public_keys.clear(); + write_profile(&fixture.home, "root", &profile); + fixture + .cache + .invalidate(&astrid_core::PrincipalId::new("root").unwrap()); + + for device_key_id in ["not-a-key-id", "0000000000000000", full_id.as_str()] { + for topic in ["tool.v1.execute.do_thing", "session.v1.append"] { + assert_eq!( + matching( + ®istry, + topic, + "root", + Some(device_key_id), + Some(&fixture.resolver), + &bus, + ) + .await, + 0 + ); + } + } + assert_no_grant_required(&mut approval).await; +} + +#[tokio::test] +async fn no_resolver_ignores_device_metadata() { + let (capsule, invoked) = TestCapsule::new("legacy", &["tool.v1.execute.do_thing"]); + let registry = registry_for(capsule, &["default"]); + let bus = Arc::new(EventBus::with_capacity(64)); + let dispatcher = EventDispatcher::new(Arc::clone(®istry), Arc::clone(&bus)); + let handle = tokio::spawn(dispatcher.run()); + let message = astrid_events::ipc::IpcMessage::new( + Topic::from_raw("tool.v1.execute.do_thing"), + IpcPayload::Custom { + data: serde_json::json!({}), + }, + uuid::Uuid::nil(), + ) + .with_principal("root") + .with_device_key_id("not-a-key-id"); + bus.publish(AstridEvent::Ipc { + metadata: astrid_events::EventMetadata::new("test"), + message, + }); + tokio::time::timeout(Duration::from_millis(500), async { + while !invoked.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .expect("legacy dispatch"); + handle.abort(); +} diff --git a/crates/astrid-capsule/src/dispatcher_tests.rs b/crates/astrid-capsule/src/dispatcher_tests.rs index c72c5cc25..0a6a192f3 100644 --- a/crates/astrid-capsule/src/dispatcher_tests.rs +++ b/crates/astrid-capsule/src/dispatcher_tests.rs @@ -406,7 +406,7 @@ async fn find_matching_interceptors_sorts_by_priority() { let registry = Arc::new(RwLock::new(registry)); let bus = EventBus::with_capacity(64); - let matches = find_matching_interceptors(®istry, "test.event", None, None, &bus).await; + let matches = find_matching_interceptors(®istry, "test.event", None, None, None, &bus).await; let names: Vec<&str> = matches.iter().map(|(c, _, _)| c.id().as_str()).collect(); assert_eq!( names, @@ -435,7 +435,7 @@ async fn find_matching_interceptors_tiebreaks_equal_priority_by_id() { let registry = Arc::new(RwLock::new(registry)); let bus = EventBus::with_capacity(64); - let matches = find_matching_interceptors(®istry, "test.event", None, None, &bus).await; + let matches = find_matching_interceptors(®istry, "test.event", None, None, None, &bus).await; let names: Vec<&str> = matches.iter().map(|(c, _, _)| c.id().as_str()).collect(); assert_eq!( names, From a536b0d15b6f35600840be83e0728e9e1edb96f0 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 14 Jul 2026 21:14:23 +0400 Subject: [PATCH 2/4] test(capsule): harden negative dispatch timing --- crates/astrid-capsule/src/dispatcher_device_scope_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/astrid-capsule/src/dispatcher_device_scope_tests.rs b/crates/astrid-capsule/src/dispatcher_device_scope_tests.rs index 45ed68634..b6b02cb9c 100644 --- a/crates/astrid-capsule/src/dispatcher_device_scope_tests.rs +++ b/crates/astrid-capsule/src/dispatcher_device_scope_tests.rs @@ -197,7 +197,7 @@ async fn matching( async fn assert_no_grant_required(receiver: &mut astrid_events::EventReceiver) { assert!( - tokio::time::timeout(Duration::from_millis(25), receiver.recv()) + tokio::time::timeout(Duration::from_millis(100), receiver.recv()) .await .is_err() ); From fb01de23783b8692e58945ffa27776ac319f1bb6 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 06:27:13 +0400 Subject: [PATCH 3/4] fix(capsule): scope DeviceScope import to tests --- crates/astrid-capsule/src/access.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/astrid-capsule/src/access.rs b/crates/astrid-capsule/src/access.rs index 2d6c3a015..eeee3be9e 100644 --- a/crates/astrid-capsule/src/access.rs +++ b/crates/astrid-capsule/src/access.rs @@ -45,7 +45,7 @@ use arc_swap::ArcSwap; use astrid_capabilities::CapabilityCheck; use astrid_core::GroupConfig; use astrid_core::principal::PrincipalId; -use astrid_core::profile::{DeviceKeyId, DeviceScope, PrincipalProfile}; +use astrid_core::profile::{DeviceKeyId, PrincipalProfile}; use tracing::warn; use crate::capsule::CapsuleId; @@ -327,7 +327,7 @@ mod tests { use astrid_core::dirs::AstridHome; use astrid_core::groups::{BUILTIN_ADMIN, Group, GroupConfig}; - use astrid_core::profile::{AuthConfig, AuthMethod, DeviceKey, PrincipalProfile}; + use astrid_core::profile::{AuthConfig, AuthMethod, DeviceKey, DeviceScope, PrincipalProfile}; fn fixture() -> (tempfile::TempDir, AstridHome, CapsuleAccessResolver) { let (dir, home, _cache, resolver) = fixture_with_cache(); From 57ec31340db1a1749d5f137a25f4352bb6cb401f Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 12:04:46 +0400 Subject: [PATCH 4/4] fix(kernel): require base pair capability for device issuance --- .../admin/pair_device_handlers.rs | 5 +++++ .../kernel_router/admin/pair_device_tests.rs | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/astrid-kernel/src/kernel_router/admin/pair_device_handlers.rs b/crates/astrid-kernel/src/kernel_router/admin/pair_device_handlers.rs index d2c8f3c56..37278ed8a 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/pair_device_handlers.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/pair_device_handlers.rs @@ -221,6 +221,11 @@ fn validate_pair_issue_authority( if let Some(scope @ DeviceScope::Scoped { .. }) = issuer_scope { issuer_check = issuer_check.with_device_scope(scope); } + if !issuer_check.has("self:auth:pair") { + return Err(format!( + "minting a paired device requires self:auth:pair, which {caller} does not effectively hold" + )); + } match requested_scope { DeviceScope::Full => { diff --git a/crates/astrid-kernel/src/kernel_router/admin/pair_device_tests.rs b/crates/astrid-kernel/src/kernel_router/admin/pair_device_tests.rs index 48d477c32..4a5782004 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/pair_device_tests.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/pair_device_tests.rs @@ -196,6 +196,27 @@ async fn non_admin_principal_denied_full_mint() { } } +#[tokio::test(flavor = "multi_thread")] +async fn admin_cap_without_base_pair_capability_cannot_issue_full_token() { + let (_dir, kernel) = fixture().await; + let caller = pid("pair_admin_without_base"); + seed(&kernel, &caller, &["self:auth:pair:admin"], vec![]); + + let resp = + handlers::dispatch_with_device(&kernel, &caller, None, issue(PairScopeArg::Full)).await; + match resp { + AdminResponseBody::Error(msg) => assert!( + msg.contains("requires self:auth:pair,"), + "pair-admin without the base pair capability must fail the base gate: {msg}" + ), + other => { + panic!( + "pair-admin without the base pair cap must be denied a Full mint, got: {other:?}" + ) + }, + } +} + #[tokio::test(flavor = "multi_thread")] async fn full_scope_device_can_mint_full() { // The common case is unchanged: a `full`-preset device (DeviceScope::Full,