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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions crates/astrid-capabilities/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions crates/astrid-capsule/src/capsule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions crates/astrid-capsule/src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions crates/astrid-capsule/src/engine/wasm/host/approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ impl approval::Host for HostState {
&mut self,
mut request: ApprovalRequest,
) -> Result<ApprovalResponse, ErrorCode> {
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();
Expand Down
12 changes: 12 additions & 0 deletions crates/astrid-capsule/src/engine/wasm/host/elicit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ impl elicit::Host for HostState {
/// and publishes an `ElicitResponse` on the response topic.
///
fn elicit(&mut self, request: ElicitRequest) -> Result<ElicitResponse, ErrorCode> {
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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<bool, ErrorCode> {
let _operation = self
.begin_host_operation()
.map_err(|()| ErrorCode::Cancelled)?;
self.effective_secret_store()
.exists(&key)
.map_err(|_| ErrorCode::StoreUnavailable)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
38 changes: 24 additions & 14 deletions crates/astrid-capsule/src/engine/wasm/host/fs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<crate::engine::wasm::PrincipalInvocationGuard>, 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();
Expand All @@ -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
Expand All @@ -245,7 +251,10 @@ fn gate_write(
state: &HostState,
physical: &std::path::Path,
kind: WriteKind,
) -> Result<(), ErrorCode> {
) -> Result<Option<crate::engine::wasm::PrincipalInvocationGuard>, 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();
Expand All @@ -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.
Expand All @@ -281,7 +290,7 @@ pub(super) fn authorize_process_read_path(
raw_path: &str,
) -> Result<std::path::PathBuf, ErrorCode> {
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)
}

Expand All @@ -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
Expand Down Expand Up @@ -336,7 +346,7 @@ impl fs::Host for HostState {

fn fs_exists(&mut self, path: String) -> Result<bool, 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 exists =
util::bounded_block_on(&self.runtime_handle, &self.blocking_semaphore, async {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -445,7 +455,7 @@ impl fs::Host for HostState {

fn fs_readdir(&mut self, path: String) -> Result<Vec<String>, 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 {
Expand All @@ -470,7 +480,7 @@ impl fs::Host for HostState {

fn fs_stat(&mut self, path: String) -> Result<FileStat, 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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -525,7 +535,7 @@ impl fs::Host for HostState {

fn read_file(&mut self, path: String) -> Result<Vec<u8>, 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
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions crates/astrid-capsule/src/engine/wasm/host/http/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,15 @@ impl HostState {
) -> Result<WireResponse, ErrorCode> {
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();
Expand Down
15 changes: 15 additions & 0 deletions crates/astrid-capsule/src/engine/wasm/host/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ impl identity::Host for HostState {
&mut self,
request: IdentityResolveRequest,
) -> Result<IdentityResolveResponse, ErrorCode> {
if !self.invocation_authority_active() {
return Err(ErrorCode::CapabilityDenied);
}
let identity_store = self
.identity_store
.clone()
Expand Down Expand Up @@ -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)?;

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -124,6 +133,9 @@ impl identity::Host for HostState {
&mut self,
request: IdentityCreateUserRequest,
) -> Result<IdentityCreateUserResponse, ErrorCode> {
if !self.invocation_authority_active() {
return Err(ErrorCode::CapabilityDenied);
}
let identity_store = self
.identity_store
.clone()
Expand Down Expand Up @@ -154,6 +166,9 @@ impl identity::Host for HostState {
&mut self,
astrid_user_id: String,
) -> Result<Vec<PlatformLink>, ErrorCode> {
if !self.invocation_authority_active() {
return Err(ErrorCode::CapabilityDenied);
}
let user_id =
uuid::Uuid::parse_str(&astrid_user_id).map_err(|_| ErrorCode::InvalidInput)?;

Expand Down
6 changes: 6 additions & 0 deletions crates/astrid-capsule/src/engine/wasm/host/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
35 changes: 35 additions & 0 deletions crates/astrid-capsule/src/engine/wasm/host/ipc_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
));
}
Loading
Loading