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:

  1. Input sanitization. Guest-supplied strings are sanitized at the entry point. The action string has its character count checked against MAX_ACTION_LEN (256) and is rejected with InvalidInput if over. Both action and target-resource have control characters stripped and whitespace trimmed. The target-resource is truncated at MAX_RESOURCE_LEN (1024) characters rather than rejected, because it is a display-only audit field.

  2. AllowanceStore fast path. The host calls AllowanceStore::find_matching_and_consume scoped to the invoking principal (HostState::effective_principal()). The store matches against SensitiveAction::ExecuteCommand { command: target_resource } using CommandPattern glob matching. Agent A’s allowances never match Agent B’s action (principal-scoped, issue #668 fix). On a match, the call returns immediately with ApprovalDecision::Allowance without publishing to the event bus.

  3. Slow path: IPC prompt. When no allowance matches, the host subscribes to astrid.v1.approval.response.<uuid> before publishing IpcPayload::ApprovalRequired on astrid.v1.approval. It then blocks via util::bounded_block_on_cancellable with a 60-second tokio::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_session and approve_always, the host calls create_allowance_from_decision, which sanitizes the action string, escapes glob metacharacters (two-layer defense against injection into the CommandPattern compiler), and stores an Allowance scoped to the invoking principal. A one-shot approve creates no allowance.

    A timeout or cancellation returns ErrorCode::Timeout.

  4. 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

  1. The host maps the typed ElicitRequest to an OnboardingField (defined in astrid_events::ipc). The ElicitType::Select variant requires a non-empty options list; an empty list returns InvalidInput.
  2. The host subscribes to astrid.v1.elicit.response.<uuid> before publishing IpcPayload::ElicitRequest on astrid.v1.elicit.
  3. It blocks via bounded_block_on_cancellable with 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.
  4. On response, if both value and values are None, the call returns ErrorCode::Cancelled. Otherwise the appropriate ElicitResponse variant is constructed. For secrets, the value is written to the SecretStore and SecretStored is returned.
  5. has-secret does not publish an IPC event; it calls effective_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:

OperationRequired 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.

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:

  1. The capability flag is checked.
  2. name and platform are trimmed and validated (empty strings return InvalidInput; platform is also ASCII-lowercased).
  3. The security gate’s check_uplink_register is called if a gate is configured.
  4. An UplinkSource::new_wasm(capsule_id) and UplinkDescriptor are built. The descriptor’s UplinkCapabilities are set to receive_only().
  5. HostState::register_uplink(descriptor) records the descriptor and returns the UUID. A resource limit error maps to Quota; other failures map to InvalidInput.

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:

ProfileRouting intent
chatConversational messages. The kernel maps platform-user-id to an Astrid principal and dispatches to a session.
interactiveLong-lived TTY-style session (the CLI capsule uses this).
notifyOne-way sink. Messages flow in but no session response is expected.
bridgeBidirectional 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.

See also