Packages: Approval, Identity, Uplink
Host Packages: Approval, Identity, Uplink
This page covers four host packages from astrid:*@1.0.0 that deal with human-in-the-loop consent, lifecycle input collection, platform user identity, and inbound message routing. Each package is a frozen WIT interface under wit/host/. The SDK wrappers in sdk-rust/astrid-sdk/src/ sit on top of the raw bindings generated by astrid-sys.
astrid:approval@1.0.0
WIT: wit/host/approval@1.0.0.wit
SDK wrapper: sdk-rust/astrid-sdk/src/lib.rs (pub mod approval)
Host implementation: core/crates/astrid-capsule/src/engine/wasm/host/approval.rs
Purpose
Any capsule can request human consent for a sensitive action. The call blocks the WASM thread until a frontend user responds or the 60-second timeout fires. A pre-approved allowance pattern short-circuits the block entirely.
WIT types
package astrid:approval@1.0.0;
interface host {
variant error-code {
invalid-input,
timeout,
store-unavailable,
unknown(string),
}
record approval-request {
action: string, // max 256 chars, control chars stripped
target-resource: string, // max 1024 chars, control chars stripped
}
enum approval-decision {
denied,
approved,
approved-session,
approved-always,
allowance,
}
record approval-response {
decision: approval-decision,
}
request-approval: func(request: approval-request)
-> result<approval-response, error-code>;
}
The full decision enum is returned so the capsule can communicate the resolution path to its user. allowance means an existing AllowanceStore pattern matched without prompting. The other approved variants come from a live user prompt.
SDK surface
Two functions are exposed, both in astrid_sdk::approval:
// Returns true if approved, false if denied.
pub fn request(action: &str, resource: &str) -> Result<bool, SysError>;
// Returns the specific Decision for display or audit.
pub fn request_decision(action: &str, resource: &str) -> Result<Decision, SysError>;
Decision mirrors the WIT enum:
pub enum Decision {
Denied,
Approved,
ApprovedSession,
ApprovedAlways,
Allowance,
}
impl Decision {
pub fn is_approved(self) -> bool { !matches!(self, Self::Denied) }
}
request is the typical call site. request_decision is used when the capsule wants to surface “this was auto-approved by an existing allowance” to the user.
Approval flow inside the kernel
The host implementation (host/approval.rs) executes four ordered steps:
-
Input sanitization. Guest-supplied strings are sanitized at the entry point. The
actionstring has its character count checked againstMAX_ACTION_LEN(256) and is rejected withInvalidInputif over. Bothactionandtarget-resourcehave control characters stripped and whitespace trimmed. Thetarget-resourceis truncated atMAX_RESOURCE_LEN(1024) characters rather than rejected, because it is a display-only audit field. -
AllowanceStore fast path. The host calls
AllowanceStore::find_matching_and_consumescoped to the invoking principal (HostState::effective_principal()). The store matches againstSensitiveAction::ExecuteCommand { command: target_resource }usingCommandPatternglob matching. Agent A’s allowances never match Agent B’s action (principal-scoped, issue #668 fix). On a match, the call returns immediately withApprovalDecision::Allowancewithout publishing to the event bus. -
Slow path: IPC prompt. When no allowance matches, the host subscribes to
astrid.v1.approval.response.<uuid>before publishingIpcPayload::ApprovalRequiredonastrid.v1.approval. It then blocks viautil::bounded_block_on_cancellablewith a 60-secondtokio::time::timeout. When the frontend publishes a response, the string decision is mapped:"approve"->ApprovedOnce"approve_session"->ApprovedSession"approve_always"->ApprovedAlways- anything else, including explicit
"deny"and unknown strings ->Denied
For
approve_sessionandapprove_always, the host callscreate_allowance_from_decision, which sanitizes the action string, escapes glob metacharacters (two-layer defense against injection into theCommandPatterncompiler), and stores anAllowancescoped to the invoking principal. A one-shotapprovecreates no allowance.A timeout or cancellation returns
ErrorCode::Timeout. -
Audit. Every call is audit-logged regardless of path.
Bus-level envelope (distinct from the host interface)
The companion astrid-bus:approval@1.0.0 package (wit/interfaces/approval.wit) defines the IPC envelope schemas for uplink consumption. Uplinks subscribe to astrid.v1.approval to display the consent prompt and publish responses on astrid.v1.approval.response.<request-id>. The host interface and the bus package are separate concerns that share a request-ID correlation scheme only.
Capsule.toml
No capability declaration is required to call request-approval. The approval host function is available to all capsules.
astrid:elicit@1.0.0
WIT: wit/host/elicit@1.0.0.wit
SDK wrapper: sdk-rust/astrid-sdk/src/elicit.rs
Host implementation: core/crates/astrid-capsule/src/engine/wasm/host/elicit.rs
Purpose
Capsules collect user input during #[astrid::install] and #[astrid::upgrade] lifecycle hooks. The interface is lifecycle-gated: calling elicit from any other execution context (run loop, interceptor, tool) returns ErrorCode::NotInLifecycle immediately. The host blocks for up to 120 seconds.
WIT types
package astrid:elicit@1.0.0;
interface host {
variant error-code {
not-in-lifecycle,
timeout,
cancelled,
invalid-input,
store-unavailable,
unknown(string),
}
enum elicit-type {
text,
secret,
%select,
array,
}
record elicit-request {
kind: elicit-type,
key: string,
description: string,
options: option<list<string>>,
default-value: option<string>,
}
variant elicit-response {
value(string),
values(list<string>),
secret-stored,
}
elicit: func(request: elicit-request) -> result<elicit-response, error-code>;
has-secret: func(key: string) -> result<bool, error-code>;
}
SDK surface
// Collect a text value. Returns the value to the capsule.
pub fn text(key: &str, description: &str) -> Result<String, SysError>;
// Collect text with a pre-filled default.
pub fn text_with_default(key: &str, description: &str, default: &str)
-> Result<String, SysError>;
// Collect a single-choice selection.
pub fn select(key: &str, description: &str, options: &[&str])
-> Result<String, SysError>;
// Collect multiple text items.
pub fn array(key: &str, description: &str) -> Result<Vec<String>, SysError>;
// Store a secret via the kernel SecretStore. The value is never returned.
pub fn secret(key: &str, description: &str) -> Result<(), SysError>;
// Check whether a secret for this key has been stored.
pub fn has_secret(key: &str) -> Result<bool, SysError>;
secret is the critical one. The guest calls it, the user types a value in the TUI, and the host stores it in the SecretStore (OS keychain with KV fallback). The response variant is ElicitResponse::SecretStored, not a Value. The plaintext never crosses the host-guest boundary.
Secret routing and store scoping
The host checks HostState::lifecycle_phase on every elicit call. If None, the call returns NotInLifecycle. When in a lifecycle phase, secrets are stored via HostState::effective_secret_store(), which returns the per-invocation store when one is installed (multi-principal path) or falls back to the load-time store (single-principal path). This scoping means has_secret on Alice’s invocation context returns false for secrets Bob stored, even if both use the same capsule.
Elicit flow inside the kernel
- The host maps the typed
ElicitRequestto anOnboardingField(defined inastrid_events::ipc). TheElicitType::Selectvariant requires a non-emptyoptionslist; an empty list returnsInvalidInput. - The host subscribes to
astrid.v1.elicit.response.<uuid>before publishingIpcPayload::ElicitRequestonastrid.v1.elicit. - It blocks via
bounded_block_on_cancellablewith a 120-second timeout. Cancellation is prioritised over completion in the biased select; a response arriving in the same poll tick as cancellation is discarded. This is intentional for clean teardown under load. - On response, if both
valueandvaluesareNone, the call returnsErrorCode::Cancelled. Otherwise the appropriateElicitResponsevariant is constructed. For secrets, the value is written to theSecretStoreandSecretStoredis returned. has-secretdoes not publish an IPC event; it callseffective_secret_store().exists(key)directly and is not audit-logged.
Bus-level envelope
astrid-bus:elicit@1.0.0 (wit/interfaces/elicit.wit) carries the same flow over the IPC bus. The request record reuses the onboarding.field schema defined in wit/interfaces/onboarding.wit, which distinguishes text, secret, choice(list<string>), and array field types. Uplinks subscribe to astrid.v1.elicit and publish responses on astrid.v1.elicit.response.<request-id>.
Capsule.toml
No capability declaration is required to call elicit or has-secret. The lifecycle gate is enforced by HostState::lifecycle_phase, not a capability field.
astrid:identity@1.0.0
WIT: wit/host/identity@1.0.0.wit
SDK wrapper: sdk-rust/astrid-sdk/src/identity.rs
Host implementation: core/crates/astrid-capsule/src/engine/wasm/host/identity.rs
Purpose
Maps external platform identities (Discord user, GitHub user, etc.) to internal Astrid user UUIDs and manages the links between them. Every operation is capability-gated at the CapsuleSecurityGate layer.
WIT types
package astrid:identity@1.0.0;
interface host {
variant error-code {
capability-denied,
invalid-input,
user-not-found,
link-not-found,
already-linked,
store-unavailable,
unknown(string),
}
record identity-resolve-request {
platform: string,
platform-user-id: string,
}
record identity-resolve-response {
user-id: string,
display-name: option<string>,
}
record identity-link-request {
platform: string,
platform-user-id: string,
astrid-user-id: string,
method: string,
}
record identity-unlink-request {
platform: string,
platform-user-id: string,
}
record identity-create-user-request {
display-name: option<string>,
}
record identity-create-user-response {
user-id: string,
}
record platform-link {
platform: string,
platform-user-id: string,
linked-at: string, // ISO 8601
method: string,
}
identity-resolve: func(request: identity-resolve-request)
-> result<identity-resolve-response, error-code>;
identity-link: func(request: identity-link-request) -> result<_, error-code>;
identity-unlink: func(request: identity-unlink-request) -> result<_, error-code>;
identity-create-user: func(request: identity-create-user-request)
-> result<identity-create-user-response, error-code>;
identity-list-links: func(astrid-user-id: string)
-> result<list<platform-link>, error-code>;
}
link-not-found is a typed error variant, not a found: bool field. The resolve call returning that variant is the authoritative signal that no link exists. The SDK translates this into Ok(None) so call sites can use idiomatic Option semantics.
SDK surface
// Returns Ok(Some(user)) or Ok(None), never Err for link-not-found.
pub fn resolve(platform: &str, platform_user_id: &str)
-> Result<Option<ResolvedUser>, SysError>;
pub fn link(
platform: &str,
platform_user_id: &str,
astrid_user_id: &str,
method: &str,
) -> Result<(), SysError>;
// Returns Ok(true) removed, Ok(false) no link existed.
pub fn unlink(platform: &str, platform_user_id: &str) -> Result<bool, SysError>;
// Requires identity = ["admin"].
pub fn create_user(display_name: Option<&str>) -> Result<String, SysError>;
pub fn list_links(astrid_user_id: &str) -> Result<Vec<Link>, SysError>;
Link includes an astrid_user_id field that the SDK fills in from the caller argument, because the WIT platform-link record scopes to a single user and does not repeat it.
Capability hierarchy
Identity operations are gated by IdentityOperation::required_capability() against the capsule’s manifest identity list:
| Operation | Required capability |
|---|---|
resolve | "resolve" |
link, unlink, list-links | "link" |
create-user | "admin" |
The hierarchy is admin > link > resolve: declaring "admin" implies "link" which implies "resolve". The check is in identity_capability_satisfies (core/crates/astrid-capsule/src/security/mod.rs).
The CapsuleSecurityGate::check_identity default implementation denies all identity operations (fail-closed). Only ManifestSecurityGate consults the manifest list.
Capsule.toml
[capabilities]
identity = ["resolve"] # read-only lookups
# identity = ["link"] # resolve + link/unlink/list
# identity = ["admin"] # link + create-user
An empty identity list (the default) means no identity calls succeed. The gate fires before any store access.
Host implementation notes
Each operation calls CapsuleSecurityGate::check_identity first, then calls the IdentityStore (an async trait) via util::bounded_block_on. astrid-user-id in the link and list-links operations is parsed as a uuid::Uuid before the gate call; an unparseable UUID returns InvalidInput immediately. The map_store_err function converts store error strings to typed variants by substring matching ("not found", "already linked", "denied" or "capability"), a pragmatic approach given the trait’s Display error type.
astrid:uplink@1.0.0
WIT: wit/host/uplink@1.0.0.wit
SDK wrapper: sdk-rust/astrid-sdk/src/lib.rs (pub mod uplink)
Host implementation: core/crates/astrid-capsule/src/engine/wasm/host/uplink.rs
Purpose
Capsules that bridge external platforms (Discord, Telegram, Slack) register named uplink endpoints and forward inbound user messages through those endpoints into the kernel’s session pipeline.
WIT types
package astrid:uplink@1.0.0;
interface host {
variant error-code {
capability-denied,
invalid-input,
invalid-profile,
unknown-uplink,
no-session,
quota,
unknown(string),
}
enum uplink-profile {
chat,
interactive,
notify,
bridge,
}
uplink-register: func(name: string, platform: string, profile: uplink-profile)
-> result<string, error-code>;
uplink-send: func(uplink-id: string, platform-user-id: string, content: string)
-> result<bool, error-code>;
}
SDK surface
pub struct UplinkId(pub(crate) String);
pub enum Profile {
Chat, // Telegram, Discord DM, Slack DM
Interactive, // CLI TTY
Notify, // one-way notification sink
Bridge, // bidirectional bridge to another runtime
}
// Register via string profile name: "chat", "interactive", "notify", "bridge".
pub fn register(name: &str, platform: &str, profile: &str)
-> Result<UplinkId, SysError>;
// Register via typed Profile.
pub fn register_profile(name: &str, platform: &str, profile: Profile)
-> Result<UplinkId, SysError>;
// Returns true if sent, false if dropped (no active session).
pub fn send(uplink_id: &UplinkId, platform_user_id: &str, content: &str)
-> Result<bool, SysError>;
false from send is normal flow, not an error. It signals that the target principal has no active session and the message was intentionally dropped. ErrorCode::NoSession is not returned from uplink-send at the WIT level.
The uplink capability flag
Access to both uplink-register and uplink-send is gated by HostState::has_uplink_capability. This boolean is set directly from manifest.capabilities.uplink at capsule load time (wasm/mod.rs line 1127). Without it, both calls return ErrorCode::CapabilityDenied before any other check runs.
uplink = true in a capsule manifest also changes the execution model: the kernel treats the capsule as a long-lived daemon and disables the WASM execution timeout (is_daemon check in wasm/mod.rs).
A second gate runs for register: CapsuleSecurityGate::check_uplink_register is called when a security gate is installed. The default implementation permits all registrations (intentionally permissive for backward compatibility). The has_uplink_capability flag is the authoritative first-line check.
Register semantics
uplink-register returns a UUID string that becomes the UplinkId. Internally:
- The capability flag is checked.
nameandplatformare trimmed and validated (empty strings returnInvalidInput;platformis also ASCII-lowercased).- The security gate’s
check_uplink_registeris called if a gate is configured. - An
UplinkSource::new_wasm(capsule_id)andUplinkDescriptorare built. The descriptor’sUplinkCapabilitiesare set toreceive_only(). HostState::register_uplink(descriptor)records the descriptor and returns the UUID. A resource limit error maps toQuota; other failures map toInvalidInput.
Send semantics
uplink-send requires the uplink-id to be a parseable UUID (returning InvalidInput otherwise) and requires it to match a descriptor in HostState::registered_uplinks. The platform is retrieved from the matching descriptor. An InboundMessage is constructed and sent via HostState::inbound_tx using try_send. A channel error (full or closed) returns Ok(false), not an error, matching the WIT contract that a dropped message is not exceptional.
Capsule.toml
[capabilities]
uplink = true
This is the only field needed. There is no finer-grained uplink capability (unlike identity which has resolve/link/admin). The uplink = true flag is a single boolean in CapabilitiesDef.
Profile routing
The kernel uses the uplink-profile enum to determine how it routes messages received through this uplink:
| Profile | Routing intent |
|---|---|
chat | Conversational messages. The kernel maps platform-user-id to an Astrid principal and dispatches to a session. |
interactive | Long-lived TTY-style session (the CLI capsule uses this). |
notify | One-way sink. Messages flow in but no session response is expected. |
bridge | Bidirectional bridge to another Astrid runtime. |
Relationships between the four packages
astrid:approval@1.0.0 and astrid:elicit@1.0.0 each have a parallel bus-level package (astrid-bus:approval@1.0.0, astrid-bus:elicit@1.0.0). The host interface is the in-capsule syscall. The bus package defines the IPC envelopes that carry the same flow to uplinks (the TUI, CLI, or web frontend that renders the prompt). Both pairs share a request-ID correlation scheme: the host subscribes to a response topic before publishing the request, which prevents races at the cost of one extra subscription per call.
astrid:identity@1.0.0 has no bus-level companion. Identity operations are synchronous store queries with no uplink involvement.
astrid:uplink@1.0.0 inverts the flow direction of the other three: where approval and elicit send data from the capsule outward to a human and wait for a reply, uplink receives data from external humans and injects it into the kernel’s session pipeline.
All four packages are imported in the synthetic capsule world in astrid-sys and are available to any capsule that depends on astrid-sdk. Capability gating happens at the host function entry point, not at the WIT import level.
ABI evolution
All four packages are frozen at @1.0.0 per the ABI evolution discipline. New functionality ships as a new file at a new version path (e.g. elicit@1.1.0.wit), added as an additional import in the astrid-sys inline world. The Component Model linker enforces exact (package, version) matching, so capsules pinned to an older version continue to resolve their old interface unchanged.