diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f9b8aa4..726e8544d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. dependencies in `Cargo.lock`. Removing the unused surface eliminates both RustSec exceptions and keeps the audit gate strict. The native principal store and live legacy-SurrealKV migration reader are unchanged. Closes #1448. +### Added + +- **`astrid agent spawn` — atomic restricted throwaway session.** Creates a derived principal with no caller-selected capability grants from an explicit set of capsule installs, user-invocable capsules, capsule-scoped state namespaces, and outbound endpoints; omitted state and egress remain unavailable. Restricted-principal network and process policy is enforced at host-call time, the CLI provides the wall-clock watchdog and denies approval requests, and teardown reports any unreclaimed state instead of masking it. Part of #1217. + ### Changed - **`agent delete` now reclaims the deleted principal's full runtime footprint.** Delete fences new token and allowance authority, unlinks authentication, removes the profile, retires live capsule views, purges every immutable-UID KV namespace (including orphaned capsules), and reclaims the home tree, signing key (`keys/{principal}.key`), and secrets (`secrets/{principal}/`). Reclamation fails closed: incomplete cleanup returns an error, retains a durable alias reservation, and is safe to retry without letting a replacement identity inherit residual authority or state. Successful responses retain an empty `cleanup_errors` array for wire compatibility. Shared runtimes remain available to other principals. Replaces the previous "reclamation is an ops concern" leave-behind, and drops the interim `--purge-home` flag. Part of #1217. diff --git a/crates/astrid-capabilities/src/store.rs b/crates/astrid-capabilities/src/store.rs index 4ef26d3e7..5d652ba8c 100644 --- a/crates/astrid-capabilities/src/store.rs +++ b/crates/astrid-capabilities/src/store.rs @@ -513,6 +513,15 @@ impl CapabilityStore { self.retiring_principals.write().await.insert(principal); } + /// Return whether new authority for `principal` is currently fenced. + /// + /// Lifecycle code uses this at other admission edges, such as capsule + /// loading, so an on-disk profile retained temporarily for reclamation + /// policy cannot make a retiring principal live again. + pub async fn is_principal_retiring(&self, principal: &PrincipalId) -> bool { + self.retiring_principals.read().await.contains(principal) + } + /// Release an in-process retirement fence after durable reclamation. pub async fn finish_principal_retirement(&self, principal: &PrincipalId) { self.retiring_principals.write().await.remove(principal); diff --git a/crates/astrid-capsule/src/capsule.rs b/crates/astrid-capsule/src/capsule.rs index 15a35357b..1b012abde 100644 --- a/crates/astrid-capsule/src/capsule.rs +++ b/crates/astrid-capsule/src/capsule.rs @@ -164,6 +164,14 @@ pub trait Capsule: Send + Sync { /// rather than risking cancellation of another principal's work. fn request_cancel_for(&self, _principal: &astrid_core::principal::PrincipalId) {} + /// Re-open per-principal work after a new dispatch view is registered. + fn resume_for(&self, _principal: &astrid_core::principal::PrincipalId) {} + + /// Retire one principal's view and wait for its admitted interceptor work. + async fn quiesce_for(&self, principal: &astrid_core::principal::PrincipalId) { + self.request_cancel_for(principal); + } + /// Extract the inbound receiver for uplink messages. /// This is typically called exactly once by the OS router after loading. fn take_inbound_rx( @@ -326,6 +334,18 @@ impl Capsule for CompositeCapsule { } } + fn resume_for(&self, principal: &astrid_core::principal::PrincipalId) { + for engine in &self.engines { + engine.resume_for(principal); + } + } + + async fn quiesce_for(&self, principal: &astrid_core::principal::PrincipalId) { + for engine in &self.engines { + engine.quiesce_for(principal).await; + } + } + async fn wait_ready(&self, timeout: std::time::Duration) -> ReadyStatus { let deadline = astrid_runtime::time::Instant::now() + timeout; for engine in &self.engines { diff --git a/crates/astrid-capsule/src/engine/mod.rs b/crates/astrid-capsule/src/engine/mod.rs index 532e1ea97..001856aa4 100644 --- a/crates/astrid-capsule/src/engine/mod.rs +++ b/crates/astrid-capsule/src/engine/mod.rs @@ -63,6 +63,19 @@ pub(crate) trait ExecutionEngine: Send + Sync { /// that belongs to someone else. fn request_cancel_for(&self, _principal: &astrid_core::principal::PrincipalId) {} + /// Re-open per-principal work after a new dispatch view is registered. + /// + /// Engines that retain a cancellation tombstone use this explicit view + /// lifecycle edge to distinguish a legitimate delete-then-recreate from an + /// invocation that raced the previous view's removal. + fn resume_for(&self, _principal: &astrid_core::principal::PrincipalId) {} + + /// Close admission, cancel principal-scoped waits, and wait until every + /// interceptor admitted before the fence has returned. + async fn quiesce_for(&self, principal: &astrid_core::principal::PrincipalId) { + self.request_cancel_for(principal); + } + /// Extract the inbound receiver if this engine provides one. fn take_inbound_rx( &mut self, diff --git a/crates/astrid-capsule/src/engine/wasm/host/approval.rs b/crates/astrid-capsule/src/engine/wasm/host/approval.rs index ddbde3d30..d69699772 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/approval.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/approval.rs @@ -243,6 +243,9 @@ impl approval::Host for HostState { &mut self, mut request: ApprovalRequest, ) -> Result { + if !self.invocation_authority_active() { + return Err(ErrorCode::StoreUnavailable); + } let allowance_store = self.allowance_store.clone(); let event_bus = self.event_bus.clone(); let runtime_handle = self.runtime_handle.clone(); diff --git a/crates/astrid-capsule/src/engine/wasm/host/elicit.rs b/crates/astrid-capsule/src/engine/wasm/host/elicit.rs index 1829663c1..ba03d94e9 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/elicit.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/elicit.rs @@ -159,6 +159,9 @@ impl elicit::Host for HostState { /// and publishes an `ElicitResponse` on the response topic. /// fn elicit(&mut self, request: ElicitRequest) -> Result { + let _operation = self + .begin_host_operation() + .map_err(|()| ErrorCode::Cancelled)?; let field = map_to_onboarding_field(&request)?; let request_id = Uuid::new_v4(); let response_topic = Topic::elicit_response(request_id); @@ -255,6 +258,12 @@ impl elicit::Host for HostState { match request.kind { ElicitType::Secret => { + // Retirement may happen while the frontend is + // answering. Re-check at the mutation edge so a + // late response cannot recreate secret state. + if !self.invocation_authority_active() { + return Err(ErrorCode::Cancelled); + } // Persist the secret via the SecretStore // abstraction. OS keychain when available, // file fallback otherwise. The value is NOT @@ -298,6 +307,9 @@ impl elicit::Host for HostState { /// /// Checks whether a secret key has been stored for this capsule. fn has_secret(&mut self, key: String) -> Result { + let _operation = self + .begin_host_operation() + .map_err(|()| ErrorCode::Cancelled)?; self.effective_secret_store() .exists(&key) .map_err(|_| ErrorCode::StoreUnavailable) diff --git a/crates/astrid-capsule/src/engine/wasm/host/elicit/wait_loop_tests.rs b/crates/astrid-capsule/src/engine/wasm/host/elicit/wait_loop_tests.rs index ab8ea102d..0df486922 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/elicit/wait_loop_tests.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/elicit/wait_loop_tests.rs @@ -212,7 +212,7 @@ async fn principal_scoped_cancel_unblocks_elicit_wait_without_instance_cancel() let (_request_id, _principal) = await_request(req_rx).await; // The view-release path: cancel + remove exactly alice's token. - crate::engine::wasm::cancel_principal_token(&tokens, &alice); + crate::engine::wasm::cancel_principal_token(&tokens, &instance_token, &alice); let (result, state) = elicit_handle.await.expect("elicit thread joined"); let elapsed = start.elapsed(); diff --git a/crates/astrid-capsule/src/engine/wasm/host/fs/mod.rs b/crates/astrid-capsule/src/engine/wasm/host/fs/mod.rs index 7b7afa467..a0c5b2464 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/fs/mod.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/fs/mod.rs @@ -198,7 +198,13 @@ mod error_mapping_tests { /// reaches the success-path [`audit_fs`], so this is the single record for a /// denied read (exactly-once). The audited path is the resolved physical /// path the gate evaluated. -fn gate_read(state: &HostState, physical: &std::path::Path) -> Result<(), ErrorCode> { +fn gate_read( + state: &HostState, + physical: &std::path::Path, +) -> Result, ErrorCode> { + let operation = state + .begin_host_operation() + .map_err(|()| ErrorCode::CapabilityDenied)?; if let Some(gate) = state.security.clone() { let capsule_id = state.capsule_id.as_str().to_owned(); let p = physical.to_string_lossy().to_string(); @@ -220,7 +226,7 @@ fn gate_read(state: &HostState, physical: &std::path::Path) -> Result<(), ErrorC return Err(ErrorCode::CapabilityDenied); } } - Ok(()) + Ok(operation) } /// The mutation a write-gated fs op represents. Every mutation and removal @@ -245,7 +251,10 @@ fn gate_write( state: &HostState, physical: &std::path::Path, kind: WriteKind, -) -> Result<(), ErrorCode> { +) -> Result, ErrorCode> { + let operation = state + .begin_host_operation() + .map_err(|()| ErrorCode::CapabilityDenied)?; if let Some(gate) = state.security.clone() { let capsule_id = state.capsule_id.as_str().to_owned(); let p = physical.to_string_lossy().to_string(); @@ -272,7 +281,7 @@ fn gate_write( return Err(ErrorCode::CapabilityDenied); } } - Ok(()) + Ok(operation) } /// Resolve and read-authorize a VFS path exposed to a sandboxed native child. @@ -281,7 +290,7 @@ pub(super) fn authorize_process_read_path( raw_path: &str, ) -> Result { let resolved = resolve_path(state, raw_path).map_err(map_resolve_err)?; - gate_read(state, &resolved.physical)?; + let _operation = gate_read(state, &resolved.physical)?; Ok(resolved.physical) } @@ -291,7 +300,8 @@ pub(super) fn authorize_process_write_path( state: &HostState, physical: &std::path::Path, ) -> Result<(), ErrorCode> { - gate_write(state, physical, WriteKind::Write) + let _operation = gate_write(state, physical, WriteKind::Write)?; + Ok(()) } /// Convert a VFS metadata record into the WIT `FileStat`. The VFS only @@ -336,7 +346,7 @@ impl fs::Host for HostState { fn fs_exists(&mut self, path: String) -> Result { let resolved = resolve_path(self, &path).map_err(map_resolve_err)?; - gate_read(self, &resolved.physical)?; + let _operation = gate_read(self, &resolved.physical)?; let vfs_path = resolve_vfs(self, &resolved).map_err(map_resolve_err)?; let exists = util::bounded_block_on(&self.runtime_handle, &self.blocking_semaphore, async { @@ -361,7 +371,7 @@ impl fs::Host for HostState { fn fs_mkdir(&mut self, path: String) -> Result<(), ErrorCode> { let resolved = resolve_path(self, &path).map_err(map_resolve_err)?; - gate_write(self, &resolved.physical, WriteKind::Write)?; + let _operation = gate_write(self, &resolved.physical, WriteKind::Write)?; let vfs_path = resolve_vfs(self, &resolved).map_err(map_resolve_err)?; // Strict-create semantics per `astrid:fs@1.0.0` (fs-mkdir @@ -421,7 +431,7 @@ impl fs::Host for HostState { // unstubs the idempotent variant the capsule contract // promises. let resolved = resolve_path(self, &path).map_err(map_resolve_err)?; - gate_write(self, &resolved.physical, WriteKind::Write)?; + let _operation = gate_write(self, &resolved.physical, WriteKind::Write)?; let vfs_path = resolve_vfs(self, &resolved).map_err(map_resolve_err)?; let result = util::bounded_block_on(&self.runtime_handle, &self.blocking_semaphore, async { @@ -445,7 +455,7 @@ impl fs::Host for HostState { fn fs_readdir(&mut self, path: String) -> Result, ErrorCode> { let resolved = resolve_path(self, &path).map_err(map_resolve_err)?; - gate_read(self, &resolved.physical)?; + let _operation = gate_read(self, &resolved.physical)?; let vfs_path = resolve_vfs(self, &resolved).map_err(map_resolve_err)?; let result = util::bounded_block_on(&self.runtime_handle, &self.blocking_semaphore, async { @@ -470,7 +480,7 @@ impl fs::Host for HostState { fn fs_stat(&mut self, path: String) -> Result { let resolved = resolve_path(self, &path).map_err(map_resolve_err)?; - gate_read(self, &resolved.physical)?; + let _operation = gate_read(self, &resolved.physical)?; let vfs_path = resolve_vfs(self, &resolved).map_err(map_resolve_err)?; let result = util::bounded_block_on(&self.runtime_handle, &self.blocking_semaphore, async { @@ -501,7 +511,7 @@ impl fs::Host for HostState { fn fs_unlink(&mut self, path: String) -> Result<(), ErrorCode> { let resolved = resolve_path(self, &path).map_err(map_resolve_err)?; - gate_write(self, &resolved.physical, WriteKind::Delete)?; + let _operation = gate_write(self, &resolved.physical, WriteKind::Delete)?; let vfs_path = resolve_vfs(self, &resolved).map_err(map_resolve_err)?; let result = util::bounded_block_on(&self.runtime_handle, &self.blocking_semaphore, async { @@ -525,7 +535,7 @@ impl fs::Host for HostState { fn read_file(&mut self, path: String) -> Result, ErrorCode> { let resolved = resolve_path(self, &path).map_err(map_resolve_err)?; - gate_read(self, &resolved.physical)?; + let _operation = gate_read(self, &resolved.physical)?; let vfs_path = resolve_vfs(self, &resolved).map_err(map_resolve_err)?; // Sentinel string used to encode the "too large at stat time" // case as a `PermissionDenied` payload so we can re-raise it @@ -596,7 +606,7 @@ impl fs::Host for HostState { return Err(ErrorCode::TooLarge); } let resolved = resolve_path(self, &path).map_err(map_resolve_err)?; - gate_write(self, &resolved.physical, WriteKind::Write)?; + let _operation = gate_write(self, &resolved.physical, WriteKind::Write)?; let vfs_path = resolve_vfs(self, &resolved).map_err(map_resolve_err)?; let result = util::bounded_block_on(&self.runtime_handle, &self.blocking_semaphore, async { diff --git a/crates/astrid-capsule/src/engine/wasm/host/http/backend.rs b/crates/astrid-capsule/src/engine/wasm/host/http/backend.rs index ae1bd81a4..07f4679c8 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/http/backend.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/http/backend.rs @@ -221,6 +221,15 @@ impl HostState { ) -> Result { check_scheme(url, opts.https_only)?; + let parsed = reqwest::Url::parse(url).map_err(|_| ErrorCode::InvalidRequest)?; + let host = parsed.host_str().ok_or(ErrorCode::InvalidRequest)?; + let port = parsed + .port_or_known_default() + .ok_or(ErrorCode::InvalidRequest)?; + if !self.principal_egress_allows(host, Some(port)) { + return Err(ErrorCode::CapabilityDenied); + } + let capsule_id = self.capsule_id.as_str().to_owned(); let security = self.security.clone(); let io_semaphore = self.io_semaphore.clone(); diff --git a/crates/astrid-capsule/src/engine/wasm/host/identity.rs b/crates/astrid-capsule/src/engine/wasm/host/identity.rs index fa5f72042..ab900f296 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/identity.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/identity.rs @@ -28,6 +28,9 @@ impl identity::Host for HostState { &mut self, request: IdentityResolveRequest, ) -> Result { + if !self.invocation_authority_active() { + return Err(ErrorCode::CapabilityDenied); + } let identity_store = self .identity_store .clone() @@ -61,6 +64,9 @@ impl identity::Host for HostState { } fn identity_link(&mut self, request: IdentityLinkRequest) -> Result<(), ErrorCode> { + if !self.invocation_authority_active() { + return Err(ErrorCode::CapabilityDenied); + } let user_id = uuid::Uuid::parse_str(&request.astrid_user_id).map_err(|_| ErrorCode::InvalidInput)?; @@ -93,6 +99,9 @@ impl identity::Host for HostState { } fn identity_unlink(&mut self, request: IdentityUnlinkRequest) -> Result<(), ErrorCode> { + if !self.invocation_authority_active() { + return Err(ErrorCode::CapabilityDenied); + } let identity_store = self .identity_store .clone() @@ -124,6 +133,9 @@ impl identity::Host for HostState { &mut self, request: IdentityCreateUserRequest, ) -> Result { + if !self.invocation_authority_active() { + return Err(ErrorCode::CapabilityDenied); + } let identity_store = self .identity_store .clone() @@ -154,6 +166,9 @@ impl identity::Host for HostState { &mut self, astrid_user_id: String, ) -> Result, ErrorCode> { + if !self.invocation_authority_active() { + return Err(ErrorCode::CapabilityDenied); + } let user_id = uuid::Uuid::parse_str(&astrid_user_id).map_err(|_| ErrorCode::InvalidInput)?; diff --git a/crates/astrid-capsule/src/engine/wasm/host/ipc.rs b/crates/astrid-capsule/src/engine/wasm/host/ipc.rs index 87b158c50..475e96389 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/ipc.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/ipc.rs @@ -233,6 +233,12 @@ fn publish_inner( device_key_id: Option<&str>, origin: astrid_events::ipc::MessageOrigin, ) -> Result<(), ErrorCode> { + // View retirement is an authority fence, not merely a liveness hint. A + // guest invocation that raced unregister retains a cancelled per-principal + // token and may no longer publish effects onto the bus. + if !state.invocation_authority_active() { + return Err(ErrorCode::CapabilityDenied); + } if topic.len() > 256 { return Err(ErrorCode::InvalidInput); } diff --git a/crates/astrid-capsule/src/engine/wasm/host/ipc_tests.rs b/crates/astrid-capsule/src/engine/wasm/host/ipc_tests.rs index b65c42dc3..119102ad3 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/ipc_tests.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/ipc_tests.rs @@ -672,3 +672,38 @@ async fn publish_as_unbound_connection_stamps_system_origin() { "an unbound publish_as forward must stamp System (non-local), not LocalSocket" ); } + +#[tokio::test] +async fn retired_principal_cannot_publish_after_view_release() { + let rt = tokio::runtime::Handle::current(); + let mut state = minimal_host_state(rt); + state.ipc_publish_patterns = vec!["capsule.v1.*".to_string()]; + let principal = astrid_core::PrincipalId::new("retired-worker").unwrap(); + assert!(crate::engine::wasm::install_principal_overlays_sync( + &mut state, + Some(&principal) + )); + crate::engine::wasm::cancel_principal_token( + &state.principal_cancel_tokens, + &state.cancel_token, + &principal, + ); + + assert!(matches!( + IpcHost::publish(&mut state, "capsule.v1.effect".into(), "{}".into()), + Err(ErrorCode::CapabilityDenied) + )); +} + +#[tokio::test] +async fn unresolved_invocation_profile_cannot_publish() { + let rt = tokio::runtime::Handle::current(); + let mut state = minimal_host_state(rt); + state.ipc_publish_patterns = vec!["capsule.v1.*".to_string()]; + state.invocation_profile_authorized = false; + + assert!(matches!( + IpcHost::publish(&mut state, "capsule.v1.effect".into(), "{}".into()), + Err(ErrorCode::CapabilityDenied) + )); +} diff --git a/crates/astrid-capsule/src/engine/wasm/host/kv.rs b/crates/astrid-capsule/src/engine/wasm/host/kv.rs index c41c7a142..e1f6618cd 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/kv.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/kv.rs @@ -27,6 +27,7 @@ fn store_err(op: &str, msg: impl std::fmt::Display) -> ErrorCode { impl kv::Host for HostState { fn kv_get(&mut self, key: String) -> Result>, ErrorCode> { + let _operation = self.begin_kv_operation()?; let kv = self.effective_kv().clone(); util::bounded_block_on(&self.runtime_handle, &self.blocking_semaphore, async { kv.get(&key).await @@ -35,6 +36,7 @@ impl kv::Host for HostState { } fn kv_set(&mut self, key: String, value: Vec) -> Result<(), ErrorCode> { + let _operation = self.begin_kv_operation()?; let kv = self.effective_kv().clone(); util::bounded_block_on(&self.runtime_handle, &self.blocking_semaphore, async { kv.set(&key, value).await @@ -43,6 +45,7 @@ impl kv::Host for HostState { } fn kv_delete(&mut self, key: String) -> Result<(), ErrorCode> { + let _operation = self.begin_kv_operation()?; let kv = self.effective_kv().clone(); util::bounded_block_on(&self.runtime_handle, &self.blocking_semaphore, async { kv.delete(&key).await @@ -52,6 +55,7 @@ impl kv::Host for HostState { } fn kv_list_keys(&mut self, prefix: String) -> Result, ErrorCode> { + let _operation = self.begin_kv_operation()?; let kv = self.effective_kv().clone(); let keys = util::bounded_block_on(&self.runtime_handle, &self.blocking_semaphore, async { kv.list_keys_with_prefix(&prefix).await @@ -69,6 +73,7 @@ impl kv::Host for HostState { cursor: Option, limit: u32, ) -> Result { + let _operation = self.begin_kv_operation()?; // Underlying ScopedKvStore doesn't expose paging yet — emulate by // listing-with-prefix then slicing. Acceptable for 1.0 because // the store backend has a 1024-key cap; revisit if a capsule @@ -99,6 +104,7 @@ impl kv::Host for HostState { } fn kv_clear_prefix(&mut self, prefix: String) -> Result { + let _operation = self.begin_kv_operation()?; let kv = self.effective_kv().clone(); util::bounded_block_on(&self.runtime_handle, &self.blocking_semaphore, async { kv.clear_prefix(&prefix).await @@ -112,6 +118,7 @@ impl kv::Host for HostState { expected: Option>, new: Vec, ) -> Result<(), ErrorCode> { + let _operation = self.begin_kv_operation()?; // Atomic compare-and-swap is delegated to the storage layer. Every // `KvStore` implementation must make the comparison and mutation one // linearizable operation. A concurrent capsule's winning commit @@ -132,3 +139,13 @@ impl kv::Host for HostState { }) } } + +impl HostState { + fn begin_kv_operation( + &self, + ) -> Result, ErrorCode> { + self.begin_host_operation().map_err(|()| { + ErrorCode::Unknown("principal capsule view is retired or unauthorized".to_string()) + }) + } +} diff --git a/crates/astrid-capsule/src/engine/wasm/host/net/mod.rs b/crates/astrid-capsule/src/engine/wasm/host/net/mod.rs index 9f80c3b0f..b745368d2 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/net/mod.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/net/mod.rs @@ -302,6 +302,15 @@ impl net::Host for HostState { fn connect_tcp(&mut self, host: String, port: u16) -> Result, ErrorCode> { validate_host(&host)?; + if !self.principal_egress_allows(&host, Some(port)) { + record_net_denied( + self, + HostAuditEvent::NetConnect { host: &host, port }, + "restricted principal network policy denied endpoint", + ); + return Err(ErrorCode::CapabilityDenied); + } + if let Some(ref gate) = self.security { let capsule_id = self.capsule_id.as_str().to_owned(); let host_for_check = host.clone(); @@ -416,6 +425,9 @@ impl net::Host for HostState { fn lookup_host(&mut self, host: String) -> Result, ErrorCode> { validate_host(&host)?; + if !self.principal_egress_allows(&host, None) { + return Err(ErrorCode::CapabilityDenied); + } if let Some(ref gate) = self.security { let capsule_id = self.capsule_id.as_str().to_owned(); let host_for_check = host.clone(); diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/handle.rs b/crates/astrid-capsule/src/engine/wasm/host/process/handle.rs index d11ea6962..ddfe4fffa 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/handle.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/handle.rs @@ -27,6 +27,7 @@ use crate::engine::wasm::host_state::HostState; impl HostProcessHandle for HostState { fn read_logs(&mut self, self_: Resource) -> Result { + self.ensure_process_handle_authority()?; let proc = self .resource_table .get_mut::(&Resource::new_borrow(self_.rep())) @@ -90,6 +91,7 @@ impl HostProcessHandle for HostState { self_: Resource, sig: ProcessSignal, ) -> Result<(), ErrorCode> { + self.ensure_process_handle_authority()?; #[cfg(unix)] { let proc = self @@ -129,6 +131,7 @@ impl HostProcessHandle for HostState { } fn kill(&mut self, self_: Resource) -> Result { + self.ensure_process_handle_authority()?; let proc = self .resource_table .get_mut::(&Resource::new_borrow(self_.rep())) @@ -158,6 +161,7 @@ impl HostProcessHandle for HostState { self_: Resource, timeout_ms: Option, ) -> Result { + self.ensure_process_handle_authority()?; let rt = self.runtime_handle.clone(); let sem = self.blocking_semaphore.clone(); let tok = self.effective_cancel_token(); @@ -230,6 +234,7 @@ impl HostProcessHandle for HostState { } fn os_pid(&mut self, self_: Resource) -> Result { + self.ensure_process_handle_authority()?; let proc = self .resource_table .get::(&Resource::new_borrow(self_.rep())) @@ -280,3 +285,11 @@ impl HostProcessHandle for HostState { Ok(()) } } + +impl HostState { + fn ensure_process_handle_authority(&self) -> Result<(), ErrorCode> { + self.invocation_authority_active() + .then_some(()) + .ok_or(ErrorCode::CapabilityDenied) + } +} diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/managed.rs b/crates/astrid-capsule/src/engine/wasm/host/process/managed.rs index 8c333b0be..2a1b45167 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/managed.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/managed.rs @@ -55,8 +55,7 @@ pub(super) fn kill_and_reap(child: &mut tokio::process::Child) -> Option { #[cfg(unix)] { if let Some(raw_pid) = child.id() { - let pid = nix::unistd::Pid::from_raw(i32::try_from(raw_pid).unwrap_or(i32::MAX)); - let _ = nix::sys::signal::killpg(pid, nix::sys::signal::Signal::SIGKILL); + kill_process_group(raw_pid); } } let _ = child.start_kill(); @@ -66,6 +65,20 @@ pub(super) fn kill_and_reap(child: &mut tokio::process::Child) -> Option { child.try_wait().ok().flatten().and_then(|s| s.code()) } +/// Kill the process group rooted at `raw_pid`, falling back to the exact child +/// only when group signaling is unavailable. Invalid/overflowing identifiers +/// are ignored rather than substituted with an unrelated PID. +#[cfg(unix)] +pub(super) fn kill_process_group(raw_pid: u32) { + let Ok(raw) = i32::try_from(raw_pid) else { + return; + }; + let pid = nix::unistd::Pid::from_raw(raw); + if nix::sys::signal::killpg(pid, nix::sys::signal::Signal::SIGKILL).is_err() { + let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGKILL); + } +} + impl Drop for ManagedProcess { fn drop(&mut self) { if let Some(mut child) = self.child.take() { diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/mod.rs b/crates/astrid-capsule/src/engine/wasm/host/process/mod.rs index 634985dc9..80ad1ecce 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/mod.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/mod.rs @@ -150,6 +150,16 @@ impl process::Host for HostState { let cmd_for_audit = request.cmd.clone(); let _env_for_audit = env_summary(&request.env); + if !self.principal_process_allows(&request.cmd) { + record_process_denied( + self, + "astrid:process/host.spawn", + &cmd_for_audit, + "restricted principal process policy denied executable", + ); + return Err(ErrorCode::CapabilityDenied); + } + if let Some(sec) = security { let cmd = request.cmd.to_string(); let check = util::bounded_block_on(&handle, &semaphore, async move { @@ -242,7 +252,7 @@ impl process::Host for HostState { }, }; let pid = child.id(); - process_tracker.register(pid, call_id); + process_tracker.register_for_principal(pid, self.effective_principal(), call_id); let output_result = util::bounded_block_on_cancellable(&handle, &semaphore, &cancel_token, async move { @@ -321,6 +331,16 @@ impl process::Host for HostState { let semaphore = self.blocking_semaphore.clone(); let cmd_for_audit = request.cmd.clone(); + if !self.principal_process_allows(&request.cmd) { + record_process_denied( + self, + "astrid:process/host.spawn-background", + &cmd_for_audit, + "restricted principal process policy denied executable", + ); + return Err(ErrorCode::CapabilityDenied); + } + if let Some(sec) = security { let cmd = request.cmd.to_string(); let check = util::bounded_block_on(&handle, &semaphore, async move { @@ -468,7 +488,8 @@ impl process::Host for HostState { // common case), so the entry is registered with None — which // makes it eligible for the "conservative fallback" branch of // `cancel_by_call_ids` (cancelled by any matching event). - self.process_tracker.register(pid, None); + self.process_tracker + .register_for_principal(pid, principal.clone(), None); let res = match self.resource_table.push(managed) { Ok(res) => res, @@ -527,6 +548,16 @@ impl process::Host for HostState { let handle = self.runtime_handle.clone(); let semaphore = self.blocking_semaphore.clone(); + if !self.principal_process_allows(&request.cmd) { + record_process_denied( + self, + "astrid:process/host.spawn-persistent", + &cmd_for_audit, + "restricted principal process policy denied executable", + ); + return Err(ErrorCode::CapabilityDenied); + } + // Capability gate FIRST — a capsule lacking `host_process` gets // `capability-denied` (consistent with `spawn` / `spawn-background` // and the WIT "Security-gated" header), BEFORE any persistence- @@ -771,6 +802,9 @@ impl process::Host for HostState { } fn attach(&mut self, id: String) -> Result, ErrorCode> { + if !self.invocation_authority_active() { + return Err(ErrorCode::CapabilityDenied); + } // Deferred: materialising a `process-handle` resource over a registry // entry needs dual-typed dispatch in the resource table. The id-keyed // free functions below ARE the documented `attach(id)?.method()` diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/mod.rs b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/mod.rs index 7c9e10d01..3c6d461dc 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/persistent/mod.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/persistent/mod.rs @@ -603,6 +603,26 @@ impl PersistentProcessRegistry { reap_entry(entry); } } + + /// Kill and remove entries owned by one retiring principal only. + pub fn shutdown_for(&self, principal: &PrincipalId) { + let mut removed = Vec::new(); + { + let mut map = self.lock(); + let keys: Vec<_> = map + .iter() + .filter_map(|(key, entry)| (entry.creator == *principal).then_some(*key)) + .collect(); + for key in keys { + if let Some(entry) = map.remove(&key) { + removed.push(entry); + } + } + } + for entry in removed { + reap_entry(entry); + } + } } fn reject_spawn(mut p: SpawnParams, err: ErrorCode) -> Result { diff --git a/crates/astrid-capsule/src/engine/wasm/host/process/tracker.rs b/crates/astrid-capsule/src/engine/wasm/host/process/tracker.rs index 493a95801..59063b87f 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/process/tracker.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/process/tracker.rs @@ -14,11 +14,13 @@ use tracing::warn; #[cfg(unix)] const SIGKILL_GRACE_PERIOD: Duration = Duration::from_secs(2); +type TrackedProcesses = HashMap)>; + /// Tracks active child process PIDs for cancellation, with optional /// call_id association for multi-session scoping. #[derive(Debug, Default)] pub struct ProcessTracker { - active_pids: Arc>>>, + active_pids: Arc>, } impl ProcessTracker { @@ -28,15 +30,30 @@ impl ProcessTracker { Self::default() } - /// Register a child process PID with an optional call_id. + /// Register a child process PID with an optional call ID. + /// + /// This compatibility entry point associates legacy callers with the + /// default principal. Principal-aware host paths use + /// [`register_for_principal`](Self::register_for_principal) so retirement + /// can cancel exactly one tenant's process group. pub fn register(&self, pid: u32, call_id: Option) { + self.register_for_principal(pid, astrid_core::PrincipalId::default(), call_id); + } + + /// Register a child process PID under the principal that created it. + pub fn register_for_principal( + &self, + pid: u32, + principal: astrid_core::PrincipalId, + call_id: Option, + ) { if pid == 0 { return; // Guard: PID 0 means "no process" on some platforms. } self.active_pids .lock() .expect("process tracker lock poisoned") - .insert(pid, call_id); + .insert(pid, (principal, call_id)); } /// Whether any child process is currently registered as running. @@ -76,7 +93,7 @@ impl ProcessTracker { .lock() .expect("process tracker lock poisoned") .iter() - .filter_map(|(&pid, stored_call_id)| match stored_call_id { + .filter_map(|(&pid, (_, stored_call_id))| match stored_call_id { None => Some(pid), Some(id) => call_id_set.contains(id).then_some(pid), }) @@ -98,6 +115,31 @@ impl ProcessTracker { self.signal_pids(&pids, handle); } + /// Cancel every child created by one retiring principal while leaving + /// shared-runtime peers untouched. + pub fn cancel_for_principal( + &self, + principal: &astrid_core::PrincipalId, + _handle: &tokio::runtime::Handle, + ) { + let pids: Vec = self + .active_pids + .lock() + .expect("process tracker lock poisoned") + .iter() + .filter_map(|(&pid, (creator, _))| (creator == principal).then_some(pid)) + .collect(); + // Principal deletion is a reclamation boundary, unlike cooperative + // tool-call cancellation. Kill synchronously so filesystem/KV cleanup + // cannot race a child during the ordinary SIGINT grace window. + #[cfg(unix)] + for pid in pids { + super::managed::kill_process_group(pid); + } + #[cfg(not(unix))] + let _ = pids; + } + fn signal_pids(&self, pids: &[u32], handle: &tokio::runtime::Handle) { if pids.is_empty() { return; @@ -168,18 +210,29 @@ mod tests { //! `spawn_background` relies on. use super::*; + fn principal() -> astrid_core::PrincipalId { + astrid_core::PrincipalId::new("tracker-test").unwrap() + } + #[test] fn register_adds_pid() { let t = ProcessTracker::new(); - t.register(42, None); + t.register_for_principal(42, principal(), None); + assert_eq!(t.active_pids_snapshot(), vec![42]); + } + + #[test] + fn legacy_register_signature_remains_available() { + let t = ProcessTracker::new(); + t.register(42, Some("legacy-call".into())); assert_eq!(t.active_pids_snapshot(), vec![42]); } #[test] fn unregister_removes_pid() { let t = ProcessTracker::new(); - t.register(42, None); - t.register(99, Some("call-a".into())); + t.register_for_principal(42, principal(), None); + t.register_for_principal(99, principal(), Some("call-a".into())); t.unregister(42); assert_eq!(t.active_pids_snapshot(), vec![99]); } @@ -187,7 +240,7 @@ mod tests { #[test] fn pid_zero_is_rejected() { let t = ProcessTracker::new(); - t.register(0, None); + t.register_for_principal(0, principal(), None); assert!(t.active_pids_snapshot().is_empty()); } @@ -196,11 +249,65 @@ mod tests { // Re-registering a PID with a different call_id must replace // the prior entry, otherwise stale call_id associations leak. let t = ProcessTracker::new(); - t.register(42, Some("call-a".into())); - t.register(42, Some("call-b".into())); + t.register_for_principal(42, principal(), Some("call-a".into())); + t.register_for_principal(42, principal(), Some("call-b".into())); assert_eq!(t.active_pids_snapshot(), vec![42]); } + #[cfg(unix)] + #[tokio::test] + async fn principal_cancel_kills_descendant_group_and_preserves_peer_group() { + use std::os::unix::process::CommandExt; + + let dir = tempfile::tempdir().unwrap(); + let alice_ready = dir.path().join("alice-ready"); + let alice_effect = dir.path().join("alice-effect"); + let bob_ready = dir.path().join("bob-ready"); + let bob_effect = dir.path().join("bob-effect"); + let script = |ready: &std::path::Path, effect: &std::path::Path| { + format!( + "touch '{}'; (sleep 0.8; echo survived > '{}') & wait", + ready.display(), + effect.display() + ) + }; + let mut alice_child = std::process::Command::new("/bin/sh") + .args(["-c", &script(&alice_ready, &alice_effect)]) + .process_group(0) + .spawn() + .unwrap(); + let mut bob_child = std::process::Command::new("/bin/sh") + .args(["-c", &script(&bob_ready, &bob_effect)]) + .process_group(0) + .spawn() + .unwrap(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while (!alice_ready.exists() || !bob_ready.exists()) && std::time::Instant::now() < deadline + { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(alice_ready.exists() && bob_ready.exists()); + + let tracker = ProcessTracker::new(); + tracker.register_for_principal(alice_child.id(), principal(), None); + let bob = astrid_core::PrincipalId::new("tracker-peer").unwrap(); + tracker.register_for_principal(bob_child.id(), bob, None); + tracker.cancel_for_principal(&principal(), &tokio::runtime::Handle::current()); + + tokio::time::sleep(std::time::Duration::from_millis(1200)).await; + let _ = alice_child.try_wait(); + let _ = bob_child.try_wait(); + assert!( + !alice_effect.exists(), + "a descendant in the retired principal's process group must not outlive reclamation" + ); + assert!(bob_effect.exists(), "peer process group must remain live"); + crate::engine::wasm::host::process::managed::kill_process_group(bob_child.id()); + let _ = bob_child.wait(); + let _ = alice_child.wait(); + } + #[test] fn unregister_after_register_clears_call_id_match() { // The contract relied on by `spawn_background`'s drop path: @@ -208,7 +315,7 @@ mod tests { // unregister, `cancel_by_call_ids` must find no PIDs to // signal — verified here by observing the snapshot is empty. let t = ProcessTracker::new(); - t.register(42, Some("call-a".into())); + t.register_for_principal(42, principal(), Some("call-a".into())); t.unregister(42); assert!(t.active_pids_snapshot().is_empty()); } diff --git a/crates/astrid-capsule/src/engine/wasm/host/sys.rs b/crates/astrid-capsule/src/engine/wasm/host/sys.rs index 7eb2266f2..82ac1eb2d 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/sys.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/sys.rs @@ -22,6 +22,9 @@ const SLEEP_NS_CAP: u64 = 60_000_000_000; impl sys::Host for HostState { fn get_config(&mut self, key: String) -> Result, ErrorCode> { + let _operation = self + .begin_host_operation() + .map_err(|()| ErrorCode::CapabilityDenied)?; // Manifest-declared secrets route through the file-per-secret // store at invocation time, never through `self.config`. This // keeps plaintext secret material off disk and out of long-lived @@ -74,6 +77,9 @@ impl sys::Host for HostState { } fn log(&mut self, level: LogLevel, message: String) { + if !self.invocation_authority_active() { + return; + } let capsule_id = self.capsule_id.as_str().to_owned(); let log_file = self.effective_capsule_log().cloned(); @@ -204,6 +210,9 @@ impl sys::Host for HostState { &mut self, request: CapabilityCheckRequest, ) -> Result { + if !self.invocation_authority_active() { + return Err(ErrorCode::CapabilityDenied); + } let registry = self.capsule_registry.clone(); let rt_handle = self.runtime_handle.clone(); let blocking_semaphore = self.blocking_semaphore.clone(); @@ -230,6 +239,9 @@ impl sys::Host for HostState { } fn enumerate_capabilities(&mut self) -> Vec { + if !self.invocation_authority_active() { + return Vec::new(); + } // Infallible self-introspection (the WIT returns a bare `list`, // no `result`). The held-capability snapshot is taken once at load // (`CapabilitiesDef::held_names`) and stored on `HostState`, so this @@ -308,6 +320,75 @@ fn should_emit_to_daemon_log(wrote_to_file: bool, level: LogLevel) -> bool { !wrote_to_file || matches!(level, LogLevel::Error) } +#[cfg(all(test, unix))] +mod retirement_secret_tests { + use std::io::Write; + use std::sync::Arc; + + use super::*; + use crate::engine::wasm::PrincipalInvocationTracker; + use crate::engine::wasm::test_fixtures::minimal_host_state; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn retirement_waits_for_inflight_secret_config_read_and_rejects_late_read() { + let temp = tempfile::tempdir().unwrap(); + let principal = astrid_core::PrincipalId::new("secret-reader").unwrap(); + let secret_dir = temp.path().join(principal.as_str()).join("test"); + std::fs::create_dir_all(&secret_dir).unwrap(); + let fifo = secret_dir.join("API_KEY"); + assert!( + std::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap() + .success() + ); + + let tracker = Arc::new(PrincipalInvocationTracker::default()); + let mut state = minimal_host_state(tokio::runtime::Handle::current()); + state.principal = principal.clone(); + state.principal_invocations = Some(Arc::clone(&tracker)); + state.file_secret_root = Some(temp.path().to_path_buf()); + state.secret_env.insert("API_KEY".to_string()); + + let reader = std::thread::spawn(move || { + let result = sys::Host::get_config(&mut state, "API_KEY".to_string()); + (state, result) + }); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let writer = std::thread::spawn(move || { + let mut file = std::fs::OpenOptions::new().write(true).open(fifo).unwrap(); + entered_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + file.write_all(b"secret-value").unwrap(); + }); + entered_rx.recv().unwrap(); + + tracker.retire(&principal); + let draining = { + let tracker = Arc::clone(&tracker); + let principal = principal.clone(); + tokio::spawn(async move { tracker.wait_for_quiescence(&principal).await }) + }; + tokio::task::yield_now().await; + assert!( + !draining.is_finished(), + "retirement must wait for the admitted secret read" + ); + + release_tx.send(()).unwrap(); + writer.join().unwrap(); + let (mut state, result) = reader.join().unwrap(); + assert_eq!(result.unwrap().as_deref(), Some("secret-value")); + draining.await.unwrap(); + assert!(matches!( + sys::Host::get_config(&mut state, "API_KEY".to_string()), + Err(ErrorCode::CapabilityDenied) + )); + } +} + #[cfg(test)] mod log_chain_tests { use std::sync::Arc; diff --git a/crates/astrid-capsule/src/engine/wasm/host/uplink.rs b/crates/astrid-capsule/src/engine/wasm/host/uplink.rs index 71c0909f0..a64dc78ab 100644 --- a/crates/astrid-capsule/src/engine/wasm/host/uplink.rs +++ b/crates/astrid-capsule/src/engine/wasm/host/uplink.rs @@ -23,7 +23,7 @@ impl uplink::Host for HostState { platform: String, profile: UplinkProfile, ) -> Result { - if !self.has_uplink_capability { + if !self.invocation_authority_active() || !self.has_uplink_capability { return Err(ErrorCode::CapabilityDenied); } let platform = platform.trim().to_ascii_lowercase(); @@ -78,7 +78,7 @@ impl uplink::Host for HostState { platform_user_id: String, content: String, ) -> Result { - if !self.has_uplink_capability { + if !self.invocation_authority_active() || !self.has_uplink_capability { return Err(ErrorCode::CapabilityDenied); } if uplink_id.len() > 64 { diff --git a/crates/astrid-capsule/src/engine/wasm/host_state.rs b/crates/astrid-capsule/src/engine/wasm/host_state.rs index b1330c204..78b34fe0f 100644 --- a/crates/astrid-capsule/src/engine/wasm/host_state.rs +++ b/crates/astrid-capsule/src/engine/wasm/host_state.rs @@ -341,14 +341,23 @@ pub struct HostState { /// Resolved on BOTH per-invocation paths: the dispatcher-driven /// interceptor path (`invoke_interceptor`) and the guest-pulled /// `ipc::recv` path ([`install_recv_invocation_context`](Self::install_recv_invocation_context), - /// via [`profile_cache`](Self::profile_cache)). Either way the *default* - /// quotas apply when the principal is unconfigured, by one of two - /// mechanisms: with no cache (tests / single-tenant) this stays `None` - /// and [`effective_profile`](Self::effective_profile) substitutes the - /// process-global default; with a cache, a principal that has no profile - /// file resolves to `Some(PrincipalProfile::default())` (a missing file is - /// not an error — see [`PrincipalProfile::load`](astrid_core::profile::PrincipalProfile::load)). + /// via [`profile_cache`](Self::profile_cache)). With no cache (tests / + /// single-tenant), this stays `None` and + /// [`effective_profile`](Self::effective_profile) substitutes the + /// process-global default. With a cache, only the bootstrap `default` + /// principal retains the missing-file compatibility fallback; a missing + /// profile for any non-default principal is an authorization failure. pub invocation_profile: Option>, + /// Whether the current invocation's profile resolved successfully. + /// + /// A restricted synthetic profile supplies fail-closed quota/network/process + /// values after a recv-path resolution error, while this bit prevents the + /// guest from publishing IPC effects until real policy is available again. + pub invocation_profile_authorized: bool, + /// Shared per-principal lifecycle admission fence. Run-loop host calls + /// acquire a guard from this tracker and retain it through the actual + /// effect, so deletion waits for effects admitted before retirement. + pub(super) principal_invocations: Option>, /// Shared profile-cache handle, used by the `ipc::recv` path to resolve /// the invoking principal's [`PrincipalProfile`](astrid_core::profile::PrincipalProfile) /// into [`invocation_profile`](Self::invocation_profile). diff --git a/crates/astrid-capsule/src/engine/wasm/host_state_cancel_tests.rs b/crates/astrid-capsule/src/engine/wasm/host_state_cancel_tests.rs index ee8936e1c..6ded49af4 100644 --- a/crates/astrid-capsule/src/engine/wasm/host_state_cancel_tests.rs +++ b/crates/astrid-capsule/src/engine/wasm/host_state_cancel_tests.rs @@ -12,13 +12,83 @@ use std::sync::Arc; use tokio::sync::Semaphore; use super::super::test_fixtures::minimal_host_state; -use super::super::{cancel_principal_token, install_principal_overlays_sync}; +use super::super::{ + PrincipalInvocationTracker, cancel_principal_token, install_principal_overlays_sync, + resume_principal_token, +}; +use crate::engine::wasm::bindings::astrid::elicit::host::Host as ElicitHost; +use crate::engine::wasm::bindings::astrid::fs::host::{ErrorCode as FsError, Host as FsHost}; +use crate::engine::wasm::bindings::astrid::kv::host::{ErrorCode as KvError, Host as KvHost}; use astrid_events::ipc::Topic; fn alice() -> astrid_core::PrincipalId { astrid_core::PrincipalId::new("agent-alice").expect("valid principal") } +struct BlockingSetStore { + inner: astrid_storage::MemoryKvStore, + entered: Arc, + release: Arc, +} + +#[async_trait::async_trait] +impl astrid_storage::KvStore for BlockingSetStore { + async fn get( + &self, + namespace: &str, + key: &str, + ) -> astrid_storage::StorageResult>> { + self.inner.get(namespace, key).await + } + async fn set( + &self, + namespace: &str, + key: &str, + value: Vec, + ) -> astrid_storage::StorageResult<()> { + self.entered.notify_one(); + self.release.notified().await; + self.inner.set(namespace, key, value).await + } + async fn delete(&self, namespace: &str, key: &str) -> astrid_storage::StorageResult { + self.inner.delete(namespace, key).await + } + async fn exists(&self, namespace: &str, key: &str) -> astrid_storage::StorageResult { + self.inner.exists(namespace, key).await + } + async fn list_keys(&self, namespace: &str) -> astrid_storage::StorageResult> { + self.inner.list_keys(namespace).await + } + async fn list_keys_with_prefix( + &self, + namespace: &str, + prefix: &str, + ) -> astrid_storage::StorageResult> { + self.inner.list_keys_with_prefix(namespace, prefix).await + } + async fn compare_and_swap( + &self, + namespace: &str, + key: &str, + expected: Option<&[u8]>, + new: Vec, + ) -> astrid_storage::StorageResult { + self.inner + .compare_and_swap(namespace, key, expected, new) + .await + } + async fn clear_namespace(&self, namespace: &str) -> astrid_storage::StorageResult { + self.inner.clear_namespace(namespace).await + } + async fn clear_prefix( + &self, + namespace: &str, + prefix: &str, + ) -> astrid_storage::StorageResult { + self.inner.clear_prefix(namespace, prefix).await + } +} + fn msg_from(principal: &astrid_core::PrincipalId) -> astrid_events::ipc::IpcMessage { astrid_events::ipc::IpcMessage::new( Topic::from_raw("some.v1.event"), @@ -50,8 +120,7 @@ async fn view_release_cancel_unblocks_principal_wait_without_instance_cancel() { .await }); - // The view-release path: cancel + REMOVE exactly A's token. - cancel_principal_token(&state.principal_cancel_tokens, &a); + cancel_principal_token(&state.principal_cancel_tokens, &state.cancel_token, &a); let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), waiter) .await @@ -67,11 +136,10 @@ async fn view_release_cancel_unblocks_principal_wait_without_instance_cancel() { ); } -/// (b) Regression pin for the remove → reinstall path: after a view-release -/// cancel (which removes A's map entry), the NEXT overlay install for A must -/// lazily mint a FRESH, uncancelled token — not resurrect the cancelled one. +/// (b) A late invocation after unregister must keep the retirement tombstone; +/// only explicit view re-registration may mint a fresh token. #[test] -fn fresh_overlay_after_view_release_cancel_yields_uncancelled_token() { +fn retired_overlay_stays_cancelled_until_explicit_resume() { let rt = tokio::runtime::Builder::new_current_thread() .build() .unwrap(); @@ -80,10 +148,15 @@ fn fresh_overlay_after_view_release_cancel_yields_uncancelled_token() { assert!(install_principal_overlays_sync(&mut state, Some(&a))); let first = state.effective_cancel_token(); - cancel_principal_token(&state.principal_cancel_tokens, &a); + cancel_principal_token(&state.principal_cancel_tokens, &state.cancel_token, &a); assert!(first.is_cancelled(), "release must cancel the live token"); - // A reinstalls the capsule and invokes again: a fresh overlay install. + // A late invocation that lost the unregister race remains cancelled. + assert!(install_principal_overlays_sync(&mut state, Some(&a))); + assert!(state.effective_cancel_token().is_cancelled()); + + // A legitimate delete-then-recreate crosses an explicit registration edge. + resume_principal_token(&state.principal_cancel_tokens, &a); assert!(install_principal_overlays_sync(&mut state, Some(&a))); assert!( !state.effective_cancel_token().is_cancelled(), @@ -145,15 +218,21 @@ fn clear_stale_invocation_cancel_token_rearms_only_while_instance_alive() { let mut state = minimal_host_state(rt.handle().clone()); let a = alice(); assert!(install_principal_overlays_sync(&mut state, Some(&a))); - cancel_principal_token(&state.principal_cancel_tokens, &a); + cancel_principal_token(&state.principal_cancel_tokens, &state.cancel_token, &a); state.clear_stale_invocation_cancel_token(); assert!( state.invocation_cancel_token.is_none(), - "a departed principal's cancelled token must not poison the pump" + "the local overlay must clear so the shared pump can receive another caller" ); assert!(!state.effective_cancel_token().is_cancelled()); + state.install_recv_invocation_context(&msg_from(&a)); + assert!( + state.effective_cancel_token().is_cancelled(), + "a queued retired-principal message must restore the tombstone" + ); - // Uncancelled overlay: left untouched. + // Explicit registration reopens the identity. + resume_principal_token(&state.principal_cancel_tokens, &a); assert!(install_principal_overlays_sync(&mut state, Some(&a))); state.clear_stale_invocation_cancel_token(); assert!( @@ -173,10 +252,8 @@ fn clear_stale_invocation_cancel_token_rearms_only_while_instance_alive() { assert!(torn_down.effective_cancel_token().is_cancelled()); } -/// The recv fast path (same-principal message) must refresh the token from -/// the shared map: a principal that departed (token cancelled + removed) and -/// re-registered gets a fresh token on its next message even though the -/// data overlays are deliberately kept. +/// The recv fast path must not mint authority after unregister. Explicit view +/// registration is the only operation that may reopen the principal. #[test] fn recv_fast_path_refreshes_token_after_view_release_cancel() { let rt = tokio::runtime::Builder::new_current_thread() @@ -187,17 +264,141 @@ fn recv_fast_path_refreshes_token_after_view_release_cancel() { state.install_recv_invocation_context(&msg_from(&a)); let first = state.effective_cancel_token(); - cancel_principal_token(&state.principal_cancel_tokens, &a); + cancel_principal_token(&state.principal_cancel_tokens, &state.cancel_token, &a); assert!(first.is_cancelled()); - // Same principal publishes again after re-registering: the fast path - // keeps the KV/log overlays but must re-mint the cancel token. + // Same principal publishes after unregister, without a registration edge. state.install_recv_invocation_context(&msg_from(&a)); assert!( state .invocation_cancel_token .as_ref() - .is_some_and(|t| !t.is_cancelled()), - "the fast path must lazily mint a fresh token for a re-registered principal" + .is_some_and(tokio_util::sync::CancellationToken::is_cancelled), + "the fast path must preserve the retirement tombstone" + ); + + resume_principal_token(&state.principal_cancel_tokens, &a); + state.install_recv_invocation_context(&msg_from(&a)); + assert!(!state.effective_cancel_token().is_cancelled()); +} + +#[tokio::test] +async fn retirement_fence_rejects_late_admission_and_drains_existing_call() { + let tracker = Arc::new(PrincipalInvocationTracker::default()); + let principal = alice(); + let admitted = tracker.begin(&principal).expect("initial admission"); + tracker.retire(&principal); + assert!( + tracker.begin(&principal).is_none(), + "late work must be fenced" + ); + + let waiter = { + let tracker = Arc::clone(&tracker); + let principal = principal.clone(); + tokio::spawn(async move { tracker.wait_for_quiescence(&principal).await }) + }; + tokio::task::yield_now().await; + assert!( + !waiter.is_finished(), + "retirement must wait for admitted work" + ); + drop(admitted); + waiter.await.unwrap(); + + tracker.resume(&principal); + assert!(tracker.begin(&principal).is_some()); +} + +#[test] +fn retired_principal_loses_kv_fs_and_secret_host_authority_without_harming_peer() { + let rt = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let mut state = minimal_host_state(rt.handle().clone()); + let a = alice(); + let bob = astrid_core::PrincipalId::new("agent-bob").unwrap(); + + assert!(install_principal_overlays_sync(&mut state, Some(&a))); + KvHost::kv_set(&mut state, "before".into(), b"allowed".to_vec()).unwrap(); + cancel_principal_token(&state.principal_cancel_tokens, &state.cancel_token, &a); + + assert!(matches!( + KvHost::kv_set(&mut state, "after".into(), b"denied".to_vec()), + Err(KvError::Unknown(_)) + )); + assert!(matches!( + FsHost::write_file( + &mut state, + "cwd://retired-effect".into(), + b"denied".to_vec() + ), + Err(FsError::CapabilityDenied) + )); + assert!(ElicitHost::has_secret(&mut state, "token".into()).is_err()); + + // The same shared Store may subsequently serve a peer. Installing Bob's + // overlay selects his independent live token; Alice's tombstone remains. + assert!(install_principal_overlays_sync(&mut state, Some(&bob))); + KvHost::kv_set(&mut state, "peer".into(), b"alive".to_vec()).unwrap(); + assert_eq!( + KvHost::kv_get(&mut state, "peer".into()).unwrap(), + Some(b"alive".to_vec()) + ); + assert!(!state.cancel_token.is_cancelled()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn retirement_waits_for_admitted_recv_kv_effect_before_reclamation() { + let tracker = Arc::new(PrincipalInvocationTracker::default()); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let backend: Arc = Arc::new(BlockingSetStore { + inner: astrid_storage::MemoryKvStore::new(), + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }); + let mut state = minimal_host_state(tokio::runtime::Handle::current()); + let a = alice(); + state.principal_invocations = Some(Arc::clone(&tracker)); + state.invocation_kv = Some(astrid_storage::ScopedKvStore::new(backend, "alice:test").unwrap()); + assert!(install_principal_overlays_sync(&mut state, Some(&a))); + // Restore the barrier backend after overlay installation selected the + // production principal store. + let backend: Arc = Arc::new(BlockingSetStore { + inner: astrid_storage::MemoryKvStore::new(), + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }); + state.invocation_kv = Some(astrid_storage::ScopedKvStore::new(backend, "alice:test").unwrap()); + + let operation = tokio::task::spawn_blocking(move || { + let result = KvHost::kv_set(&mut state, "effect".into(), b"committed".to_vec()); + (state, result) + }); + entered.notified().await; + tracker.retire(&a); + let draining = { + let tracker = Arc::clone(&tracker); + let a = a.clone(); + tokio::spawn(async move { tracker.wait_for_quiescence(&a).await }) + }; + tokio::task::yield_now().await; + assert!( + !draining.is_finished(), + "reclamation must wait behind the KV effect" + ); + + release.notify_one(); + let (mut state, result) = operation.await.unwrap(); + result.unwrap(); + draining.await.unwrap(); + + let bob = astrid_core::PrincipalId::new("agent-bob-barrier").unwrap(); + tracker.resume(&bob); + assert!(install_principal_overlays_sync(&mut state, Some(&bob))); + assert!( + state.begin_host_operation().is_ok(), + "peer authority remains live" ); } diff --git a/crates/astrid-capsule/src/engine/wasm/host_state_effective.rs b/crates/astrid-capsule/src/engine/wasm/host_state_effective.rs index b1698fab2..3fb0e2247 100644 --- a/crates/astrid-capsule/src/engine/wasm/host_state_effective.rs +++ b/crates/astrid-capsule/src/engine/wasm/host_state_effective.rs @@ -4,6 +4,19 @@ use super::*; impl HostState { + /// Admit one principal-scoped host operation and hold its lifecycle count + /// until the returned guard drops. + pub(crate) fn begin_host_operation( + &self, + ) -> Result, ()> { + if !self.invocation_authority_active() { + return Err(()); + } + self.principal_invocations + .as_ref() + .map(|tracker| tracker.begin(&self.effective_principal()).ok_or(())) + .transpose() + } /// Return the effective KV store for the current invocation. /// /// Per-principal isolation lives HERE, not in capsule keys. Every real store @@ -187,6 +200,16 @@ impl HostState { .unwrap_or_else(|| self.cancel_token.clone()) } + /// Whether the current principal-scoped host authority is still live. + /// + /// Authority-sensitive hosts use this common decision. Profile failure and + /// view retirement are revocations, not merely quota/liveness signals; a + /// guest that was already running must not retain principal-scoped access. + #[must_use] + pub(crate) fn invocation_authority_active(&self) -> bool { + self.invocation_profile_authorized && !self.effective_cancel_token().is_cancelled() + } + /// Return the effective quota profile for the current invocation. /// /// Prefers `invocation_profile` (set by @@ -207,4 +230,61 @@ impl HostState { None => astrid_core::profile::PrincipalProfile::default_ref(), } } + + /// Enforce the derived-principal network boundary while preserving legacy + /// profiles that predate principal-level host enforcement. Membership in + /// the built-in `restricted` group opts into fail-closed egress: only the + /// profile's explicit `network.egress` patterns may resolve/connect. + pub(crate) fn principal_egress_allows(&self, host: &str, port: Option) -> bool { + if !self.invocation_authority_active() { + return false; + } + let profile = self.effective_profile(); + if !profile + .groups + .iter() + .any(|group| group == astrid_core::groups::BUILTIN_RESTRICTED) + { + return true; + } + profile.network.egress.iter().any(|pattern| { + let Some((pattern_host, pattern_port)) = pattern.rsplit_once(':') else { + return false; + }; + if !pattern_host.eq_ignore_ascii_case(host) { + return false; + } + match port { + Some(port) => { + pattern_port == "*" + || pattern_port.parse::().is_ok_and(|value| value == port) + }, + None => pattern_port == "*" || pattern_port.parse::().is_ok(), + } + }) + } + + /// Restricted principals may spawn only executables explicitly named in + /// their profile. Derived principals currently provision an empty list, so + /// a child process cannot bypass the host's network boundary. + pub(crate) fn principal_process_allows(&self, command: &str) -> bool { + if !self.invocation_authority_active() { + return false; + } + let profile = self.effective_profile(); + if !profile + .groups + .iter() + .any(|group| group == astrid_core::groups::BUILTIN_RESTRICTED) + { + return true; + } + profile.process.allow.iter().any(|allowed| { + allowed == command + || (!allowed.contains('/') + && std::path::Path::new(command) + .file_name() + .is_some_and(|name| name == std::ffi::OsStr::new(allowed))) + }) + } } diff --git a/crates/astrid-capsule/src/engine/wasm/host_state_hook.rs b/crates/astrid-capsule/src/engine/wasm/host_state_hook.rs index b5c591fb5..cf172a79d 100644 --- a/crates/astrid-capsule/src/engine/wasm/host_state_hook.rs +++ b/crates/astrid-capsule/src/engine/wasm/host_state_hook.rs @@ -76,6 +76,8 @@ impl HostState { invocation_secret_store: None, invocation_capsule_log: None, invocation_profile: None, + invocation_profile_authorized: true, + principal_invocations: None, profile_cache: None, invocation_env_overlay: None, kv_backend, diff --git a/crates/astrid-capsule/src/engine/wasm/host_state_invocation.rs b/crates/astrid-capsule/src/engine/wasm/host_state_invocation.rs index bb92f3455..77a385adb 100644 --- a/crates/astrid-capsule/src/engine/wasm/host_state_invocation.rs +++ b/crates/astrid-capsule/src/engine/wasm/host_state_invocation.rs @@ -21,10 +21,13 @@ impl HostState { /// `Some(p)` looks up `p`'s entry in the shared /// [`principal_cancel_tokens`](Self::principal_cancel_tokens) map, lazily /// minting a fresh [`child_token`](CancellationToken::child_token) of the - /// instance [`cancel_token`](Self::cancel_token) if absent — so a - /// full-instance cancel still cascades, and a principal whose token was - /// cancelled + removed on view release gets a FRESH, uncancelled token when - /// it re-registers and invokes again. `None` (principal-less context) + /// instance [`cancel_token`](Self::cancel_token) if absent. A cancelled + /// entry is deliberately retained as a retirement tombstone: an invocation + /// that lost the unregister race must inherit the cancelled token rather + /// than minting fresh authority. Explicit view registration calls + /// [`ExecutionEngine::resume_for`](crate::engine::ExecutionEngine::resume_for) + /// before a reused principal may receive a fresh token. `None` + /// (principal-less context) /// clears the overlay so waits fall back to the instance token. /// /// The map mutex is only ever held for this entry-or-clone, so a poisoned @@ -78,6 +81,11 @@ impl HostState { .as_ref() .is_some_and(CancellationToken::is_cancelled) { + // Clear only the Store-local overlay so the shared recv pump can + // wait for another principal. The cancelled entry remains in the + // shared map as a retirement tombstone; if a queued message from + // the departed principal is received, context installation restores + // that cancelled token before guest code can act on the message. self.invocation_cancel_token = None; } } @@ -159,7 +167,7 @@ impl HostState { .caller_context .as_ref() .and_then(|c| c.principal.clone()); - if new_principal == existing_principal { + if new_principal == existing_principal && self.invocation_profile_authorized { // Refresh the caller context so e.g. topic name / payload // tracking stays current. Also refresh the env overlay: dashboard // onboarding can write config after a capsule is already loaded, @@ -222,20 +230,31 @@ impl HostState { // the dispatcher-driven interceptor path. When `msg.principal` is // absent/unparseable the owner's own profile is resolved, mirroring // `invoke_interceptor`'s `owner_principal` fallback. Best-effort: a - // failed load logs and leaves `invocation_profile = None` (the same - // process-global default fall-back as a missing cache), never denying - // the message — the recv path has no error channel. + // failed load logs and installs a restricted deny profile. The recv + // path has no error channel, so carrying an explicit authority floor is + // the only fail-closed outcome that still lets the shared pump advance. let profile_principal = publisher.clone().unwrap_or_else(|| self.principal.clone()); - self.invocation_profile = self.profile_cache.as_ref().and_then(|cache| { + let profile_cache = self.profile_cache.clone(); + self.invocation_profile_authorized = true; + self.invocation_profile = profile_cache.as_ref().map(|cache| { match cache.resolve(&profile_principal) { - Ok(profile) => Some(profile), + Ok(profile) => profile, Err(e) => { + self.invocation_profile_authorized = false; tracing::warn!( principal = %profile_principal, error = %e, - "recv-path profile resolve failed; per-principal quotas fall back to the default profile" + "recv-path profile resolve failed; installing restricted authority floor" ); - None + // This profile now gates authority as well as quotas. A + // failed lookup must therefore retain the restricted + // fail-closed marker instead of falling back to the + // process-global legacy profile (which has no restricted + // group and would permit network/process host calls). + Arc::new(astrid_core::profile::PrincipalProfile { + groups: vec![astrid_core::groups::BUILTIN_RESTRICTED.to_string()], + ..Default::default() + }) }, } }); diff --git a/crates/astrid-capsule/src/engine/wasm/host_state_tests.rs b/crates/astrid-capsule/src/engine/wasm/host_state_tests.rs index aee0ae3ab..3fd2e5ba4 100644 --- a/crates/astrid-capsule/src/engine/wasm/host_state_tests.rs +++ b/crates/astrid-capsule/src/engine/wasm/host_state_tests.rs @@ -862,3 +862,76 @@ fn effective_kv_falls_back_to_neutral_for_principalless_message() { ); assert!(std::ptr::eq(state.effective_kv(), &state.kv)); } + +#[test] +fn restricted_profile_egress_is_fail_closed_and_endpoint_scoped() { + let rt = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let mut state = minimal_host_state(rt.handle().clone()); + let mut profile = astrid_core::profile::PrincipalProfile { + groups: vec![astrid_core::groups::BUILTIN_RESTRICTED.to_string()], + ..Default::default() + }; + state.invocation_profile = Some(Arc::new(profile.clone())); + assert!(!state.principal_egress_allows("api.example.com", Some(443))); + assert!(!state.principal_egress_allows("api.example.com", None)); + assert!(!state.principal_process_allows("curl")); + + profile.network.egress = vec!["api.example.com:443".to_string()]; + profile.process.allow = vec!["/usr/bin/curl".to_string(), "git".to_string()]; + state.invocation_profile = Some(Arc::new(profile)); + assert!(state.principal_egress_allows("API.EXAMPLE.COM", Some(443))); + assert!(state.principal_egress_allows("api.example.com", None)); + assert!(!state.principal_egress_allows("api.example.com", Some(80))); + assert!(!state.principal_egress_allows("other.example.com", Some(443))); + assert!(state.principal_process_allows("/usr/bin/curl")); + assert!(state.principal_process_allows("/opt/homebrew/bin/git")); + assert!(!state.principal_process_allows("/usr/bin/wget")); +} + +#[test] +fn legacy_nonrestricted_profile_preserves_existing_egress_behavior() { + let rt = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let mut state = minimal_host_state(rt.handle().clone()); + state.invocation_profile = Some(Arc::new(astrid_core::profile::PrincipalProfile::default())); + assert!(state.principal_egress_allows("api.example.com", Some(443))); + assert!(state.principal_process_allows("anything")); +} + +#[test] +fn recv_profile_load_failure_installs_restricted_authority_floor() { + let rt = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let mut state = minimal_host_state(rt.handle().clone()); + let dir = tempfile::tempdir().unwrap(); + let home = astrid_core::dirs::AstridHome::from_path(dir.path()); + let principal = astrid_core::PrincipalId::new("broken-profile").unwrap(); + let path = astrid_core::profile::PrincipalProfile::path_for(&home, &principal); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "this is not valid = [toml").unwrap(); + state.profile_cache = Some(Arc::new( + crate::profile_cache::PrincipalProfileCache::with_home(home), + )); + let message = astrid_events::ipc::IpcMessage::new( + Topic::from_raw("user.v1.prompt"), + astrid_events::ipc::IpcPayload::RawJson(serde_json::json!({})), + uuid::Uuid::new_v4(), + ) + .with_principal(principal.to_string()); + + state.install_recv_invocation_context(&message); + assert!(!state.invocation_profile_authorized); + assert!( + state + .effective_profile() + .groups + .iter() + .any(|group| group == astrid_core::groups::BUILTIN_RESTRICTED) + ); + assert!(!state.principal_egress_allows("api.example.com", Some(443))); + assert!(!state.principal_process_allows("curl")); +} diff --git a/crates/astrid-capsule/src/engine/wasm/mod.rs b/crates/astrid-capsule/src/engine/wasm/mod.rs index eb5e01aa0..4a82a64e9 100644 --- a/crates/astrid-capsule/src/engine/wasm/mod.rs +++ b/crates/astrid-capsule/src/engine/wasm/mod.rs @@ -181,6 +181,10 @@ pub struct WasmEngine { /// shared runtime interrupts its in-flight blocking host calls without /// touching the other principals' work. principal_cancel_tokens: Option, + /// Admission fence and active-call counter for each principal sharing this + /// runtime. View retirement closes admission first, then waits for calls + /// admitted under the old view to return before reclamation proceeds. + principal_invocations: Option>, /// RAII guard that stops the epoch ticker thread on drop. epoch_ticker: Option, /// Shared per-principal profile cache (Layer 3, issue #666). @@ -293,6 +297,95 @@ pub struct WasmEngine { Option>, } +#[derive(Default)] +pub(super) struct PrincipalInvocationTracker { + state: std::sync::Mutex, + changed: tokio::sync::Notify, +} + +#[derive(Default)] +struct PrincipalInvocationState { + retired: std::collections::HashSet, + active: std::collections::HashMap, +} + +pub(crate) struct PrincipalInvocationGuard { + tracker: Arc, + principal: astrid_core::PrincipalId, +} + +impl PrincipalInvocationTracker { + pub(super) fn begin( + self: &Arc, + principal: &astrid_core::PrincipalId, + ) -> Option { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.retired.contains(principal) { + return None; + } + *state.active.entry(principal.clone()).or_default() += 1; + Some(PrincipalInvocationGuard { + tracker: Arc::clone(self), + principal: principal.clone(), + }) + } + + fn retire(&self, principal: &astrid_core::PrincipalId) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retired + .insert(principal.clone()); + } + + fn resume(&self, principal: &astrid_core::PrincipalId) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retired + .remove(principal); + } + + async fn wait_for_quiescence(&self, principal: &astrid_core::PrincipalId) { + loop { + let notified = self.changed.notified(); + let active = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .active + .get(principal) + .copied() + .unwrap_or(0); + if active == 0 { + return; + } + notified.await; + } + } +} + +impl Drop for PrincipalInvocationGuard { + fn drop(&mut self) { + let mut state = self + .tracker + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(active) = state.active.get_mut(&self.principal) { + *active = active.saturating_sub(1); + if *active == 0 { + state.active.remove(&self.principal); + } + } + drop(state); + self.tracker.changed.notify_waiters(); + } +} + impl WasmEngine { /// Construct a WASM engine for one capsule. /// @@ -334,6 +427,7 @@ impl WasmEngine { ready_rx: None, cancel_token: None, principal_cancel_tokens: None, + principal_invocations: None, epoch_ticker: None, profile_cache: None, owner_principal: None, @@ -1077,30 +1171,39 @@ fn install_principal_overlays_sync( true } -/// Cancel and REMOVE `principal`'s per-principal cancellation token. +/// Cancel and retain `principal`'s per-principal cancellation token as a +/// retirement tombstone. /// /// The core of [`ExecutionEngine::request_cancel_for`] for the WASM engine, /// split out so the mechanism is unit-testable without loading a component. -/// Removal (not just cancellation) matters: a principal that releases its view -/// of a shared runtime and later re-registers one (remove → reinstall) must -/// lazily receive a FRESH, uncancelled child token from the overlay installer, -/// not the insta-cancelled leftover. A principal with no entry (never invoked, -/// or already removed) is a no-op. The map mutex is held only for the removal; -/// a poisoned lock is recovered rather than propagated — cancellation is a -/// liveness mechanism and must not itself wedge. +/// Retention closes the unregister/install race: a late invocation sees the +/// cancelled entry and cannot mint fresh authority. A principal with no prior +/// invocation still receives a cancelled tombstone. Explicit view registration +/// removes the tombstone through [`resume_principal_token`]. fn cancel_principal_token( tokens: &PrincipalCancelTokens, + parent: &tokio_util::sync::CancellationToken, principal: &astrid_core::principal::PrincipalId, ) { let token = { let mut map = tokens .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - map.remove(principal) + map.entry(principal.clone()) + .or_insert_with(|| parent.child_token()) + .clone() }; - if let Some(token) = token { - token.cancel(); - } + token.cancel(); +} + +fn resume_principal_token( + tokens: &PrincipalCancelTokens, + principal: &astrid_core::principal::PrincipalId, +) { + tokens + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(principal); } /// Open (creating the log dir if needed) the daily-rotated log file for @@ -1411,6 +1514,7 @@ impl ExecutionEngine for WasmEngine { // principal's entry reaches its waits on any pooled instance. let principal_cancel_tokens = HostState::new_principal_cancel_tokens(); let principal_cancel_tokens_for_state = principal_cancel_tokens.clone(); + let principal_invocations = Arc::new(PrincipalInvocationTracker::default()); let process_tracker = Arc::new(crate::engine::wasm::host::process::ProcessTracker::new()); let process_tracker_for_listener = process_tracker.clone(); // Host-owned persistent-process registry — one per engine, cloned @@ -1784,6 +1888,7 @@ impl ExecutionEngine for WasmEngine { let io_semaphore = io_semaphore.clone(); let cancel_token_for_state = cancel_token_for_state.clone(); let principal_cancel_tokens_for_state = principal_cancel_tokens_for_state.clone(); + let principal_invocations_for_state = Arc::clone(&principal_invocations); let process_tracker = process_tracker.clone(); let persistent_registry = persistent_registry.clone(); let memory_ledger = memory_ledger.clone(); @@ -1850,6 +1955,8 @@ impl ExecutionEngine for WasmEngine { invocation_secret_store: None, invocation_capsule_log: None, invocation_profile: None, + invocation_profile_authorized: true, + principal_invocations: Some(Arc::clone(&principal_invocations_for_state)), profile_cache: st_profile_cache.clone(), invocation_env_overlay: None, // Neutral, physically-isolated KV fallback (see the `kv` field @@ -2239,6 +2346,7 @@ impl ExecutionEngine for WasmEngine { self.cancel_token = Some(cancel_token.clone()); self.principal_cancel_tokens = Some(principal_cancel_tokens); + self.principal_invocations = Some(principal_invocations); self.wasmtime_engine = Some(wt_engine.clone()); // Start the epoch ticker for timeout enforcement. @@ -2440,8 +2548,35 @@ impl ExecutionEngine for WasmEngine { } fn request_cancel_for(&self, principal: &astrid_core::principal::PrincipalId) { + if let Some(tracker) = &self.principal_invocations { + tracker.retire(principal); + } + if let (Some(tokens), Some(parent)) = (&self.principal_cancel_tokens, &self.cancel_token) { + cancel_principal_token(tokens, parent, principal); + } + if let Some(processes) = &self.persistent_processes { + processes.shutdown_for(principal); + } + if let Some(processes) = &self.process_tracker + && let Ok(runtime) = tokio::runtime::Handle::try_current() + { + processes.cancel_for_principal(principal, &runtime); + } + } + + fn resume_for(&self, principal: &astrid_core::principal::PrincipalId) { + if let Some(tracker) = &self.principal_invocations { + tracker.resume(principal); + } if let Some(tokens) = &self.principal_cancel_tokens { - cancel_principal_token(tokens, principal); + resume_principal_token(tokens, principal); + } + } + + async fn quiesce_for(&self, principal: &astrid_core::principal::PrincipalId) { + self.request_cancel_for(principal); + if let Some(tracker) = &self.principal_invocations { + tracker.wait_for_quiescence(principal).await; } } @@ -2485,6 +2620,17 @@ impl ExecutionEngine for WasmEngine { .and_then(|p| astrid_core::PrincipalId::new(p).ok()) .or_else(|| self.owner_principal.clone()) .unwrap_or_default(); + let _invocation_guard = match self.principal_invocations.as_ref() { + Some(tracker) => match tracker.begin(&invoking_principal) { + Some(guard) => Some(guard), + None => { + return Ok(crate::capsule::InterceptResult::Deny { + reason: format!("principal '{invoking_principal}' capsule view is retired"), + }); + }, + }, + None => None, + }; // Per-invocation timing for the live "sample" view (#816 // observability). Started before profile resolution + pool checkout so @@ -2712,6 +2858,7 @@ impl ExecutionEngine for WasmEngine { invoking_principal.clone(), ); state.invocation_profile = invocation_profile.clone(); + state.invocation_profile_authorized = true; state.invocation_env_overlay = load_invocation_env_overlay(&invoking_principal, state.capsule_id.as_str()); @@ -2750,13 +2897,21 @@ impl ExecutionEngine for WasmEngine { "astrid-hook-trigger", ); match typed_lookup { - Ok(func) => func - .call_async(&mut *s, (action.to_string(), payload.to_vec())) - .await - .map(|(cr,)| cr) - .map_err(|e| { - CapsuleError::WasmError(format!("astrid_hook_trigger failed: {e:?}")) - }), + Ok(func) => { + let invocation_cancel = s.data().effective_cancel_token(); + tokio::select! { + biased; + () = invocation_cancel.cancelled() => Err(CapsuleError::WasmError( + "principal capsule view retired during invocation".to_string() + )), + called = func.call_async( + &mut *s, + (action.to_string(), payload.to_vec()) + ) => called.map(|(cr,)| cr).map_err(|e| { + CapsuleError::WasmError(format!("astrid_hook_trigger failed: {e:?}")) + }), + } + }, Err(e) => Err(CapsuleError::UnsupportedEntryPoint(format!( "capsule does not export `astrid-hook-trigger`: {e}" ))), @@ -2973,6 +3128,8 @@ async fn build_lifecycle_host_state( invocation_secret_store: None, invocation_capsule_log: None, invocation_profile: None, + invocation_profile_authorized: true, + principal_invocations: None, // Lifecycle hooks don't run the per-principal recv loop; no cache needed. profile_cache: None, invocation_env_overlay: None, diff --git a/crates/astrid-capsule/src/engine/wasm/pool.rs b/crates/astrid-capsule/src/engine/wasm/pool.rs index 8c9f869c5..e25262687 100644 --- a/crates/astrid-capsule/src/engine/wasm/pool.rs +++ b/crates/astrid-capsule/src/engine/wasm/pool.rs @@ -435,6 +435,7 @@ fn clear_on_return(state: &mut HostState, reset_resources: bool) { state.invocation_secret_store = None; state.invocation_capsule_log = None; state.invocation_profile = None; + state.invocation_profile_authorized = true; state.invocation_env_overlay = None; // A leftover per-principal cancellation token (possibly already cancelled // by a view release) must not decide which teardown signal the NEXT diff --git a/crates/astrid-capsule/src/engine/wasm/test_fixtures.rs b/crates/astrid-capsule/src/engine/wasm/test_fixtures.rs index f5d44d9a3..c210720d2 100644 --- a/crates/astrid-capsule/src/engine/wasm/test_fixtures.rs +++ b/crates/astrid-capsule/src/engine/wasm/test_fixtures.rs @@ -104,6 +104,8 @@ pub(crate) fn minimal_host_state(rt: tokio::runtime::Handle) -> HostState { invocation_secret_store: None, invocation_capsule_log: None, invocation_profile: None, + invocation_profile_authorized: true, + principal_invocations: None, profile_cache: None, invocation_env_overlay: None, kv, diff --git a/crates/astrid-capsule/src/profile_cache.rs b/crates/astrid-capsule/src/profile_cache.rs index b6dcf124a..97b7f31f8 100644 --- a/crates/astrid-capsule/src/profile_cache.rs +++ b/crates/astrid-capsule/src/profile_cache.rs @@ -15,8 +15,9 @@ //! //! # Fail-closed //! -//! [`PrincipalProfile::load`] treats a missing file as [`PrincipalProfile::default`] -//! (single-tenant parity), but malformed TOML, unknown fields, invalid values, +//! The bootstrap `default` principal retains missing-file single-tenant parity. +//! A missing profile for every non-default identity is a hard error, as are +//! malformed TOML, unknown fields, invalid values, //! or a future `profile_version` are hard errors. Those errors propagate out //! of [`PrincipalProfileCache::resolve`] so callers can deny the invocation //! with a clear audit trail, rather than silently falling back to permissive @@ -113,6 +114,11 @@ impl PrincipalProfileCache { /// The caller is expected to deny the invocation on any of these errors /// (see Layer 3 design doc, issue #666). pub fn resolve(&self, principal: &PrincipalId) -> ProfileResult> { + // `default` is the explicit single-tenant compatibility identity and + // may use the built-in profile when no file exists. Every other + // principal is an isolation boundary: silently manufacturing the + // permissive default profile for a deleted or half-provisioned alias + // would restore authority after its profile fence was removed. loop { let state = self .state @@ -123,7 +129,11 @@ impl PrincipalProfileCache { } let generation = state.generations.get(principal).copied().unwrap_or(0); drop(state); - let profile = Arc::new(PrincipalProfile::load(&self.astrid_home, principal)?); + let profile = Arc::new(if *principal == PrincipalId::default() { + PrincipalProfile::load(&self.astrid_home, principal)? + } else { + PrincipalProfile::load_required(&self.astrid_home, principal)? + }); if let Some(profile) = self.publish_loaded(principal, profile, generation) { return Ok(profile); } @@ -296,9 +306,9 @@ mod tests { } #[test] - fn missing_file_returns_default_and_caches_it() { + fn only_default_principal_may_use_missing_file_compatibility_profile() { let (_dir, cache) = fixture(); - let p = principal("alice"); + let p = PrincipalId::default(); let profile = cache.resolve(&p).expect("resolve missing"); assert_eq!(*profile, PrincipalProfile::default()); @@ -307,6 +317,13 @@ mod tests { // Second call: same Arc, no second disk read. let profile2 = cache.resolve(&p).expect("resolve cached"); assert!(Arc::ptr_eq(&profile, &profile2)); + + let alice = principal("alice"); + assert!(matches!( + cache.resolve(&alice), + Err(ProfileError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound + )); + assert_eq!(cache.len(), 1, "failed identities must not be cached"); } #[test] @@ -380,7 +397,11 @@ mod tests { max_memory_bytes = 16777216\n" ), ); - // Bob has no file on disk → Default. + write_profile( + &dir, + &b, + &format!("profile_version = {CURRENT_PROFILE_VERSION}\n"), + ); let pa = cache.resolve(&a).expect("alice"); let pb = cache.resolve(&b).expect("bob"); @@ -402,7 +423,11 @@ mod tests { let (dir, cache) = fixture(); let p = principal("reloader"); - // First load: no file → Default. + write_profile( + &dir, + &p, + &format!("profile_version = {CURRENT_PROFILE_VERSION}\n"), + ); let first = cache.resolve(&p).expect("first resolve"); assert_eq!(first.quotas.max_memory_bytes, DEFAULT_MAX_MEMORY_BYTES); @@ -476,9 +501,14 @@ mod tests { // Lightweight contention check — not a loom model, just a sanity // check that multiple threads can `resolve()` the same principal // without deadlocks or panics. - let (_dir, cache) = fixture(); + let (dir, cache) = fixture(); let cache = Arc::new(cache); let p = principal("racer"); + write_profile( + &dir, + &p, + &format!("profile_version = {CURRENT_PROFILE_VERSION}\n"), + ); let mut handles = Vec::new(); for _ in 0..8 { diff --git a/crates/astrid-cli/src/admin_client.rs b/crates/astrid-cli/src/admin_client.rs index 81f1ec641..fb326ebfd 100644 --- a/crates/astrid-cli/src/admin_client.rs +++ b/crates/astrid-cli/src/admin_client.rs @@ -8,7 +8,7 @@ pub(crate) use astrid_uplink::admin_client::into_result; use anyhow::Result; -use astrid_core::kernel_api::{AdminRequestKind, AdminResponseBody}; +use astrid_core::kernel_api::{AdminRequestKind, AdminResponseBody, AgentDeriveRequest}; use astrid_uplink::admin_client::AdminClient as UplinkAdminClient; /// Admin request surface available to CLI commands. @@ -21,6 +21,13 @@ impl AdminClient { pub(crate) async fn request(&mut self, kind: AdminRequestKind) -> Result { self.0.request(kind).await } + + pub(crate) async fn request_agent_derive( + &mut self, + request: AgentDeriveRequest, + ) -> Result { + self.0.request_agent_derive(request).await + } } /// Connect to the daemon as the process principal. diff --git a/crates/astrid-cli/src/commands/agent/mod.rs b/crates/astrid-cli/src/commands/agent/mod.rs index e52657cc3..a7157f228 100644 --- a/crates/astrid-cli/src/commands/agent/mod.rs +++ b/crates/astrid-cli/src/commands/agent/mod.rs @@ -17,6 +17,8 @@ use clap::{Args, Subcommand}; use colored::Colorize; use serde::Serialize; +mod spawn; + use crate::admin_client::{AdminClient, into_result}; use crate::commands::stub::{self, ISSUE_DELEGATION, ISSUE_REMOTE_AUTH}; use crate::context; @@ -31,6 +33,9 @@ use crate::value_formatter::{ValueFormat, emit_structured}; pub(crate) enum AgentCommand { /// Provision a new agent. Create(CreateArgs), + /// Spawn a restricted throwaway with explicit capsules, state, and egress; + /// run one bounded job, then tear it down (#1217). + Spawn(spawn::SpawnArgs), /// List agents on this host (and registered remotes when ready). List(ListArgs), /// Show the active agent context. @@ -260,6 +265,7 @@ pub(crate) struct StubArgs { pub(crate) async fn run(cmd: AgentCommand) -> Result { match cmd { AgentCommand::Create(args) => run_create(args).await, + AgentCommand::Spawn(args) => spawn::run(args).await, AgentCommand::List(args) => run_list(args).await, AgentCommand::Current => run_current(), AgentCommand::Switch(args) => run_switch(args).await, diff --git a/crates/astrid-cli/src/commands/agent/spawn.rs b/crates/astrid-cli/src/commands/agent/spawn.rs new file mode 100644 index 000000000..d84a2931f --- /dev/null +++ b/crates/astrid-cli/src/commands/agent/spawn.rs @@ -0,0 +1,285 @@ +//! `astrid agent spawn` — atomic locked-down throwaway session (#1217). +//! +//! Composes shipped primitives into one blocking call: +//! 1. **derive** a restricted principal with an explicit runtime capsule set, +//! explicit state namespaces, explicit user-invocable capsules, and an +//! explicit network allow-list. Nothing is inherited implicitly. +//! 2. **run** one bounded job under it — authenticate a fresh uplink *as* the +//! throwaway (its keypair was minted by create), submit the prompt, drain +//! the response. This command is the wall-clock watchdog; nothing in the +//! runtime bounds a multi-turn react loop by wall-clock. +//! 3. **tear down** — delete the throwaway; `AgentDelete` reclaims its +//! on-disk footprint (#1217), on success, failure, or timeout alike. +//! +//! The restricted profile is enforced again at network host-call time. An empty +//! `--allow-egress` set therefore means no outbound network access. + +use std::process::ExitCode; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use astrid_core::kernel_api::{AdminRequestKind, AgentDeriveRequest}; +use astrid_core::{PrincipalId, SessionId}; +use clap::Args; + +use crate::admin_client::{AdminClient, into_result}; +use crate::socket_client::{self, SocketClient}; + +#[derive(Args, Debug, Clone)] +pub(crate) struct SpawnArgs { + /// The job for the throwaway agent: a prompt, or the text/task to act on. + /// Framed as untrusted work — the agent evaluates it, never obeys it. + #[arg(long)] + pub job: String, + + /// Principal to derive selected capsule installs/state from. Nothing is + /// copied unless named by the corresponding flags. Defaults to the active + /// agent. + #[arg(long = "derive-from", value_name = "PRINCIPAL")] + pub derive_from: Option, + + /// Capsule installs required to execute the job (for example a harness and + /// model provider). Repeat for each capsule; no capsule is loaded implicitly. + #[arg(long = "load-capsule", value_name = "CAPSULE", required = true)] + pub load_capsules: Vec, + + /// Loaded capsule whose user-invocable tool surface the derived principal + /// may call. Omitted capsules may still participate in internal orchestration. + #[arg(long = "allow-capsule", value_name = "CAPSULE")] + pub allow_capsules: Vec, + + /// Loaded capsule whose env, KV, and declared secret state is copied from + /// the source. Repeat explicitly; omitted namespaces remain empty. + #[arg(long = "inherit-capsule-state", value_name = "CAPSULE")] + pub inherit_capsule_state: Vec, + + /// Outbound network endpoint allowed to the restricted principal, using a + /// manifest-style `host:port` or `host:*` pattern. Empty means no egress. + #[arg(long = "allow-egress", value_name = "HOST:PORT")] + pub network_egress: Vec, + + /// Explicit name for the throwaway principal. Defaults to + /// `{derive_from}-spawn-{id}`. + #[arg(long)] + pub name: Option, + + /// Wall-clock ceiling in seconds. The command blocks for the job's response + /// up to this long, then cancels the turn and tears down regardless. + #[arg(long, default_value_t = 300, value_parser = clap::value_parser!(u64).range(1..=86_400))] + pub timeout: u64, + + /// Leave the throwaway principal in place instead of deleting it (debug). + #[arg(long)] + pub keep: bool, +} + +pub(crate) async fn run(args: SpawnArgs) -> Result { + crate::commands::daemon::ensure_daemon("agent-spawn").await?; + + // Resolve names client-side so a typo fails before any principal is minted. + let derive_from = match args.derive_from.as_deref() { + Some(p) => PrincipalId::new(p).context("invalid --derive-from principal")?, + None => crate::principal::current(), + }; + let session = SessionId::from_uuid(uuid::Uuid::new_v4()); + let derived_name = match &args.name { + Some(n) => n.clone(), + None => format!("{derive_from}-spawn-{}", short_suffix(&session)), + }; + let derived = PrincipalId::new(&derived_name).context("invalid derived agent name")?; + + let mut admin = crate::admin_client::connect_as_active_agent().await?; + + // 1. Atomically provision the explicit restricted runtime shape. The + // kernel validates that allowed/stateful capsules are a subset of the + // loaded set and copies nothing outside those named namespaces. + let create = admin + .request_agent_derive(AgentDeriveRequest { + name: derived_name.clone(), + source: derive_from.clone(), + load_capsules: args.load_capsules.clone(), + allow_capsules: args.allow_capsules.clone(), + inherit_capsule_state: args.inherit_capsule_state.clone(), + network_egress: args.network_egress.clone(), + }) + .await?; + into_result(create).with_context(|| format!("failed to create throwaway '{derived}'"))?; + eprintln!("[spawn] created restricted throwaway '{derived}' from '{derive_from}'"); + + // 2. Run the one job under the throwaway. Teardown owns the security + // guarantee, so it must run whether the job succeeds, fails, or times + // out — hence the outcome is captured, not `?`-propagated here. + let outcome = run_job_under(&derived, &session, &args.job, args.timeout).await; + + // 3. Teardown (delete reclaims the footprint) unless --keep. + let teardown_outcome = if args.keep { + eprintln!( + "[spawn] --keep set: leaving '{derived}' in place \ + (reclaim with `astrid agent delete {derived}`)" + ); + Ok(()) + } else { + teardown(&mut admin, &derived).await + }; + + // 4. Surface the job outcome. The response goes to stdout so a caller can + // capture it (e.g. land it as a review item); status lines go to stderr. + match (outcome, teardown_outcome) { + (Ok(response), Ok(())) => { + print!("{response}"); + if !response.ends_with('\n') { + println!(); + } + Ok(ExitCode::SUCCESS) + }, + (Err(job), Ok(())) => { + eprintln!("[spawn] job failed: {job:#}"); + Ok(ExitCode::from(1)) + }, + (Ok(_), Err(teardown)) => { + eprintln!("[spawn] teardown failed: {teardown:#}"); + Ok(ExitCode::from(1)) + }, + (Err(job), Err(teardown)) => { + eprintln!("[spawn] job failed: {job:#}"); + eprintln!("[spawn] teardown also failed: {teardown:#}"); + Ok(ExitCode::from(1)) + }, + } +} + +/// Connect an uplink authenticated AS the throwaway, submit the job, and drain +/// the response under a wall-clock ceiling. On timeout, send the cooperative +/// cancel sentinel; the hard guarantee is the caller's teardown regardless. +async fn run_job_under( + principal: &PrincipalId, + session: &SessionId, + job: &str, + timeout_secs: u64, +) -> Result { + let mut client = socket_client::connect_for_workspace(session.clone(), principal.clone(), None) + .await + .map_err(|e| anyhow!("failed to connect as '{principal}': {e}"))?; + + client + .send_input(job.to_string()) + .await + .context("failed to submit job")?; + + let drained = tokio::time::timeout( + Duration::from_secs(timeout_secs), + drain_until_final(&mut client, session), + ) + .await; + + let result = match drained { + Ok(inner) => inner, + Err(_elapsed) => { + // Cooperative cancel so the react capsule aborts the in-flight turn + // promptly; delete (which reclaims) is the hard stop regardless. + let _ = send_cancel(&mut client, session).await; + Err(anyhow!( + "job exceeded the {timeout_secs}s wall-clock ceiling" + )) + }, + }; + + // Best-effort disconnect; the connection also closes on drop. + let disconnect = astrid_types::ipc::IpcMessage::new( + astrid_types::Topic::client_disconnect(), + astrid_types::ipc::IpcPayload::Disconnect { + reason: Some("spawn".to_string()), + }, + session.0, + ); + let _ = client.send_message(disconnect).await; + + result +} + +/// Read response events until the terminal `AgentResponse { is_final: true }`, +/// accumulating text. Approval requests are auto-DENIED: a locked-down +/// throwaway drafts a result for review, it never acts in the world. +async fn drain_until_final(client: &mut SocketClient, session: &SessionId) -> Result { + let mut response = String::new(); + loop { + let message = match client.read_message().await { + Ok(Some(msg)) => msg, + Ok(None) => { + return Err(anyhow!( + "daemon closed the response stream before the final marker" + )); + }, + Err(e) => return Err(e.context("failed to read from daemon")), + }; + match &message.payload { + astrid_types::ipc::IpcPayload::AgentResponse { text, is_final, .. } => { + response.push_str(text); + if *is_final { + break; + } + }, + astrid_types::ipc::IpcPayload::ApprovalRequired { request_id, .. } => { + let deny = astrid_types::ipc::IpcPayload::ApprovalResponse { + request_id: request_id.clone(), + decision: "deny".to_string(), + reason: Some("spawn: locked-down throwaway never acts".to_string()), + }; + let topic = astrid_types::Topic::approval_response(request_id); + let msg = astrid_types::ipc::IpcMessage::new(topic, deny, session.0); + client.send_message(msg).await?; + }, + _ => {}, + } + } + Ok(response) +} + +/// Signal the react capsule to abort the current turn: an empty `UserInput` +/// carrying the `cancel_turn` sentinel (mirrors the TUI's cancel path). +async fn send_cancel(client: &mut SocketClient, session: &SessionId) -> Result<()> { + let cancel = astrid_types::ipc::IpcPayload::UserInput { + text: String::new(), + session_id: session.0.to_string(), + context: Some(serde_json::json!({ "action": "cancel_turn" })), + }; + let msg = + astrid_types::ipc::IpcMessage::new(astrid_types::Topic::user_prompt(), cancel, session.0); + client.send_message(msg).await?; + Ok(()) +} + +/// Delete the throwaway. `AgentDelete` always reclaims the footprint (#1217) +/// and closes authz first, so a reclamation hiccup can't re-open access or mask +/// the job's real outcome — it's surfaced as a warning. +async fn teardown(admin: &mut AdminClient, derived: &PrincipalId) -> Result<()> { + let body = admin + .request(AdminRequestKind::AgentDelete { + principal: derived.clone(), + }) + .await + .with_context(|| format!("could not delete '{derived}'"))?; + let outcome = into_result(body).with_context(|| format!("delete of '{derived}' failed"))?; + if let astrid_events::kernel_api::AdminResponseBody::Success(value) = outcome + && let Some(errors) = value + .get("cleanup_errors") + .and_then(|value| value.as_array()) + && !errors.is_empty() + { + let details = errors + .iter() + .filter_map(|error| error.as_str()) + .collect::>() + .join("; "); + return Err(anyhow!( + "delete of '{derived}' left unreclaimed state: {details}" + )); + } + eprintln!("[spawn] tore down '{derived}' (footprint reclaimed)"); + Ok(()) +} + +/// First 8 hex chars of the session uuid — short but unique per spawn. +fn short_suffix(session: &SessionId) -> String { + session.0.simple().to_string()[..8].to_string() +} diff --git a/crates/astrid-core/src/kernel_api/agent.rs b/crates/astrid-core/src/kernel_api/agent.rs new file mode 100644 index 000000000..b3e488b87 --- /dev/null +++ b/crates/astrid-core/src/kernel_api/agent.rs @@ -0,0 +1,89 @@ +//! Agent-specific management API payloads. + +use crate::PrincipalId; +use serde::{Deserialize, Serialize}; + +/// Explicit input for atomically provisioning one restricted derived agent. +/// +/// Unlike ordinary principal inheritance, omitted capsule installs and state +/// namespaces are never copied into the derived runtime. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentDeriveRequest { + /// New throwaway principal name. + pub name: String, + /// Existing principal whose selected capsule installs/state are used. + pub source: PrincipalId, + /// Capsule installs to materialize and load for the derived runtime. + #[serde(default)] + pub load_capsules: Vec, + /// Loaded capsules whose user-invocable tool surface may be dispatched. + #[serde(default)] + pub allow_capsules: Vec, + /// Capsule namespaces whose env, KV, and declared secrets are copied. + #[serde(default)] + pub inherit_capsule_state: Vec, + /// Outbound `host:port` patterns allowed for the restricted principal. + /// Empty means no outbound network access. + #[serde(default)] + pub network_egress: Vec, +} + +/// Request envelope for the additive `astrid.v1.admin.agent.derive` endpoint. +/// +/// Derivation intentionally has its own topic and envelope instead of adding a +/// variant to [`super::AdminRequestKind`], whose exhaustive public enum is a +/// compatibility contract for existing Rust clients. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentDeriveKernelRequest { + /// Optional client correlation identifier, echoed in the response. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, + /// Explicit restricted-principal shape. + #[serde(flatten)] + pub request: AgentDeriveRequest, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_request_keeps_the_existing_tagged_admin_wire_shape() { + let request = AgentDeriveKernelRequest { + request_id: Some("request-1".into()), + request: AgentDeriveRequest { + name: "worker".into(), + source: PrincipalId::default(), + load_capsules: vec!["harness".into()], + allow_capsules: Vec::new(), + inherit_capsule_state: Vec::new(), + network_egress: Vec::new(), + }, + }; + let json = serde_json::to_value(request).unwrap(); + assert_eq!(json["request_id"], "request-1"); + assert_eq!(json["name"], "worker"); + assert_eq!(json["source"], "default"); + assert_eq!(json["load_capsules"][0], "harness"); + } + + #[test] + fn legacy_derive_grants_are_ignored() { + let request: AgentDeriveKernelRequest = serde_json::from_value(serde_json::json!({ + "name": "worker", + "source": "default", + "grants": ["*"], + "load_capsules": ["harness"] + })) + .unwrap(); + + assert_eq!(request.request.load_capsules, ["harness"]); + assert!( + serde_json::to_value(request) + .unwrap() + .get("grants") + .is_none(), + "derived principals must not accept caller-selected capability grants" + ); + } +} diff --git a/crates/astrid-core/src/kernel_api/mod.rs b/crates/astrid-core/src/kernel_api/mod.rs index 1257264f6..b8d052684 100644 --- a/crates/astrid-core/src/kernel_api/mod.rs +++ b/crates/astrid-core/src/kernel_api/mod.rs @@ -8,8 +8,10 @@ //! has no dependency on `astrid-core` — it must compile on //! `wasm32-unknown-unknown` without dragging in the kernel). +mod agent; mod projection_names; mod readiness; +pub use agent::{AgentDeriveKernelRequest, AgentDeriveRequest}; pub use projection_names::{ PROJECTION_NAME_DIAGNOSTIC_METHOD, PROJECTION_NAME_DIAGNOSTIC_TOPIC, ProjectionNameCollisionDiagnostic, ProjectionNameDiagnostic, ProjectionNameEscapeDiagnostic, diff --git a/crates/astrid-core/src/profile/io_impl.rs b/crates/astrid-core/src/profile/io_impl.rs index ac0f6247b..d9f0736f4 100644 --- a/crates/astrid-core/src/profile/io_impl.rs +++ b/crates/astrid-core/src/profile/io_impl.rs @@ -44,6 +44,27 @@ impl PrincipalProfile { Self::load_from_path(&Self::path_for(home, principal)) } + /// Load a profile without the missing-file compatibility fallback. + /// + /// Non-default principals use this at authority boundaries so removal of + /// their profile is itself a durable revocation fence. The file is opened + /// exactly once; there is no `exists`/read race that can fall through to a + /// permissive default after concurrent deletion. + /// + /// # Errors + /// + /// Returns [`ProfileError::Io`] including `NotFound` for any read failure, + /// [`ProfileError::Parse`] for malformed TOML, or [`ProfileError::Invalid`] + /// when validation rejects the loaded profile. + pub fn load_required(home: &AstridHome, principal: &PrincipalId) -> ProfileResult { + let path = Self::path_for(home, principal); + let content = + crate::platform_fs::read_private_file_to_string(&path).map_err(ProfileError::Io)?; + let profile: Self = toml::from_str(&content)?; + profile.validate()?; + Ok(profile) + } + /// Load a profile from an explicit path. Exposed for tests and tools /// that don't own an [`AstridHome`]. /// diff --git a/crates/astrid-kernel/src/kernel_router/admin/agent_create_helpers.rs b/crates/astrid-kernel/src/kernel_router/admin/agent_create_helpers.rs index a88fc83a5..69314af22 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/agent_create_helpers.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/agent_create_helpers.rs @@ -12,11 +12,14 @@ //! //! Everything here must run under the admin write lock held by the caller. +use std::collections::HashSet; use std::path::Path; use std::sync::Arc; use astrid_core::principal::PrincipalId; -use astrid_core::profile::{CapabilityPattern, GroupName, PrincipalProfile}; +use astrid_core::profile::{ + CapabilityPattern, CapsuleGrant, GroupName, NetworkConfig, PrincipalProfile, +}; use astrid_events::kernel_api::AdminResponseBody; use tracing::info; @@ -25,6 +28,381 @@ use super::handlers::{ require_principal_exists, success_json, }; +/// Provision the explicit, restricted runtime shape used by `agent spawn`. +#[allow(clippy::too_many_arguments)] +pub(super) async fn provision_derived_principal( + kernel: &Arc, + principal: PrincipalId, + profile_path: std::path::PathBuf, + source: PrincipalId, + load_capsules: Vec, + allow_capsules: Vec, + inherit_capsule_state: Vec, + network_egress: Vec, +) -> AdminResponseBody { + if source == principal { + return err_bad_input("derived principal cannot use itself as its source".to_string()); + } + if let Err(response) = ensure_derived_target_clean(kernel, &principal, &profile_path).await { + return response; + } + let source_path = principal_profile_path(kernel, &source); + if let Err(e) = require_principal_exists(&source, &source_path) { + return err_bad_input(format!("derive source rejected: {e}")); + } + if let Err(response) = validate_derived_capsules( + kernel, + &source, + &load_capsules, + &allow_capsules, + &inherit_capsule_state, + ) { + return response; + } + if let Err(response) = validate_derived_network(&network_egress) { + return response; + } + + let response = provision_new_principal( + kernel, + principal.clone(), + profile_path.clone(), + vec![astrid_core::groups::BUILTIN_RESTRICTED.to_string()], + Vec::new(), + None, + None, + false, + false, + ) + .await; + if !matches!(response, AdminResponseBody::Success(_)) { + return response; + } + + let mut profile = match PrincipalProfile::load_from_path(&profile_path) { + Ok(profile) => profile, + Err(e) => { + return rollback_after_failure(kernel, &principal, err_profile(&principal, &e)).await; + }, + }; + profile.capsules = allow_capsules; + profile.network.egress = network_egress; + if let Err(e) = profile.validate() { + return rollback_after_failure( + kernel, + &principal, + err_bad_input(format!("derived profile rejected: {e}")), + ) + .await; + } + + if let Err(e) = materialize_cloned_capsule_installs(kernel, &source, &principal, &load_capsules) + { + return rollback_after_failure( + kernel, + &principal, + err_internal(format!("derived capsule materialization failed: {e}")), + ) + .await; + } + if let Err(e) = profile.save_to_path(&profile_path) { + return rollback_after_failure(kernel, &principal, err_profile(&principal, &e)).await; + } + kernel.profile_cache.invalidate(&principal); + if let Err(e) = super::inheritance::inherit_selected_capsule_state( + kernel, + &source, + &principal, + &inherit_capsule_state, + ) + .await + { + return rollback_after_failure( + kernel, + &principal, + err_internal(format!("derived state inheritance failed: {e}")), + ) + .await; + } + finish_derived_principal(kernel, principal, source, load_capsules, profile).await +} + +async fn finish_derived_principal( + kernel: &Arc, + principal: PrincipalId, + source: PrincipalId, + load_capsules: Vec, + profile: PrincipalProfile, +) -> AdminResponseBody { + if let Err(error) = kernel + .ensure_principal_capsules_ready(&principal, &load_capsules) + .await + { + return rollback_after_failure( + kernel, + &principal, + err_internal(format!("derived capsule readiness failed: {error}")), + ) + .await; + } + kernel.publish_capsules_loaded().await; + info!(%principal, %source, ?load_capsules, "Layer 6 agent.derive"); + success_json(serde_json::json!({ + "principal": principal.as_str(), + "source": source.as_str(), + "loaded_capsules": load_capsules, + "allowed_capsules": profile.capsules, + "network_egress": profile.network.egress, + })) +} + +async fn ensure_derived_target_clean( + kernel: &Arc, + principal: &PrincipalId, + profile_path: &Path, +) -> Result<(), AdminResponseBody> { + let home = kernel + .astrid_home + .principal_home(principal) + .root() + .to_path_buf(); + let key = kernel + .astrid_home + .keys_dir() + .join(format!("{principal}.key")); + let secrets = kernel.astrid_home.secrets_dir().join(principal.as_str()); + let identity = kernel + .identity_store + .resolve(AGENT_IDENTITY_PLATFORM, principal.as_str()) + .await + .map_err(|e| err_internal(format!("identity store resolve failed: {e}")))?; + if identity.is_some() + || profile_path.exists() + || home.exists() + || key.exists() + || secrets.exists() + { + return Err(err_bad_input(format!( + "derived principal '{principal}' has residual identity or filesystem state" + ))); + } + Ok(()) +} + +fn validate_derived_capsules( + kernel: &crate::Kernel, + source: &PrincipalId, + load: &[String], + allowed: &[String], + inherited: &[String], +) -> Result<(), AdminResponseBody> { + if load.is_empty() { + return Err(err_bad_input( + "at least one load_capsule is required".to_string(), + )); + } + let mut seen = HashSet::new(); + for capsule in load { + if !seen.insert(capsule) { + return Err(err_bad_input(format!("duplicate load capsule '{capsule}'"))); + } + CapsuleGrant::new(capsule) + .map_err(|e| err_bad_input(format!("load capsule rejected: {e}")))?; + validate_derived_capsule_install(kernel, source, capsule)?; + } + for (kind, capsules) in [("allow", allowed), ("state inheritance", inherited)] { + seen.clear(); + for capsule in capsules { + if !seen.insert(capsule) { + return Err(err_bad_input(format!( + "duplicate {kind} capsule '{capsule}'" + ))); + } + if !load.contains(capsule) { + return Err(err_bad_input(format!( + "capsule '{capsule}' must be loaded before it can be allowed or inherit state" + ))); + } + } + } + Ok(()) +} + +fn validate_derived_capsule_install( + kernel: &crate::Kernel, + source: &PrincipalId, + capsule: &str, +) -> Result<(), AdminResponseBody> { + let source_install = kernel + .astrid_home + .principal_home(source) + .capsules_dir() + .join(capsule); + if !source_install.is_dir() { + return Err(err_bad_input(format!( + "source capsule install '{capsule}' is missing at {}", + source_install.display() + ))); + } + let manifest = astrid_capsule::discovery::load_manifest(&source_install.join("Capsule.toml")) + .map_err(|e| { + err_bad_input(format!( + "source capsule '{capsule}' has an invalid manifest: {e}" + )) + })?; + if manifest.package.name != capsule { + return Err(err_bad_input(format!( + "source capsule directory '{capsule}' contains manifest for '{}'", + manifest.package.name + ))); + } + if !manifest.mcp_servers.is_empty() { + return Err(err_bad_input(format!( + "source capsule '{capsule}' declares a host MCP server; derived principals require WASM-only capsules" + ))); + } + Ok(()) +} + +fn validate_derived_network(egress: &[String]) -> Result<(), AdminResponseBody> { + NetworkConfig { + egress: egress.to_vec(), + ..NetworkConfig::default() + } + .validate() + .map_err(|e| err_bad_input(format!("derived network policy rejected: {e}")))?; + for endpoint in egress { + validate_derived_egress_endpoint(endpoint).map_err(err_bad_input)?; + } + Ok(()) +} + +fn validate_derived_egress_endpoint(endpoint: &str) -> Result<(), String> { + let Some((host, port)) = endpoint.rsplit_once(':') else { + return Err(format!( + "derived network endpoint '{endpoint}' must use host:port" + )); + }; + if host.is_empty() || port.is_empty() { + return Err(format!( + "derived network endpoint '{endpoint}' must use a non-empty host and port" + )); + } + if port != "*" && port.parse::().is_err() { + return Err(format!( + "derived network endpoint '{endpoint}' has an invalid port" + )); + } + Ok(()) +} + +async fn rollback_after_failure( + kernel: &Arc, + principal: &PrincipalId, + original: AdminResponseBody, +) -> AdminResponseBody { + match rollback_derived_principal(kernel, principal).await { + Ok(()) => original, + Err(error) => err_internal(format!( + "derived principal provisioning failed and rollback could not complete: {error}" + )), + } +} + +async fn rollback_derived_principal( + kernel: &Arc, + principal: &PrincipalId, +) -> Result<(), String> { + let pending = super::agent_delete::prepare_identity_removal(kernel, principal) + .await + .map_err(|response| format!("identity removal preparation returned {response:?}"))?; + kernel + .capabilities + .begin_principal_retirement(principal.clone()) + .await; + kernel + .allowance_store + .begin_principal_retirement(principal) + .map_err(|error| format!("allowance retirement fence failed: {error}"))?; + kernel + .identity_store + .unlink(AGENT_IDENTITY_PLATFORM, principal.as_str()) + .await + .map_err(|error| format!("identity unlink failed: {error}"))?; + let mut cleanup_errors = Vec::new(); + if let Err(error) = kernel.unload_principal_capsules(principal).await { + cleanup_errors.push(format!("capsule retirement failed: {error}")); + } + let capsule_dir = kernel.astrid_home.principal_home(principal).capsules_dir(); + if let Ok(entries) = std::fs::read_dir(capsule_dir) { + for capsule in entries.flatten().filter_map(|entry| { + entry + .file_type() + .ok() + .filter(std::fs::FileType::is_dir) + .and_then(|_| entry.file_name().into_string().ok()) + }) { + if let Err(error) = kernel + .kv + .clear_namespace(&format!("{principal}:capsule:{capsule}")) + .await + { + cleanup_errors.push(format!("KV namespace for capsule '{capsule}': {error}")); + } + } + } + collect_remove_file( + &principal_profile_path(kernel, principal), + "profile", + &mut cleanup_errors, + ); + collect_remove_dir( + kernel.astrid_home.principal_home(principal).root(), + "principal home", + &mut cleanup_errors, + ); + collect_remove_file( + &kernel + .astrid_home + .keys_dir() + .join(format!("{principal}.key")), + "principal key", + &mut cleanup_errors, + ); + collect_remove_dir( + &kernel.astrid_home.secrets_dir().join(principal.as_str()), + "principal secrets", + &mut cleanup_errors, + ); + kernel.profile_cache.invalidate(principal); + if !cleanup_errors.is_empty() { + // Dropping `pending` intentionally retains its durable ownership + // reservation. The capability and allowance retirement fences also + // remain closed. A retry must finish reclamation before this alias can + // acquire fresh authority. + return Err(cleanup_errors.join("; ")); + } + super::agent_delete::finish_identity_removal(kernel, principal, pending) + .await + .map_err(|response| format!("identity removal completion returned {response:?}")) +} + +fn collect_remove_file(path: &Path, label: &str, errors: &mut Vec) { + if let Err(error) = std::fs::remove_file(path) + && error.kind() != std::io::ErrorKind::NotFound + { + errors.push(format!("{label} {}: {error}", path.display())); + } +} + +fn collect_remove_dir(path: &Path, label: &str, errors: &mut Vec) { + if let Err(error) = std::fs::remove_dir_all(path) + && error.kind() != std::io::ErrorKind::NotFound + { + errors.push(format!("{label} {}: {error}", path.display())); + } +} + /// Build, register, and provision a genuinely-new principal. /// /// The collision + backfill decision is made by the caller (`agent_create`); @@ -41,6 +419,7 @@ pub(super) async fn provision_new_principal( inherit_from: Option, clone_from: Option, allow_admin_clone: bool, + warm_after_create: bool, ) -> AdminResponseBody { if let Err(error) = kernel .ownership_store @@ -79,6 +458,7 @@ pub(super) async fn provision_new_principal( }; if let Err(e) = profile.validate() { + remove_principal_key(kernel, &principal); return err_bad_input(format!("profile rejected: {e}")); } @@ -88,7 +468,10 @@ pub(super) async fn provision_new_principal( .await { Ok(u) => u, - Err(e) => return err_internal(format!("identity store create_user failed: {e}")), + Err(e) => { + remove_principal_key(kernel, &principal); + return err_internal(format!("identity store create_user failed: {e}")); + }, }; if let Err(e) = kernel .identity_store @@ -100,17 +483,12 @@ pub(super) async fn provision_new_principal( ) .await { - // Best-effort rollback so partial state doesn't persist. - let _ = kernel.identity_store.delete_user(user.id).await; + rollback_created_identity(kernel, &principal, user.id, &profile_path, false).await; return err_internal(format!("identity store link failed: {e}")); } if let Err(e) = profile.save_to_path(&profile_path) { - let _ = kernel - .identity_store - .unlink(AGENT_IDENTITY_PLATFORM, principal.as_str()) - .await; - let _ = kernel.identity_store.delete_user(user.id).await; + rollback_created_identity(kernel, &principal, user.id, &profile_path, false).await; return err_internal(format!("profile save failed: {e}")); } @@ -123,12 +501,7 @@ pub(super) async fn provision_new_principal( // Roll back identity + profile so the agent isn't left in a state // where future invocations would leak into someone else's data. if let Err(e) = kernel.astrid_home.principal_home(&principal).ensure() { - let _ = kernel - .identity_store - .unlink(AGENT_IDENTITY_PLATFORM, principal.as_str()) - .await; - let _ = kernel.identity_store.delete_user(user.id).await; - let _ = std::fs::remove_file(&profile_path); + rollback_created_identity(kernel, &principal, user.id, &profile_path, true).await; return err_internal(format!( "principal home tree provisioning failed (rolled back): {e}" )); @@ -138,13 +511,7 @@ pub(super) async fn provision_new_principal( && let Err(e) = materialize_cloned_capsule_installs(kernel, source, &principal, &profile.capsules) { - let _ = kernel - .identity_store - .unlink(AGENT_IDENTITY_PLATFORM, principal.as_str()) - .await; - let _ = kernel.identity_store.delete_user(user.id).await; - let _ = std::fs::remove_file(&profile_path); - let _ = std::fs::remove_dir_all(kernel.astrid_home.principal_home(&principal).root()); + rollback_created_identity(kernel, &principal, user.id, &profile_path, true).await; return err_internal(format!("capsule install clone failed (rolled back): {e}")); } @@ -164,7 +531,9 @@ pub(super) async fn provision_new_principal( super::inheritance::inherit_from_principal(kernel, source, &principal).await; } - warm_created_principal(kernel, principal.clone()); + if warm_after_create { + warm_created_principal(kernel, principal.clone()); + } info!(%principal, user_id = %user.id, "Layer 6 agent.create"); success_json(serde_json::json!({ @@ -173,6 +542,34 @@ pub(super) async fn provision_new_principal( })) } +async fn rollback_created_identity( + kernel: &crate::Kernel, + principal: &PrincipalId, + user_id: uuid::Uuid, + profile_path: &Path, + remove_home: bool, +) { + let _ = kernel + .identity_store + .unlink(AGENT_IDENTITY_PLATFORM, principal.as_str()) + .await; + let _ = kernel.identity_store.delete_user(user_id).await; + let _ = std::fs::remove_file(profile_path); + if remove_home { + let _ = std::fs::remove_dir_all(kernel.astrid_home.principal_home(principal).root()); + } + remove_principal_key(kernel, principal); +} + +fn remove_principal_key(kernel: &crate::Kernel, principal: &PrincipalId) { + let _ = std::fs::remove_file( + kernel + .astrid_home + .keys_dir() + .join(format!("{principal}.key")), + ); +} + fn warm_created_principal(kernel: &Arc, principal: PrincipalId) { let kernel = Arc::clone(kernel); astrid_runtime::spawn(async move { @@ -500,3 +897,25 @@ pub(super) async fn backfill_keypair( "message": format!("backfilled missing keypair for existing principal {principal}"), })) } + +#[cfg(test)] +mod rollback_cleanup_tests { + use super::*; + + #[test] + fn cleanup_collectors_preserve_every_reclamation_error() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("directory"); + let file = temp.path().join("file"); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(&file, b"state").unwrap(); + let mut errors = Vec::new(); + + collect_remove_file(&directory, "profile", &mut errors); + collect_remove_dir(&file, "home", &mut errors); + + assert_eq!(errors.len(), 2, "both independent failures must survive"); + assert!(errors[0].contains("profile")); + assert!(errors[1].contains("home")); + } +} diff --git a/crates/astrid-kernel/src/kernel_router/admin/agent_delete.rs b/crates/astrid-kernel/src/kernel_router/admin/agent_delete.rs index 429199d97..062c4f854 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/agent_delete.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/agent_delete.rs @@ -47,17 +47,6 @@ pub(super) async fn agent_delete( return err_internal(format!("identity store unlink failed: {e}")); } - let path = principal_profile_path(kernel, &principal); - if let Err(e) = std::fs::remove_file(&path) - && e.kind() != std::io::ErrorKind::NotFound - { - return err_internal(format!( - "failed to remove profile.toml at {}: {e}", - path.display() - )); - } - kernel.profile_cache.invalidate(&principal); - let (unloaded_capsules, reclaimed, cleanup_errors) = match retire_and_reclaim(kernel, &principal, pending.principal_uid).await { Ok(result) => result, @@ -71,6 +60,20 @@ pub(super) async fn agent_delete( )); } + // Native state reclamation resolves quota policy through the profile. Keep + // it until that purge has committed, while the capability retirement fence + // and identity unlink keep every authority and capsule-load edge closed. + let path = principal_profile_path(kernel, &principal); + if let Err(e) = std::fs::remove_file(&path) + && e.kind() != std::io::ErrorKind::NotFound + { + return err_internal(format!( + "failed to remove profile.toml at {}: {e}", + path.display() + )); + } + kernel.profile_cache.invalidate(&principal); + if let Err(response) = finish_identity_removal(kernel, &principal, pending).await { return response; } @@ -84,13 +87,13 @@ pub(super) async fn agent_delete( })) } -struct PendingIdentityRemoval { +pub(super) struct PendingIdentityRemoval { user: Option, principal_uid: Option, ownership_guard: Option, } -async fn prepare_identity_removal( +pub(super) async fn prepare_identity_removal( kernel: &Arc, principal: &PrincipalId, ) -> Result { @@ -174,7 +177,7 @@ async fn recover_or_reserve_legacy_alias( .map_err(|e| err_internal(format!("ownership store legacy deletion guard failed: {e}"))) } -async fn finish_identity_removal( +pub(super) async fn finish_identity_removal( kernel: &Arc, principal: &PrincipalId, pending: PendingIdentityRemoval, diff --git a/crates/astrid-kernel/src/kernel_router/admin/agent_derive.rs b/crates/astrid-kernel/src/kernel_router/admin/agent_derive.rs new file mode 100644 index 000000000..dd3250396 --- /dev/null +++ b/crates/astrid-kernel/src/kernel_router/admin/agent_derive.rs @@ -0,0 +1,202 @@ +//! Restricted derived-principal request dispatch. + +use std::sync::Arc; + +use astrid_audit::{AuditOutcome, AuthorizationProof}; +use astrid_core::principal::PrincipalId; +use astrid_events::ipc::{IpcMessage, Topic}; +use astrid_events::kernel_api::{ + AdminKernelResponse, AdminResponseBody, AgentDeriveKernelRequest, AgentDeriveRequest, +}; +use serde_json::Value; +use tracing::warn; + +use super::handlers::{err_bad_input, principal_profile_path}; +use super::{AdminAuditEntry, CallerResolutionError, MANAGEMENT_CALLER_REQUIRED}; + +const METHOD: &str = "admin.agent.derive"; +const REQUIRED_CAPABILITY: &str = "agent:create:inherit"; + +pub(super) fn try_dispatch( + kernel: &Arc, + message: &IpcMessage, + value: &Value, +) -> bool { + if message.topic.as_str() != "astrid.v1.admin.agent.derive" { + return false; + } + let Ok(request) = serde_json::from_value::(value.clone()) else { + warn!(topic = %message.topic, "Failed to parse AgentDeriveKernelRequest from IPC"); + return true; + }; + let response_topic = super::admin_response_topic(&message.topic); + let device_key_id = super::resolve_device_key_id(message); + match super::resolve_caller(message) { + Ok(caller) => { + let kernel = Arc::clone(kernel); + astrid_runtime::spawn(async move { + handle_request(&kernel, response_topic, caller, device_key_id, request).await; + }); + }, + Err(error) => { + let kernel = Arc::clone(kernel); + astrid_runtime::spawn(async move { + reject_without_caller(&kernel, response_topic, device_key_id, request, error).await; + }); + }, + } + true +} + +async fn reject_without_caller( + kernel: &Arc, + response_topic: Topic, + device_key_id: Option, + request: AgentDeriveKernelRequest, + error: CallerResolutionError, +) { + let caller = PrincipalId::anonymous(); + let reason = format!("{MANAGEMENT_CALLER_REQUIRED}: {}", error.reason()); + super::record_admin_audit( + kernel, + AdminAuditEntry { + caller: &caller, + method: METHOD, + required_cap: REQUIRED_CAPABILITY, + device_key_id: device_key_id.as_deref(), + target_principal: None, + params: serde_json::to_value(&request.request).ok(), + authorization: AuthorizationProof::Denied { + reason: reason.clone(), + }, + outcome: AuditOutcome::failure(&reason), + }, + ) + .await; + publish( + kernel, + response_topic, + &caller, + device_key_id.as_deref(), + request.request_id, + AdminResponseBody::Error(MANAGEMENT_CALLER_REQUIRED.to_string()), + ); +} + +async fn handle_request( + kernel: &Arc, + response_topic: Topic, + caller: PrincipalId, + device_key_id: Option, + request: AgentDeriveKernelRequest, +) { + let params = serde_json::to_value(&request.request).ok(); + let body = match super::authorize_request( + kernel, + &caller, + device_key_id.as_deref(), + REQUIRED_CAPABILITY, + ) { + Ok(_) => { + super::record_admin_audit( + kernel, + AdminAuditEntry { + caller: &caller, + method: METHOD, + required_cap: REQUIRED_CAPABILITY, + device_key_id: device_key_id.as_deref(), + target_principal: None, + params, + authorization: AuthorizationProof::System { + reason: format!("policy allow: {caller} holds {REQUIRED_CAPABILITY}"), + }, + outcome: AuditOutcome::success(), + }, + ) + .await; + agent_derive_from_req(kernel, request.request).await + }, + Err(error) => { + let error = error.to_string(); + super::record_admin_audit( + kernel, + AdminAuditEntry { + caller: &caller, + method: METHOD, + required_cap: REQUIRED_CAPABILITY, + device_key_id: device_key_id.as_deref(), + target_principal: None, + params, + authorization: AuthorizationProof::Denied { + reason: error.clone(), + }, + outcome: AuditOutcome::failure(&error), + }, + ) + .await; + AdminResponseBody::Error(error) + }, + }; + publish( + kernel, + response_topic, + &caller, + device_key_id.as_deref(), + request.request_id, + body, + ); +} + +fn publish( + kernel: &Arc, + response_topic: Topic, + caller: &PrincipalId, + device_key_id: Option<&str>, + request_id: Option, + body: AdminResponseBody, +) { + super::publish_response( + kernel, + response_topic, + caller.as_str(), + device_key_id, + AdminKernelResponse::for_request(request_id, body), + ); +} + +pub(super) async fn agent_derive_from_req( + kernel: &Arc, + req: AgentDeriveRequest, +) -> AdminResponseBody { + let AgentDeriveRequest { + name, + source, + load_capsules, + allow_capsules, + inherit_capsule_state, + network_egress, + } = req; + let principal = match PrincipalId::new(&name) { + Ok(principal) => principal, + Err(e) => return err_bad_input(format!("principal rejected: {e}")), + }; + if let Some(reason) = principal.reserved_reason() { + return err_bad_input(format!("principal {name:?} is {reason}")); + } + let _guard = kernel.admin_write_lock.lock().await; + let profile_path = principal_profile_path(kernel, &principal); + if profile_path.exists() { + return err_bad_input(format!("principal `{principal}` already exists")); + } + super::agent_create_helpers::provision_derived_principal( + kernel, + principal, + profile_path, + source, + load_capsules, + allow_capsules, + inherit_capsule_state, + network_egress, + ) + .await +} diff --git a/crates/astrid-kernel/src/kernel_router/admin/enforcement_tests.rs b/crates/astrid-kernel/src/kernel_router/admin/enforcement_tests.rs index 4024b80da..46e95c0e9 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/enforcement_tests.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/enforcement_tests.rs @@ -19,7 +19,9 @@ use astrid_core::dirs::AstridHome; use astrid_core::principal::PrincipalId; use astrid_core::profile::PrincipalProfile; use astrid_events::ipc::{IpcMessage, IpcPayload, Topic}; -use astrid_events::kernel_api::{AdminKernelRequest, AdminRequestKind}; +use astrid_events::kernel_api::{ + AdminKernelRequest, AdminRequestKind, AgentDeriveKernelRequest, AgentDeriveRequest, +}; use tempfile::TempDir; use crate::Kernel; @@ -91,6 +93,46 @@ async fn send_admin_with_raw_principal( .expect("admin response within 2s") } +async fn send_derive( + kernel: &Arc, + principal: Option<&str>, + request_id: &str, +) -> serde_json::Value { + let topic = Topic::admin_request("agent.derive"); + let response_topic = Topic::admin_response("agent.derive"); + let mut rx = kernel.event_bus.subscribe_topic(response_topic.as_str()); + let payload = serde_json::to_value(AgentDeriveKernelRequest { + request_id: Some(request_id.to_string()), + request: AgentDeriveRequest { + name: "derived-test".into(), + source: PrincipalId::default(), + load_capsules: vec!["missing-capsule".into()], + allow_capsules: Vec::new(), + inherit_capsule_state: Vec::new(), + network_egress: Vec::new(), + }, + }) + .unwrap(); + let mut message = IpcMessage::new(topic, IpcPayload::RawJson(payload), kernel.session_id.0); + message.principal = principal.map(str::to_string); + let _ = kernel.event_bus.publish(astrid_events::AstridEvent::Ipc { + metadata: astrid_events::EventMetadata::new("test"), + message, + }); + astrid_runtime::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let event = rx.recv().await.unwrap(); + if let astrid_events::AstridEvent::Ipc { message, .. } = &*event + && let IpcPayload::RawJson(value) = &message.payload + { + return value.clone(); + } + } + }) + .await + .expect("derive response within 2s") +} + // ── enabled-flag enforcement (Layer 5 preamble + Layer 6 admin) ── #[tokio::test(flavor = "multi_thread")] @@ -292,6 +334,35 @@ async fn admin_request_id_echoed_on_deny_path_too() { assert_eq!(resp["status"], "Error"); } +#[tokio::test(flavor = "multi_thread")] +async fn additive_derive_endpoint_preserves_correlation_and_authorization() { + let (_dir, kernel) = fixture().await; + seed_profile( + &kernel, + &PrincipalId::default(), + &PrincipalProfile { + groups: vec!["admin".to_string()], + ..Default::default() + }, + ); + + let authorized = send_derive(&kernel, Some("default"), "derive-authorized").await; + assert_eq!(authorized["request_id"], "derive-authorized"); + assert_eq!(authorized["status"], "Error"); + assert!( + authorized["data"] + .as_str() + .unwrap() + .contains("missing-capsule"), + "authorized request must reach derive input validation" + ); + + let denied = send_derive(&kernel, None, "derive-denied").await; + assert_eq!(denied["request_id"], "derive-denied"); + assert_eq!(denied["status"], "Error"); + assert_eq!(denied["data"], super::super::MANAGEMENT_CALLER_REQUIRED); +} + #[tokio::test(flavor = "multi_thread")] async fn admin_router_denies_missing_and_invalid_principals_deterministically() { let (_dir, kernel) = fixture().await; diff --git a/crates/astrid-kernel/src/kernel_router/admin/handlers.rs b/crates/astrid-kernel/src/kernel_router/admin/handlers.rs index 85a94f2a7..fba267d2c 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/handlers.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/handlers.rs @@ -351,6 +351,7 @@ async fn agent_create( inherit_from, clone_from, allow_admin_clone, + true, ) .await } diff --git a/crates/astrid-kernel/src/kernel_router/admin/inheritance.rs b/crates/astrid-kernel/src/kernel_router/admin/inheritance.rs index fc5c1c45d..c00c3e9f2 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/inheritance.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/inheritance.rs @@ -17,6 +17,7 @@ //! necessary, not this), so a partial copy leaves a "needs manual setup" //! agent, never a confidentiality break. +use std::collections::HashSet; use std::sync::Arc; use astrid_core::principal::PrincipalId; @@ -33,17 +34,87 @@ pub(super) async fn inherit_from_principal( source: &PrincipalId, principal: &PrincipalId, ) { - copy_env_dir(kernel, source, principal); + let _ = copy_env_dir(kernel, source, principal, None); + let (capsule_ids, secret_keys_by_capsule) = snapshot_loaded_capsule_state(kernel).await; + let _ = copy_capsule_state( + kernel, + source, + principal, + &capsule_ids, + &secret_keys_by_capsule, + ) + .await; +} + +/// Copy only the named capsule state namespaces for a derived principal. +pub(super) async fn inherit_selected_capsule_state( + kernel: &Arc, + source: &PrincipalId, + principal: &PrincipalId, + capsules: &[String], +) -> Result<(), String> { + let selected: HashSet<&str> = capsules.iter().map(String::as_str).collect(); + let mut errors = copy_env_dir(kernel, source, principal, Some(&selected)); + + let mut capsule_ids = Vec::with_capacity(capsules.len()); + let mut secret_keys_by_capsule = Vec::new(); + let source_capsules = kernel.astrid_home.principal_home(source).capsules_dir(); + for name in capsules { + let capsule_id = match astrid_capsule::capsule::CapsuleId::new(name.clone()) { + Ok(capsule_id) => capsule_id, + Err(error) => { + errors.push(format!("invalid selected capsule '{name}': {error}")); + continue; + }, + }; + let manifest_path = source_capsules.join(name).join("Capsule.toml"); + match astrid_capsule::discovery::load_manifest(&manifest_path) { + Ok(manifest) => { + let keys = manifest + .env + .iter() + .filter(|(_, def)| def.env_type == "secret") + .map(|(key, _)| key.clone()) + .collect::>(); + if !keys.is_empty() { + secret_keys_by_capsule.push((capsule_id.clone(), keys)); + } + capsule_ids.push(capsule_id); + }, + Err(error) => errors.push(format!( + "selected capsule '{name}' manifest could not be read: {error}" + )), + } + } + errors.extend( + copy_capsule_state( + kernel, + source, + principal, + &capsule_ids, + &secret_keys_by_capsule, + ) + .await, + ); + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} +async fn snapshot_loaded_capsule_state( + kernel: &Arc, +) -> ( + Vec, + Vec<(astrid_capsule::capsule::CapsuleId, Vec)>, +) { // Snapshot manifest data under the registry lock, then drop it // before any async / blocking I/O. Holding the read lock across // `copy_kv_namespaces` (async KV) and `copy_secret_files` // (blocking fs) would serialise every concurrent install / update // / remove against the inherit path for as long as the copy ran. - let (capsule_ids, secret_keys_by_capsule): ( - Vec, - Vec<(astrid_capsule::capsule::CapsuleId, Vec)>, - ) = { + { let registry = kernel.capsules.read().await; let ids: Vec<_> = registry.list().into_iter().cloned().collect(); let mut secrets: Vec<(astrid_capsule::capsule::CapsuleId, Vec)> = Vec::new(); @@ -62,11 +133,20 @@ pub(super) async fn inherit_from_principal( } } (ids, secrets) - }; + } +} - let total_keys = copy_kv_namespaces(kernel, source, principal, &capsule_ids).await; - let (probed_secrets, copied_secrets) = - copy_secret_files(kernel, source, principal, &secret_keys_by_capsule); +async fn copy_capsule_state( + kernel: &Arc, + source: &PrincipalId, + principal: &PrincipalId, + capsule_ids: &[astrid_capsule::capsule::CapsuleId], + secret_keys_by_capsule: &[(astrid_capsule::capsule::CapsuleId, Vec)], +) -> Vec { + let (total_keys, mut errors) = copy_kv_namespaces(kernel, source, principal, capsule_ids).await; + let (probed_secrets, copied_secrets, secret_errors) = + copy_secret_files(kernel, source, principal, secret_keys_by_capsule); + errors.extend(secret_errors); info!( %principal, @@ -76,23 +156,41 @@ pub(super) async fn inherit_from_principal( probed_secrets, "agent.create: inherited source's env JSON + KV namespaces + secrets" ); + errors } -fn copy_env_dir(kernel: &Arc, source: &PrincipalId, principal: &PrincipalId) { +fn copy_env_dir( + kernel: &Arc, + source: &PrincipalId, + principal: &PrincipalId, + selected: Option<&HashSet<&str>>, +) -> Vec { + let mut errors = Vec::new(); let source_env = kernel.astrid_home.principal_home(source).env_dir(); let agent_env = kernel.astrid_home.principal_home(principal).env_dir(); if !source_env.is_dir() { - return; + return errors; } if let Err(e) = std::fs::create_dir_all(&agent_env) { tracing::warn!(%principal, error = %e, "agent.create: env_dir mkdir failed"); - return; + errors.push(format!("env destination create failed: {e}")); + return errors; } - let Ok(entries) = std::fs::read_dir(&source_env) else { - return; + let entries = match std::fs::read_dir(&source_env) { + Ok(entries) => entries, + Err(error) => { + errors.push(format!("env source read failed: {error}")); + return errors; + }, }; for entry in entries.flatten() { let name = entry.file_name(); + let selected_name = name + .to_str() + .and_then(|name| name.strip_suffix(".env.json")); + if selected.is_some_and(|set| selected_name.is_none_or(|name| !set.contains(name))) { + continue; + } let src = entry.path(); let dst = agent_env.join(&name); if let Err(e) = std::fs::copy(&src, &dst) { @@ -102,8 +200,13 @@ fn copy_env_dir(kernel: &Arc, source: &PrincipalId, principal: &P error = %e, "agent.create: env JSON copy failed" ); + errors.push(format!( + "env file {} copy failed: {e}", + name.to_string_lossy() + )); } } + errors } async fn copy_kv_namespaces( @@ -111,8 +214,9 @@ async fn copy_kv_namespaces( source: &PrincipalId, principal: &PrincipalId, capsule_ids: &[astrid_capsule::capsule::CapsuleId], -) -> usize { +) -> (usize, Vec) { let mut total_keys = 0usize; + let mut errors = Vec::new(); for capsule_id in capsule_ids { let src_ns = format!("{source}:capsule:{capsule_id}"); let dst_ns = format!("{principal}:capsule:{capsule_id}"); @@ -125,6 +229,7 @@ async fn copy_kv_namespaces( error = %e, "agent.create: KV list_keys failed for capsule namespace" ); + errors.push(format!("KV namespace {src_ns} list failed: {e}")); continue; }, }; @@ -149,6 +254,7 @@ async fn copy_kv_namespaces( error = %e, "agent.create: KV copy write failed" ); + errors.push(format!("KV namespace {dst_ns} key {key} write failed: {e}")); } }, Ok(None) => { /* benign race: key disappeared between list and get */ }, @@ -160,11 +266,12 @@ async fn copy_kv_namespaces( error = %e, "agent.create: KV copy read failed" ); + errors.push(format!("KV namespace {src_ns} key {key} read failed: {e}")); }, } } } - total_keys + (total_keys, errors) } fn copy_secret_files( @@ -172,10 +279,11 @@ fn copy_secret_files( source: &PrincipalId, principal: &PrincipalId, secret_keys_by_capsule: &[(astrid_capsule::capsule::CapsuleId, Vec)], -) -> (usize, usize) { +) -> (usize, usize, Vec) { use astrid_storage::{FileSecretStore, SecretStore}; let mut probed = 0usize; let mut copied = 0usize; + let mut errors = Vec::new(); let secrets_root = kernel.astrid_home.secrets_dir(); for (capsule_id, secret_keys) in secret_keys_by_capsule { let src = @@ -199,6 +307,7 @@ fn copy_secret_files( security_event = true, "agent.create: secret read failed for source's slot" ); + errors.push(format!("secret {capsule_id}/{key} read failed: {e}")); continue; }, }; @@ -211,10 +320,11 @@ fn copy_secret_files( security_event = true, "agent.create: secret write failed for new principal" ); + errors.push(format!("secret {capsule_id}/{key} write failed: {e}")); } else { copied = copied.saturating_add(1); } } } - (probed, copied) + (probed, copied, errors) } diff --git a/crates/astrid-kernel/src/kernel_router/admin/mod.rs b/crates/astrid-kernel/src/kernel_router/admin/mod.rs index a83b2a818..835a37a73 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/mod.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/mod.rs @@ -23,6 +23,7 @@ mod agent_create_helpers; mod agent_delete; +mod agent_derive; mod caps_tokens; #[cfg(test)] mod enforcement_tests; @@ -43,6 +44,8 @@ mod state_tests_agent_clone; #[cfg(test)] mod state_tests_agent_delete; #[cfg(test)] +mod state_tests_agent_derive; +#[cfg(test)] mod state_tests_agent_modify; #[cfg(test)] mod state_tests_caps; @@ -100,6 +103,10 @@ pub(crate) fn spawn_admin_router(kernel: Arc) -> astrid_runtime:: continue; }; + if agent_derive::try_dispatch(&kernel, message, val) { + continue; + } + match serde_json::from_value::(val.clone()) { Ok(req) => { // Spawn a fresh task per request so reads diff --git a/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs b/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs index 489118327..873701053 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/state_tests.rs @@ -550,13 +550,9 @@ async fn agent_delete_removes_identity_profile_and_invalidates_cache() { // close the login route, not the policy. assert!(!path.exists(), "profile.toml must be removed post-delete"); - // Cache cleared: re-resolving returns Default (enabled=true, no - // groups/grants/revokes), and the Layer 5 enforcement preamble - // grants no caps for that shape. - let after = kernel.profile_cache.resolve(&pid("bob")).unwrap(); - assert!(after.groups.is_empty()); - assert!(after.grants.is_empty()); - assert!(after.revokes.is_empty()); + // Cache cleared: a deleted non-default identity has no compatibility + // profile and therefore cannot regain host authority. + assert!(kernel.profile_cache.resolve(&pid("bob")).is_err()); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/astrid-kernel/src/kernel_router/admin/state_tests_agent_delete.rs b/crates/astrid-kernel/src/kernel_router/admin/state_tests_agent_delete.rs index 9b6c93159..d6ff5a339 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/state_tests_agent_delete.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/state_tests_agent_delete.rs @@ -71,6 +71,10 @@ async fn create(kernel: &Arc, principal: &PrincipalId) { #[tokio::test(flavor = "multi_thread")] async fn agent_delete_reclaims_home_key_and_secrets_and_reports_them() { let (_dir, kernel) = fixture().await; + assert!( + kernel.principal_store.is_some(), + "deletion regressions must exercise the native production store" + ); let principal = PrincipalId::new("ghost").unwrap(); create(&kernel, &principal).await; let (home, key, secrets) = seed_footprint(&kernel, &principal); @@ -138,8 +142,7 @@ async fn agent_delete_closes_authz_before_reclaiming() { .await; assert!(matches!(response, AdminResponseBody::Success(_))); - let after = kernel.profile_cache.resolve(&principal).unwrap(); - assert!(after.groups.is_empty() && after.grants.is_empty()); + assert!(kernel.profile_cache.resolve(&principal).is_err()); assert!(!home.exists() && !key.exists() && !secrets.exists()); } diff --git a/crates/astrid-kernel/src/kernel_router/admin/state_tests_agent_derive.rs b/crates/astrid-kernel/src/kernel_router/admin/state_tests_agent_derive.rs new file mode 100644 index 000000000..4bc56d479 --- /dev/null +++ b/crates/astrid-kernel/src/kernel_router/admin/state_tests_agent_derive.rs @@ -0,0 +1,310 @@ +//! Atomic derived-principal provisioning tests (#1217). + +use std::sync::Arc; + +use astrid_core::dirs::AstridHome; +use astrid_core::groups::{BUILTIN_ADMIN, BUILTIN_RESTRICTED}; +use astrid_core::principal::PrincipalId; +use astrid_core::profile::PrincipalProfile; +use astrid_events::kernel_api::{AdminResponseBody, AgentDeriveRequest}; +use astrid_storage::{FileSecretStore, SecretStore}; + +use crate::Kernel; + +async fn fixture() -> (tempfile::TempDir, Arc) { + let dir = tempfile::tempdir().unwrap(); + let kernel = crate::test_kernel_with_home(AstridHome::from_path(dir.path())).await; + let admin = PrincipalProfile { + groups: vec![BUILTIN_ADMIN.to_string()], + ..Default::default() + }; + admin + .save_to_path(&PrincipalProfile::path_for( + &kernel.astrid_home, + &PrincipalId::default(), + )) + .unwrap(); + kernel.profile_cache.invalidate(&PrincipalId::default()); + kernel + .identity_store + .create_principal(PrincipalId::default(), [7; 32]) + .await + .unwrap(); + (dir, kernel) +} + +fn seed_capsule(kernel: &Kernel, source: &PrincipalId, capsule: &str) { + let dir = kernel + .astrid_home + .principal_home(source) + .capsules_dir() + .join(capsule); + std::fs::create_dir_all(&dir).unwrap(); + let env = if capsule == "provider" { + "\n[env.API_KEY]\ntype = \"secret\"\n" + } else { + "" + }; + std::fs::write( + dir.join("Capsule.toml"), + format!("[package]\nname = \"{capsule}\"\nversion = \"1.0.0\"\n{env}"), + ) + .unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn derive_materializes_only_named_capsules_and_state() { + let (_dir, kernel) = fixture().await; + let source = PrincipalId::default(); + for capsule in ["harness", "provider", "unrelated"] { + seed_capsule(&kernel, &source, capsule); + } + let source_env = kernel.astrid_home.principal_home(&source).env_dir(); + std::fs::create_dir_all(&source_env).unwrap(); + std::fs::write( + source_env.join("provider.env.json"), + br#"{"api_key":"secret"}"#, + ) + .unwrap(); + std::fs::write( + source_env.join("unrelated.env.json"), + br#"{"private":"data"}"#, + ) + .unwrap(); + kernel + .kv + .set("default:capsule:provider", "model", b"selected".to_vec()) + .await + .unwrap(); + kernel + .kv + .set("default:capsule:unrelated", "private", b"excluded".to_vec()) + .await + .unwrap(); + let source_secrets = FileSecretStore::new( + kernel + .astrid_home + .secrets_dir() + .join(source.as_str()) + .join("provider"), + ); + source_secrets.set("API_KEY", "selected-secret").unwrap(); + + let response = super::agent_derive::agent_derive_from_req( + &kernel, + AgentDeriveRequest { + name: "triage".into(), + source: source.clone(), + load_capsules: vec!["harness".into(), "provider".into()], + allow_capsules: Vec::new(), + inherit_capsule_state: vec!["provider".into()], + network_egress: vec!["api.example.com:443".into()], + }, + ) + .await; + assert!(matches!(response, AdminResponseBody::Success(_))); + + let derived = PrincipalId::new("triage").unwrap(); + let profile = PrincipalProfile::load_from_path(&PrincipalProfile::path_for( + &kernel.astrid_home, + &derived, + )) + .unwrap(); + assert_eq!(profile.groups, vec![BUILTIN_RESTRICTED.to_string()]); + assert!(profile.grants.is_empty()); + assert!(profile.capsules.is_empty()); + assert_eq!(profile.network.egress, vec!["api.example.com:443"]); + + let home = kernel.astrid_home.principal_home(&derived); + assert!(home.capsules_dir().join("harness").exists()); + assert!(home.capsules_dir().join("provider").exists()); + assert!(!home.capsules_dir().join("unrelated").exists()); + assert!(home.env_dir().join("provider.env.json").exists()); + assert!(!home.env_dir().join("unrelated.env.json").exists()); + assert_eq!( + kernel + .kv + .get("triage:capsule:provider", "model") + .await + .unwrap(), + Some(b"selected".to_vec()) + ); + assert!( + kernel + .kv + .get("triage:capsule:unrelated", "private") + .await + .unwrap() + .is_none() + ); + let derived_secrets = FileSecretStore::new( + kernel + .astrid_home + .secrets_dir() + .join(derived.as_str()) + .join("provider"), + ); + assert_eq!( + derived_secrets.get("API_KEY").unwrap().as_deref(), + Some("selected-secret") + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn derive_rejects_state_or_tool_capsules_outside_loaded_set() { + let (_dir, kernel) = fixture().await; + let response = super::agent_derive::agent_derive_from_req( + &kernel, + AgentDeriveRequest { + name: "bad".into(), + source: PrincipalId::default(), + load_capsules: vec!["harness".into()], + allow_capsules: vec!["shell".into()], + inherit_capsule_state: Vec::new(), + network_egress: Vec::new(), + }, + ) + .await; + assert!(matches!(response, AdminResponseBody::Error(_))); + assert!( + !PrincipalProfile::path_for(&kernel.astrid_home, &PrincipalId::new("bad").unwrap()) + .exists() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn derive_rejects_invalid_shape_without_leaving_identity_artifacts() { + let (_dir, kernel) = fixture().await; + seed_capsule(&kernel, &PrincipalId::default(), "harness"); + seed_capsule(&kernel, &PrincipalId::default(), "host-mcp"); + let host_mcp_manifest = kernel + .astrid_home + .principal_home(&PrincipalId::default()) + .capsules_dir() + .join("host-mcp/Capsule.toml"); + std::fs::write( + host_mcp_manifest, + "[package]\nname = \"host-mcp\"\nversion = \"1.0.0\"\n\n[[mcp_server]]\nid = \"legacy\"\ntype = \"stdio\"\ncommand = \"echo\"\n", + ) + .unwrap(); + + let cases = [ + ( + "duplicate", + AgentDeriveRequest { + name: "duplicate".into(), + source: PrincipalId::default(), + load_capsules: vec!["harness".into(), "harness".into()], + allow_capsules: Vec::new(), + inherit_capsule_state: Vec::new(), + network_egress: Vec::new(), + }, + ), + ( + "malformed-egress", + AgentDeriveRequest { + name: "malformed-egress".into(), + source: PrincipalId::default(), + load_capsules: vec!["harness".into()], + allow_capsules: Vec::new(), + inherit_capsule_state: Vec::new(), + network_egress: vec!["api.example.com".into()], + }, + ), + ( + "anonymous", + AgentDeriveRequest { + name: "anonymous".into(), + source: PrincipalId::default(), + load_capsules: vec!["harness".into()], + allow_capsules: Vec::new(), + inherit_capsule_state: Vec::new(), + network_egress: Vec::new(), + }, + ), + ( + "native-engine", + AgentDeriveRequest { + name: "native-engine".into(), + source: PrincipalId::default(), + load_capsules: vec!["host-mcp".into()], + allow_capsules: Vec::new(), + inherit_capsule_state: Vec::new(), + network_egress: Vec::new(), + }, + ), + ]; + + for (name, request) in cases { + let response = super::agent_derive::agent_derive_from_req(&kernel, request).await; + assert!(matches!(response, AdminResponseBody::Error(_))); + let principal = PrincipalId::new(name).unwrap(); + assert!(!PrincipalProfile::path_for(&kernel.astrid_home, &principal).exists()); + assert!( + !kernel + .astrid_home + .keys_dir() + .join(format!("{principal}.key")) + .exists() + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn derive_rolls_back_when_required_capsule_cannot_load() { + let (_dir, kernel) = fixture().await; + let source = PrincipalId::default(); + let install = kernel + .astrid_home + .principal_home(&source) + .capsules_dir() + .join("broken-harness"); + std::fs::create_dir_all(&install).unwrap(); + std::fs::write( + install.join("Capsule.toml"), + r#"[package] +name = "broken-harness" +version = "1.0.0" + +[[component]] +id = "main" +file = "missing.wasm" +"#, + ) + .unwrap(); + + let response = super::agent_derive::agent_derive_from_req( + &kernel, + AgentDeriveRequest { + name: "broken-worker".into(), + source, + load_capsules: vec!["broken-harness".into()], + allow_capsules: Vec::new(), + inherit_capsule_state: Vec::new(), + network_egress: Vec::new(), + }, + ) + .await; + let AdminResponseBody::Error(error) = response else { + panic!("broken required capsule must fail derivation") + }; + assert!(error.contains("failed to load"), "got: {error}"); + + let principal = PrincipalId::new("broken-worker").unwrap(); + assert!(!PrincipalProfile::path_for(&kernel.astrid_home, &principal).exists()); + assert!( + kernel + .identity_store + .resolve("cli", principal.as_str()) + .await + .unwrap() + .is_none() + ); + assert!( + !kernel + .astrid_home + .principal_home(&principal) + .root() + .exists() + ); +} diff --git a/crates/astrid-kernel/src/kernel_router/admin/tests.rs b/crates/astrid-kernel/src/kernel_router/admin/tests.rs index 96e8cd33a..5c7487286 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/tests.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/tests.rs @@ -430,7 +430,11 @@ fn profile_cache_invalidation_reflects_on_disk_mutation() { let cache = PrincipalProfileCache::with_home(home.clone()); let principal = pid("alice"); - // First resolve: missing file → Default (enabled=true, no grants). + let path = PrincipalProfile::path_for(&home, &principal); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + PrincipalProfile::default().save_to_path(&path).unwrap(); + + // First resolve caches the explicit non-default profile. let first = cache.resolve(&principal).unwrap(); assert!(first.enabled); assert!(first.grants.is_empty()); @@ -440,8 +444,6 @@ fn profile_cache_invalidation_reflects_on_disk_mutation() { grants: vec!["self:capsule:install".into()], ..Default::default() }; - let path = PrincipalProfile::path_for(&home, &principal); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); updated.save_to_path(&path).unwrap(); // Without invalidate, cache returns stale Default. diff --git a/crates/astrid-kernel/src/lib.rs b/crates/astrid-kernel/src/lib.rs index 6c33a6ac6..ab83f5ae4 100644 --- a/crates/astrid-kernel/src/lib.rs +++ b/crates/astrid-kernel/src/lib.rs @@ -1048,6 +1048,9 @@ impl Kernel { registry .register_existing(&id, &wasm_hash, principal) .map_err(|e| anyhow::anyhow!("Failed to add capsule view: {e}"))?; + if let Some(capsule) = registry.get_for(principal, &id) { + capsule.resume_for(principal); + } return Ok(()); } } @@ -1082,6 +1085,8 @@ impl Kernel { error = %e, "Failed to add view after concurrent shared load" ); + } else if let Some(capsule) = registry.get_for(principal, &id) { + capsule.resume_for(principal); } } drop(registry); @@ -1102,6 +1107,9 @@ impl Kernel { registry .register_owned_by_default(capsule, wasm_hash, principal) .map_err(|e| anyhow::anyhow!("Failed to register capsule: {e}"))?; + if let Some(capsule) = registry.get_for(principal, &id) { + capsule.resume_for(principal); + } } Ok(()) @@ -1493,15 +1501,21 @@ impl Kernel { #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] pub async fn ensure_principal_loaded(&self, principal: &PrincipalId) { let _load_guard = self.capsule_load_lock.lock().await; - // A deleted principal has no policy profile. Re-check under the same - // lock used by deletion/unload so a loader queued before the authz - // fence cannot re-attach capsule views after deletion has retired them. - if *principal != PrincipalId::default() - && !astrid_core::profile::PrincipalProfile::path_for(&self.astrid_home, principal) + if *principal != PrincipalId::default() { + // The retirement fence is authoritative while deletion retains the + // profile long enough for quota-aware state reclamation. Check it + // under the same load lock used by unload so a queued loader cannot + // re-attach a view after retirement begins. + if self.capabilities.is_principal_retiring(principal).await { + tracing::debug!(%principal, "Skipping capsule load for retiring principal"); + return; + } + if !astrid_core::profile::PrincipalProfile::path_for(&self.astrid_home, principal) .exists() - { - tracing::debug!(%principal, "Skipping capsule load for principal without a profile"); - return; + { + tracing::debug!(%principal, "Skipping capsule load for principal without a profile"); + return; + } } let sorted = self.sorted_principal_capsules(principal); validate_principal_capsules(principal, &sorted); @@ -1540,6 +1554,71 @@ impl Kernel { .await; } + /// Load a principal's capsule view and prove that every explicitly required + /// capsule reached readiness before returning. + /// + /// The ordinary background warm path remains best-effort for compatibility; + /// derived sessions use this checked edge because returning a principal that + /// cannot run its selected harness would violate the atomic derive contract. + pub(crate) async fn ensure_principal_capsules_ready( + &self, + principal: &PrincipalId, + required: &[String], + ) -> Result<(), String> { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + let _ = (principal, required); + return Err("capsule loading is unavailable in the portable kernel build".to_string()); + } + + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + use astrid_capsule::capsule::ReadyStatus; + + self.ensure_principal_loaded(principal).await; + let capsules = { + let registry = self.capsules.read().await; + let mut capsules = Vec::with_capacity(required.len()); + for name in required { + let id = astrid_capsule_types::CapsuleId::new(name.clone()) + .map_err(|error| format!("invalid required capsule '{name}': {error}"))?; + let capsule = registry.get_for(principal, &id).ok_or_else(|| { + format!( + "required capsule '{name}' failed to load for principal '{principal}'" + ) + })?; + capsules.push((name.clone(), capsule)); + } + capsules + }; + + let timeout = std::time::Duration::from_millis(500); + let mut waits = tokio::task::JoinSet::new(); + for (name, capsule) in capsules { + waits.spawn(async move { (name, capsule.wait_ready(timeout).await) }); + } + while let Some(result) = waits.join_next().await { + let (name, status) = result + .map_err(|error| format!("required capsule readiness task failed: {error}"))?; + match status { + ReadyStatus::Ready => {}, + ReadyStatus::Timeout => { + return Err(format!( + "required capsule '{name}' did not signal ready within {}ms", + timeout.as_millis() + )); + }, + ReadyStatus::Crashed => { + return Err(format!( + "required capsule '{name}' exited before signaling ready" + )); + }, + } + } + Ok(()) + } + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] async fn ensure_principal_uplinks_loaded(&self, principal: &PrincipalId) { let _load_guard = self.capsule_load_lock.lock().await; @@ -2096,13 +2175,19 @@ impl Kernel { // other principals still reference the shared instance would break them. let removed = { let mut registry = self.capsules.write().await; - match registry.unregister_for(principal, id) { + let removed = match registry.unregister_for(principal, id) { Ok(removed) => removed, Err(astrid_capsule_types::error::CapsuleError::NotFound(_)) => return Ok(false), Err(e) => { return Err(anyhow::anyhow!("failed to unregister capsule '{id}': {e}")); }, - } + }; + // Keep the registry write edge across quiescence. Registration is + // the only path that calls `resume_for`; without this ordering, a + // concurrent reinstall could resume the principal and then have + // this old unload cancel its newly registered view. + removed.capsule.quiesce_for(principal).await; + removed }; // Explicitly unload the old capsule only when this was the last view. @@ -2144,14 +2229,10 @@ impl Kernel { // principal. Cancel exactly that principal's waits; everyone // else's work is untouched (per-principal child tokens, not the // instance-wide `request_cancel`). - // - // Accepted race: an invocation dispatched before the unregister - // above but installing its per-principal context after this cancel - // mints a fresh token and survives until its own timeout. New - // invocations cannot dispatch (the view is gone), so the window is - // bounded; closing it would take cross-component locking between - // the registry and every engine, which is not worth it. - removed.capsule.request_cancel_for(principal); + // The lifecycle fence rejects late admissions, cancels blocking + // host work, and waits for interceptor calls admitted before + // unregister to return. The cancelled token remains as a tombstone + // until an explicit future view registration reopens the identity. tracing::debug!( capsule_id = %id, principal = %principal, @@ -2606,14 +2687,37 @@ async fn open_test_runtime_kv( ) -> ( Arc, astrid_storage::PrincipalDirectory, + astrid_storage::RuntimePrincipalStore, ) { - let quota: Arc> = - Arc::new(|_: &astrid_storage::StateOwner| Ok(None)); let directory = astrid_storage::PrincipalDirectory::default(); - let kv = astrid_storage::open_runtime_kv_with_directory(home, quota, directory.clone()) - .await - .expect("test kernel: open authoritative principal store"); - (kv, directory) + let quota_home = home.clone(); + let quota_directory = directory.clone(); + let quota: Arc> = + Arc::new(move |owner: &astrid_storage::StateOwner| match owner { + astrid_storage::StateOwner::System => Ok(None), + astrid_storage::StateOwner::Principal(uid) => { + quota_directory + .alias_for(*uid) + .map_or(Ok(None), |principal| { + astrid_core::profile::PrincipalProfile::load_required( + "a_home, + &principal, + ) + .map(|profile| Some(profile.quotas.max_storage_bytes)) + .map_err(|error| { + astrid_storage::StorageError::Internal(format!( + "resolve storage quota for {principal}: {error}" + )) + }) + }) + }, + }); + let store = + astrid_storage::open_runtime_principal_store_with_directory(home, quota, directory.clone()) + .await + .expect("test kernel: open authoritative principal store"); + let kv = store.kv(); + (kv, directory, store) } #[cfg(test)] @@ -2651,7 +2755,7 @@ pub(crate) async fn test_kernel_with_home(home: astrid_core::dirs::AstridHome) - // Use the same authoritative principal-store composition as native boot. // A test helper opening the legacy import source directly would let kernel // tests pass against a runtime topology that production cannot select. - let (kv, principal_directory) = open_test_runtime_kv(&home).await; + let (kv, principal_directory, principal_store) = open_test_runtime_kv(&home).await; let capabilities = Arc::new( CapabilityStore::with_kv_store(Arc::clone(&kv)) .await @@ -2721,7 +2825,7 @@ pub(crate) async fn test_kernel_with_home(home: astrid_core::dirs::AstridHome) - singleton_lock: None, kv, #[cfg(not(target_family = "wasm"))] - principal_store: None, + principal_store: Some(principal_store), audit_log, runtime_key, active_connections: DashMap::new(), @@ -4220,6 +4324,41 @@ mod tests { cancelled_for: Arc>>, } + struct BlockingQuiesceCapsule { + id: CapsuleId, + manifest: CapsuleManifest, + entered: Arc, + release: Arc, + } + + #[async_trait::async_trait] + impl Capsule for BlockingQuiesceCapsule { + 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 quiesce_for(&self, _principal: &PrincipalId) { + self.entered.notify_one(); + self.release.notified().await; + } + } + #[async_trait::async_trait] impl Capsule for CancellableTestCapsule { fn id(&self) -> &CapsuleId { @@ -4404,9 +4543,9 @@ mod tests { ); } - // Bob's release is the LAST view: the full instance-scoped - // `request_cancel` + `unload` path runs, and no additional - // per-principal cancel substitutes for it. + // Bob's release is the LAST view: its principal-scoped fence closes + // first so late work cannot survive into teardown, followed by the + // full instance-scoped `request_cancel` + `unload` path. let removed = kernel.unload_one_capsule(&id, &bob).await.unwrap(); assert!(removed); assert!( @@ -4419,10 +4558,69 @@ mod tests { ); assert_eq!( cancelled_for.lock().expect("cancelled_for mutex").clone(), - vec![alice], - "the last release goes through the instance-scoped path, not \ - request_cancel_for" + vec![alice, bob], + "every releasing principal must cross its lifecycle fence, including \ + the last view before instance teardown" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn registration_cannot_resume_principal_until_old_view_finishes_quiescing() { + let (_d, home) = scratch_home(); + let kernel = test_kernel_with_home(home).await; + let id = CapsuleId::new("serialized-lifecycle").unwrap(); + let alice = PrincipalId::new("alice").unwrap(); + let bob = PrincipalId::new("bob").unwrap(); + let hash = astrid_capsule::registry::WasmHash::from_raw("serialized-lifecycle-hash"); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + + { + let mut registry = kernel.capsules.write().await; + registry + .register_owned_by_default( + Box::new(BlockingQuiesceCapsule { + id: id.clone(), + manifest: CapsuleManifest::default(), + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }), + hash.clone(), + &alice, + ) + .unwrap(); + registry.register_existing(&id, &hash, &bob).unwrap(); + } + + let unloading = { + let kernel = Arc::clone(&kernel); + let id = id.clone(); + let alice = alice.clone(); + tokio::spawn(async move { kernel.unload_one_capsule(&id, &alice).await }) + }; + entered.notified().await; + + let registering = { + let kernel = Arc::clone(&kernel); + let id = id.clone(); + let hash = hash.clone(); + let alice = alice.clone(); + tokio::spawn(async move { + let mut registry = kernel.capsules.write().await; + registry.register_existing(&id, &hash, &alice) + }) + }; + tokio::task::yield_now().await; + assert!( + !registering.is_finished(), + "registration/resume must wait behind the old view's quiescence" ); + + release.notify_one(); + assert!(unloading.await.unwrap().unwrap()); + registering.await.unwrap().unwrap(); + assert!(kernel.capsules.read().await.get_for(&alice, &id).is_some()); + assert!(kernel.capsules.read().await.get_for(&bob, &id).is_some()); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/astrid-uplink/src/admin_client.rs b/crates/astrid-uplink/src/admin_client.rs index 72a4d64d2..df29e8278 100644 --- a/crates/astrid-uplink/src/admin_client.rs +++ b/crates/astrid-uplink/src/admin_client.rs @@ -24,6 +24,7 @@ use anyhow::{Context, Result, anyhow}; use astrid_core::PrincipalId; use astrid_core::kernel_api::{ AdminKernelRequest, AdminKernelResponse, AdminRequestKind, AdminResponseBody, + AgentDeriveKernelRequest, AgentDeriveRequest, }; use astrid_types::Topic; use astrid_types::ipc::{IpcMessage, IpcPayload}; @@ -146,6 +147,17 @@ impl AdminClient { let req = AdminKernelRequest::with_request_id(request_id.clone(), kind); let payload = serde_json::to_value(&req).context("Failed to serialize AdminKernelRequest")?; + self.send_and_wait(topic, want_response, request_id, payload) + .await + } + + async fn send_and_wait( + &mut self, + topic: Topic, + want_response: Topic, + request_id: String, + payload: Value, + ) -> Result { let msg = IpcMessage::new(topic, IpcPayload::RawJson(payload), Uuid::nil()) .with_principal(self.caller.to_string()); self.inner.send_message(msg).await?; @@ -230,6 +242,28 @@ impl AdminClient { } } } + + /// Atomically create a restricted derived principal through the additive + /// derive endpoint without widening the exhaustive admin request enum. + /// + /// # Errors + /// Returns an error when serialization, transport, response decoding, or + /// the response deadline fails. + pub async fn request_agent_derive( + &mut self, + request: AgentDeriveRequest, + ) -> Result { + let request_id = Uuid::new_v4().to_string(); + let topic = Topic::admin_request("agent.derive"); + let want_response = Topic::admin_response("agent.derive"); + let payload = serde_json::to_value(AgentDeriveKernelRequest { + request_id: Some(request_id.clone()), + request, + }) + .context("Failed to serialize AgentDeriveKernelRequest")?; + self.send_and_wait(topic, want_response, request_id, payload) + .await + } } /// Convert an [`AdminResponseBody`] into a `Result`, lifting `Error` diff --git a/e2e/cli-scenarios.toml b/e2e/cli-scenarios.toml index ece41af50..c3ff2e02b 100644 --- a/e2e/cli-scenarios.toml +++ b/e2e/cli-scenarios.toml @@ -26,6 +26,12 @@ status = "covered" mode = "mutating" principal = "admin" +[commands."agent spawn"] +scenario = "ephemeral_spawn" +status = "mapped" +mode = "mutating" +principal = "admin" + [commands."agent list"] scenario = "principal_visibility" status = "covered" diff --git a/e2e/runtime-scenario-specs.toml b/e2e/runtime-scenario-specs.toml index cc12506e8..84e3f6a4f 100644 --- a/e2e/runtime-scenario-specs.toml +++ b/e2e/runtime-scenario-specs.toml @@ -214,6 +214,16 @@ denial = ["cross-principal, stale, or unknown request id is rejected or ignored" state = ["elicit waiter is consumed once"] evidence = ["prompt SSE and elicit response artifacts"] +[scenarios.ephemeral_spawn] +status = "mapped" +surfaces = ["cli"] +auth = ["admin/default"] +success = ["caller explicitly selects the runtime capsules, user-invocable capsules, inherited capsule state, grants, and egress for a derived restricted principal; one bounded job runs and the throwaway plus its footprint are torn down"] +denial = ["omitted capsule state and egress are unavailable, restricted host network and process policy is fail-closed, and approval requests are auto-denied"] +state = ["selected capsule installs and state are materialized before warm-up; teardown reclaims home, keys, and secrets and reports any residue"] +evidence = ["agent spawn CLI transcript, derived profile and namespace assertions, and post-teardown agent list"] +remaining = ["live loop execution (react + LLM capsule set) and fresh-principal warm-up are mapped but not yet executed by the harness"] + [scenarios.first_party_capsule_commands] status = "mapped" surfaces = ["cli", "capsule"]