From 23289a060c03df7748b63cf973dfe11c178cf38b Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 21 May 2026 17:18:31 +0400 Subject: [PATCH 01/14] feat!: split astrid:capsule@0.1.0 into per-domain packages (#750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the monolithic astrid:capsule@0.1.0 with one frozen WIT file per domain at host/@.wit. The wasmtime Component Model linker enforces structural typing on every (package, version) pair, so the single mega-package meant any record-field add or function-add broke every capsule built against the prior shape. Per-domain packages contain the blast radius: bumping astrid:net no longer recompiles capsules that only import astrid:kv. Packages introduced: astrid:fs@1.0.0 host/fs@1.0.0.wit astrid:ipc@1.0.0 host/ipc@1.0.0.wit astrid:uplink@1.0.0 host/uplink@1.0.0.wit astrid:kv@1.0.0 host/kv@1.0.0.wit astrid:net@1.0.0 host/net@1.0.0.wit astrid:http@1.0.0 host/http@1.0.0.wit astrid:sys@1.0.0 host/sys@1.0.0.wit astrid:process@1.0.0 host/process@1.0.0.wit astrid:elicit@1.0.0 host/elicit@1.0.0.wit astrid:approval@1.0.0 host/approval@1.0.0.wit astrid:identity@1.0.0 host/identity@1.0.0.wit astrid:guest@1.0.0 host/guest@1.0.0.wit (lifecycle exports) The RFC originally proposed a minimal astrid:core for cross-cutting types, but auditing the actual types revealed nothing genuinely shared — every type in the old monolithic 'types' interface is used by exactly one domain. Zero-shared is purer and cheaper to maintain, so astrid:core is not introduced. Each per-domain package owns its own types in a single 'host' interface alongside its functions. The guest export contract (astrid-hook-trigger, run, astrid-install, astrid-upgrade) lives in astrid:guest rather than astrid:hook to avoid collision with the existing capsule-to-capsule astrid:hook in interfaces/hook.wit. The naming is symmetric with the per-domain 'host' interfaces: 'host' is kernel-side (imported by capsules), 'guest' is capsule-side (called by the kernel). Dropped without replacement (dead in the host WIT, duplicated by capsule-to-capsule interfaces/tool.wit): - tool-input, tool-output, tool-definition - capsule-context Hard cut: host/astrid-capsule.wit deleted. Pre-1.0 we have license to break the ecosystem rebuild; post-1.0 we do not, so the new model ships clean rather than carrying a deprecated alias. Evolution discipline (frozen-file rule) enforced by CI: scripts/lint-wit-immutability.sh — fails any PR that modifies or deletes a published *@X.Y.Z.wit file. New shape = new file at a new version path. .github/workflows/lint.yml — runs the immutability lint and parses every WIT file with wasm-tools on every PR / push to main. README rewritten with the per-domain layout, the evolution rule, and the worked example of bumping a package version. See RFC #22 (host_abi) and issue unicity-astrid/astrid#750 for the full design rationale. --- .github/workflows/lint.yml | 51 ++ README.md | 55 +- host/approval@1.0.0.wit | 37 ++ host/astrid-capsule.wit | 947 ------------------------------- host/elicit@1.0.0.wit | 39 ++ host/fs@1.0.0.wit | 62 ++ host/guest@1.0.0.wit | 67 +++ host/http@1.0.0.wit | 74 +++ host/identity@1.0.0.wit | 99 ++++ host/ipc@1.0.0.wit | 113 ++++ host/kv@1.0.0.wit | 34 ++ host/net@1.0.0.wit | 193 +++++++ host/process@1.0.0.wit | 84 +++ host/sys@1.0.0.wit | 85 +++ host/uplink@1.0.0.wit | 25 + scripts/lint-wit-immutability.sh | 66 +++ 16 files changed, 1081 insertions(+), 950 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 host/approval@1.0.0.wit delete mode 100644 host/astrid-capsule.wit create mode 100644 host/elicit@1.0.0.wit create mode 100644 host/fs@1.0.0.wit create mode 100644 host/guest@1.0.0.wit create mode 100644 host/http@1.0.0.wit create mode 100644 host/identity@1.0.0.wit create mode 100644 host/ipc@1.0.0.wit create mode 100644 host/kv@1.0.0.wit create mode 100644 host/net@1.0.0.wit create mode 100644 host/process@1.0.0.wit create mode 100644 host/sys@1.0.0.wit create mode 100644 host/uplink@1.0.0.wit create mode 100755 scripts/lint-wit-immutability.sh diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..8568345 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,51 @@ +name: lint + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + wit-immutability: + name: WIT frozen-file immutability + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine base ref + id: base + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT" + else + echo "ref=HEAD~1" >> "$GITHUB_OUTPUT" + fi + + - name: Check frozen-file immutability + run: scripts/lint-wit-immutability.sh ${{ steps.base.outputs.ref }} + + wit-parses: + name: WIT files parse + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install wasm-tools + run: cargo install --locked wasm-tools + + - name: Parse every WIT file + run: | + fail=0 + for f in $(find host interfaces -name '*.wit' 2>/dev/null); do + if ! wasm-tools component wit "$f" >/dev/null 2>&1; then + echo "FAIL: $f" + wasm-tools component wit "$f" 2>&1 | head -20 + fail=1 + else + echo "OK: $f" + fi + done + exit $fail diff --git a/README.md b/README.md index dbd915b..87e00dc 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,58 @@ The Astrid CLI installs the capsule-to-capsule interfaces to `~/.astrid/wit/astr The kernel-to-capsule contract. Capsules import these interfaces; the kernel provides the implementations. Lives outside `interfaces/` because the consumer set is fixed (the kernel + each SDK that wraps the imports) rather than open-ended. +Each domain is its own package, frozen at a per-file version. A capsule imports only the domains it uses; bumping one domain does not affect capsules that do not import it. + | File | Package | Description | |------|---------|-------------| -| `host/astrid-capsule.wit` | `astrid:capsule@0.1.0` | The 49 host functions across 11 domain-specific interfaces (`fs`, `ipc`, `kv`, `net`, `http`, `sys`, `process`, `elicit`, `approval`, `identity`, `uplink`, `types`) plus the 4 guest exports (`astrid-hook-trigger`, `run`, `astrid-install`, `astrid-upgrade`) that make up the `capsule` world. | +| `host/fs@1.0.0.wit` | `astrid:fs@1.0.0` | Filesystem operations within the workspace boundary. | +| `host/ipc@1.0.0.wit` | `astrid:ipc@1.0.0` | Publish/subscribe IPC event bus. | +| `host/uplink@1.0.0.wit` | `astrid:uplink@1.0.0` | Inbound message ingestion from external platforms. | +| `host/kv@1.0.0.wit` | `astrid:kv@1.0.0` | Per-capsule, per-principal key-value storage. | +| `host/net@1.0.0.wit` | `astrid:net@1.0.0` | Unix-domain sockets and gated outbound TCP. | +| `host/http@1.0.0.wit` | `astrid:http@1.0.0` | HTTP client with SSRF protection and streaming. | +| `host/sys@1.0.0.wit` | `astrid:sys@1.0.0` | Logging, config, time, caller context, capability introspection. | +| `host/process@1.0.0.wit` | `astrid:process@1.0.0` | OS-sandboxed host process spawn / kill / read-logs. | +| `host/elicit@1.0.0.wit` | `astrid:elicit@1.0.0` | Interactive user input during install/upgrade lifecycle. | +| `host/approval@1.0.0.wit` | `astrid:approval@1.0.0` | Human-in-the-loop approval gate for sensitive actions. | +| `host/identity@1.0.0.wit` | `astrid:identity@1.0.0` | Multi-platform identity resolve and link. | +| `host/guest@1.0.0.wit` | `astrid:guest@1.0.0` | Guest export contract — `astrid-hook-trigger`, `run`, `astrid-install`, `astrid-upgrade`. Capsule worlds `include astrid:guest/exports@1.0.0`. | + +A capsule's world declares only the imports it uses plus the guest export include: + +```wit +world my-capsule { + include astrid:guest/exports@1.0.0; + import astrid:ipc/host@1.0.0; + import astrid:kv/host@1.0.0; + // intentionally not importing net, http, identity, … +} +``` + +### Evolution discipline + +Once a `host/@X.Y.Z.wit` file is shipped (i.e. on `main`), it is **immutable forever**. The wasmtime Component Model linker enforces structural typing on every `(package, version)` pair, so any record-field add or function add in a published WIT file causes every capsule built against the prior shape to fail to instantiate. The fix is to never edit a published file in place. + +Shape changes ship as a new file at a new version: + +``` +host/ + ipc@1.0.0.wit # frozen + ipc@1.1.0.wit # frozen (additive change from 1.0.0) + ipc@2.0.0.wit # current (breaking change from 1.x) +``` + +To evolve a package: + +1. Copy the latest frozen file: `cp host/ipc@1.0.0.wit host/ipc@1.1.0.wit`. +2. Bump the package declaration inside the new file: `package astrid:ipc@1.1.0;`. +3. Make your shape changes in the new file. +4. Leave the existing frozen file untouched. +5. The kernel registers both versions in its linker (`bindings::ipc_v1_0::add_to_linker` and `bindings::ipc_v1_1::add_to_linker`) so old and new capsules both load. + +CI enforces this via `scripts/lint-wit-immutability.sh` — any PR that modifies or deletes a published `*@X.Y.Z.wit` file fails the build. + +See [RFC: Host ABI](https://github.com/unicity-astrid/rfcs/pull/22) for the full design (per-domain packages, multi-version kernel registration, frozen-file rule) and [issue #750](https://github.com/unicity-astrid/astrid/issues/750) for the motivating bug. ## Capsule interfaces (`interfaces/`) @@ -52,13 +101,13 @@ The kernel validates at boot that every required import has a matching export fr SDKs use `wit-bindgen` (Rust), `ComponentizeJS` (JS/TS), or equivalent toolchains to generate typed bindings from these definitions. The generated types match the IPC payload schemas so capsule authors get compile-time type safety. -The kernel uses `wasmtime::component::bindgen!` against `host/astrid-capsule.wit` to generate the host-side trait the host implementations satisfy. +The kernel uses `wasmtime::component::bindgen!` against each `host/@.wit` to generate one binding module per `(package, version)` pair. The kernel's linker setup registers every supported version explicitly — there is no implicit version negotiation in the Component Model. ## Cross-repo coordination Both kinds of WIT files change rarely but breakingly. The repos that submodule from here are: -- [`unicity-astrid/astrid`](https://github.com/unicity-astrid/astrid) -- kernel (host implementations bound to `host/astrid-capsule.wit`) +- [`unicity-astrid/astrid`](https://github.com/unicity-astrid/astrid) -- kernel (host implementations bound to each `host/@.wit`) - [`unicity-astrid/sdk-rust`](https://github.com/unicity-astrid/sdk-rust) -- Rust SDK (guest bindings + capsule contracts) - [`unicity-astrid/sdk-js`](https://github.com/unicity-astrid/sdk-js) -- JavaScript / TypeScript SDK (same) diff --git a/host/approval@1.0.0.wit b/host/approval@1.0.0.wit new file mode 100644 index 0000000..7899c01 --- /dev/null +++ b/host/approval@1.0.0.wit @@ -0,0 +1,37 @@ +/// Human-in-the-loop approval for sensitive actions. +/// +/// Checks the AllowanceStore first (instant path for pre-approved +/// patterns), then publishes an ApprovalRequired IPC event and blocks +/// until the frontend user responds or the request times out (60s). +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:approval@1.0.0; + +interface host { + /// Approval request from a capsule to the host. + /// + /// The capsule declares the action and resource. The kernel classifies + /// risk and manages approval policy — the capsule sees only approved/denied. + record approval-request { + /// The action being requested (e.g. "git push"). + action: string, + /// Full resource description (e.g. "git push origin main"). + target-resource: string, + } + + /// Approval response from the host. + record approval-response { + /// Whether the action was approved. + approved: bool, + } + + /// Request human approval for a sensitive action. + /// + /// Decision values: "approve" (once), "approve_session", "approve_always", + /// "deny", "allowance" (auto-granted via existing allowance). + /// Action strings are sanitized (control chars stripped, max 256 chars). + /// Resource strings are sanitized (max 1024 chars). + request-approval: func(request: approval-request) -> result; +} diff --git a/host/astrid-capsule.wit b/host/astrid-capsule.wit deleted file mode 100644 index 05db395..0000000 --- a/host/astrid-capsule.wit +++ /dev/null @@ -1,947 +0,0 @@ -/// WIT interface definitions for Astrid WASM-sandboxed capsules. -/// -/// Package: astrid:capsule@0.1.0 -/// -/// This file defines the canonical contract between the Astrid host runtime -/// and WASM capsule guests. Near-term it documents the Extism host function -/// contract (WS-3); long-term it becomes direct Component Model imports/exports -/// when migrating to the Component Model (WS-8). -/// -/// The host exposes 49 functions across 11 domain-specific interfaces. All -/// operations are capability-gated and audited by the Astrid runtime. Capsules -/// never bypass the security model — the host enforces workspace boundaries, -/// budget limits, capability tokens, and per-principal scoping on every call. -/// -/// Error handling: Functions that can fail return `result` where -/// the error string describes the failure. Unrecoverable errors (lock -/// poisoning, memory exhaustion) trap — those represent kernel invariant -/// violations. All other errors (file-not-found, permission denied, KV miss, -/// network errors) are returned as the error variant so the guest can handle -/// them gracefully. - -package astrid:capsule@0.1.0; - -// --------------------------------------------------------------------------- -// Shared types -// --------------------------------------------------------------------------- - -/// Shared types used across host and guest interfaces. -interface types { - /// Log severity level for structured capsule logging. - enum log-level { - trace, - debug, - info, - warn, - error, - } - - /// A key-value pair used for typed header lists. - record key-value-pair { - key: string, - value: string, - } - - /// Context passed to a capsule when a hook fires. - record capsule-context { - /// The event name that triggered this hook (e.g. "pre-tool-call"). - event: string, - /// Session ID for the current interaction. - session-id: string, - /// Authenticated user ID, if available. - user-id: option, - /// Event-specific payload as a JSON string. - data: option, - } - - /// Result returned by a capsule after hook execution. - record capsule-result { - /// Action directive (e.g. "continue", "abort", "modify"). - action: string, - /// Optional payload as a JSON string. - data: option, - } - - /// Input arguments for a tool invocation. - record tool-input { - /// Tool name to invoke. - name: string, - /// Tool arguments as a JSON string. - arguments: string, - } - - /// Output from a tool execution. - record tool-output { - /// Result content as a JSON string. - content: string, - /// Whether this output represents an error. - is-error: bool, - } - - /// Metadata describing a tool that a capsule exposes. - record tool-definition { - /// Unique tool name. - name: string, - /// Human-readable description. - description: string, - /// JSON Schema describing the tool's input parameters. - input-schema: string, - } - - /// HTTP request sent by a capsule to the host. - record http-request-data { - /// Target URL. - url: string, - /// HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS). - method: string, - /// Request headers as key-value pairs. - headers: list, - /// Optional request body. - body: option, - } - - /// HTTP response returned by the host. - record http-response-data { - /// HTTP status code. - status: u16, - /// Response headers. - headers: list, - /// Response body as raw bytes (may contain non-UTF-8 binary data). - body: list, - } - - /// HTTP streaming response metadata returned on stream start. - record http-stream-start-response { - /// Opaque handle for subsequent read/close calls. - handle: u64, - /// HTTP status code. - status: u16, - /// Response headers. - headers: list, - } - - /// File metadata returned by `fs-stat`. - record file-stat { - /// File size in bytes. - size: u64, - /// Whether this entry is a directory. - is-dir: bool, - /// Last modification time (UNIX epoch seconds), if available. - mtime: option, - } - - /// Request to spawn a host process. - record spawn-request { - /// Command to execute. - cmd: string, - /// Command arguments. - args: list, - } - - /// Result of a synchronous process execution. - record process-result { - /// Captured standard output. - stdout: string, - /// Captured standard error. - stderr: string, - /// Process exit code (-1 if killed or unknown). - exit-code: s32, - } - - /// Result of spawning a background process. - record spawn-background-result { - /// Opaque process handle for subsequent log/kill calls. - id: u64, - } - - /// Logs and status from a background process. - record read-logs-result { - /// Buffered stdout since last read. - stdout: string, - /// Buffered stderr since last read. - stderr: string, - /// Whether the process is still running. - running: bool, - /// Exit code if the process has terminated. - exit-code: option, - } - - /// Result of killing a background process. - record kill-process-result { - /// Whether the process was successfully killed. - killed: bool, - /// Exit code if available. - exit-code: option, - /// Final buffered stdout. - stdout: string, - /// Final buffered stderr. - stderr: string, - } - - /// Caller context returned by `get-caller`. - record caller-context { - /// The acting principal for this invocation. - principal: option, - /// UUID of the capsule that originated the IPC message. - source-id: string, - /// ISO 8601 timestamp of the originating message. - timestamp: string, - } - - /// IPC message envelope returned by `ipc-poll` and `ipc-recv`. - record ipc-envelope { - /// List of IPC messages received since last poll/recv. - messages: list, - /// Number of messages dropped due to buffer overflow. - dropped: u64, - /// Cumulative lag (messages missed due to slow consumption). - lagged: u64, - } - - /// A single IPC message. - record ipc-message { - /// Topic the message was published on. - topic: string, - /// Message payload as JSON. - payload: string, - /// UUID of the capsule that sent this message. - source-id: string, - /// Principal attributed to the publisher of this message. - /// - /// For messages published via `ipc-publish`, this is the publishing - /// capsule's invocation principal (whoever the host attributed the - /// call to). For messages published via `ipc-publish-as`, this is - /// the principal the uplink claimed on behalf of an external caller. - /// - /// `none` for system / kernel-originated events that have no - /// attributable principal, and for legacy messages that predate - /// this field. Subscribers processing multi-message recv batches - /// should read this per-message rather than relying on the - /// invocation context (which only reflects the first message's - /// publisher). - principal: option, - } - - /// Pre-registered interceptor handle mapping. - record interceptor-handle { - /// Subscription handle ID. - handle-id: u64, - /// Interceptor action name. - action: string, - /// IPC topic pattern this handle is subscribed to. - topic: string, - } - - /// Status of a network read operation. - variant net-read-status { - /// Data frame received. - data(list), - /// Stream closed by peer. - closed, - /// No data available (non-blocking). - pending, - } - - /// Request to check a capsule's capability. - record capability-check-request { - /// UUID of the capsule to check. - source-uuid: string, - /// Capability name to check. - capability: string, - } - - /// Response from a capability check. - record capability-check-response { - /// Whether the capability is allowed. - allowed: bool, - } - - /// Request to resolve a platform identity to an Astrid user. - record identity-resolve-request { - /// External platform (e.g. "discord", "github"). - platform: string, - /// User ID on the external platform. - platform-user-id: string, - } - - /// Result of an identity resolve operation. - record identity-resolve-response { - /// Whether a matching user was found. - found: bool, - /// The Astrid user ID, if found. - user-id: option, - /// The user's display name, if available. - display-name: option, - /// Error message, if the operation failed. - error: option, - } - - /// Request to link a platform identity to an Astrid user. - record identity-link-request { - /// External platform (e.g. "discord", "github"). - platform: string, - /// User ID on the external platform. - platform-user-id: string, - /// Internal Astrid user ID to link to. - astrid-user-id: string, - /// Authentication method (e.g. "passkey", "token"). - method: string, - } - - /// Request to unlink a platform identity. - record identity-unlink-request { - /// External platform. - platform: string, - /// User ID on the external platform. - platform-user-id: string, - } - - /// Request to create a new Astrid user. - record identity-create-user-request { - /// Optional display name for the new user. - display-name: option, - } - - /// Request to list platform links for an Astrid user. - record identity-list-links-request { - /// Internal Astrid user ID. - astrid-user-id: string, - } - - /// Result of an identity mutation (link, unlink, create-user, list-links). - record identity-ok-response { - /// Whether the operation succeeded. - ok: bool, - /// Error message, if the operation failed. - error: option, - /// Created user ID (set by create-user on success). - user-id: option, - /// Whether a link was removed (set by unlink on success). - removed: option, - /// JSON-encoded list of platform links (set by list-links on success). - links-json: option, - } - - /// Request for user input during capsule lifecycle. - record elicit-request { - /// Input type: "text", "secret", "select", or "array". - elicit-type: string, - /// Key for storing the collected value. - key: string, - /// Human-readable prompt description. - description: string, - /// Options for select-type inputs. - options: option>, - /// Default value. - default-value: option, - } - - /// Approval request from a capsule to the host. - /// - /// The capsule declares the action and resource. The kernel classifies - /// risk and manages approval policy — the capsule sees only approved/denied. - record approval-request { - /// The action being requested (e.g. "git push"). - action: string, - /// Full resource description (e.g. "git push origin main"). - target-resource: string, - } - - /// Approval response from the host. - record approval-response { - /// Whether the action was approved. - approved: bool, - } - - /// Direction argument for `net-shutdown` — mirrors `std::net::Shutdown`. - enum shutdown-how { - /// Half-close the read side. Subsequent reads return EOF; writes - /// continue. - read, - /// Half-close the write side. The peer sees EOF on its read side; - /// reads continue. - write, - /// Close both directions. Equivalent to `net-close-stream` except - /// the handle entry is retained so getters (`peer-addr`, etc.) - /// still work until the explicit close call. - both, - } -} - -// --------------------------------------------------------------------------- -// Host interfaces — domain-specific groupings -// --------------------------------------------------------------------------- - -/// Filesystem operations within the capsule's workspace boundary. -/// -/// All paths are resolved relative to the workspace root. The `home://` -/// URI scheme accesses the principal's home directory. Paths under `/tmp/` -/// access per-principal temporary storage. Attempts to escape any boundary -/// return an error. Symlink traversal is canonicalized to prevent bypass. -interface fs { - use types.{file-stat}; - - /// Check whether a file or directory exists at the given path. - /// - /// Returns true if the path exists, false otherwise. - /// Security-gated: requires file-read capability. - fs-exists: func(path: string) -> result; - - /// Create a directory (and parents) at the given path. - /// - /// Security-gated: requires file-write capability. - fs-mkdir: func(path: string) -> result<_, string>; - - /// List entries in a directory. - /// - /// Returns a list of entry name strings. - /// Security-gated: requires file-read capability. - fs-readdir: func(path: string) -> result, string>; - - /// Get file metadata (size, type, mtime). - /// - /// Security-gated: requires file-read capability. - fs-stat: func(path: string) -> result; - - /// Remove a file at the given path. - /// - /// Security-gated: requires file-write capability. - fs-unlink: func(path: string) -> result<_, string>; - - /// Read a file's contents as bytes. - /// - /// Files larger than 10 MB are rejected. Paths are resolved through - /// the VFS layer with sandbox boundary enforcement. - /// Security-gated: requires file-read capability. - read-file: func(path: string) -> result, string>; - - /// Write content to a file (truncating if it exists, creating if not). - /// - /// Security-gated: requires file-write capability. - write-file: func(path: string, content: list) -> result<_, string>; -} - -/// Inter-Process Communication (IPC) event bus. -/// -/// Capsules communicate via a publish/subscribe event bus. Topics are -/// dot-delimited strings (max 8 segments, max 256 bytes). Publishing -/// and subscribing are independently ACL-gated via `ipc_publish` and -/// `ipc_subscribe` patterns in `Capsule.toml [capabilities]`. -/// -/// Provenance is tracked via `IpcMessage::source_id` (the capsule's -/// session UUID), not topic namespacing. -interface ipc { - use types.{ipc-envelope, interceptor-handle}; - - /// Publish a message to a topic. - /// - /// Payload must be valid JSON. Rate-limited per capsule. The principal - /// is automatically propagated from the caller context. - ipc-publish: func(topic: string, payload: string) -> result<_, string>; - - /// Publish a message on behalf of a specific principal. - /// - /// Like `ipc-publish` but stamps the outgoing envelope with the - /// supplied principal instead of the capsule's own — used by - /// uplinks (CLI proxy, Telegram bridge, etc.) to relay messages - /// from external clients while preserving the calling identity - /// for downstream capability checks. Gated on `uplink = true` in - /// the manifest's `[capabilities]` block. The trust model is - /// "trust the uplink": the kernel does not verify that the uplink - /// actually authenticated the claimed principal. Real cross- - /// principal auth lives in issue #658. - ipc-publish-as: func( - topic: string, - payload: string, - principal: string, - ) -> result<_, string>; - - /// Subscribe to a topic pattern and receive a handle. - /// - /// Supports exact matches and trailing-suffix wildcards (`foo.bar.*`). - /// Mid-segment wildcards (`a.*.b`) are rejected. Returns a handle ID - /// for use with `ipc-poll`, `ipc-recv`, and `ipc-unsubscribe`. - /// Max 128 subscriptions per capsule. - ipc-subscribe: func(topic-pattern: string) -> result; - - /// Unsubscribe from a topic by handle ID. - /// - /// Runtime-owned interceptor handles cannot be unsubscribed. - ipc-unsubscribe: func(handle-id: u64) -> result<_, string>; - - /// Non-blocking poll for messages on a subscription. - /// - /// Returns the messages received since last poll, plus drop/lag counts. - ipc-poll: func(handle-id: u64) -> result; - - /// Blocking receive on a subscription with timeout. - /// - /// Blocks the WASM thread until a message arrives, the timeout expires - /// (capped at 60s), or the capsule is unloaded. - ipc-recv: func(handle-id: u64, timeout-ms: u64) -> result; - - /// Get pre-registered interceptor handle mappings for run-loop capsules. - /// - /// Returns a list of interceptor handle objects describing which IPC - /// subscription handles correspond to interceptor actions. - get-interceptor-handles: func() -> result, string>; -} - -/// Uplink communications — inbound message ingestion from external platforms. -/// -/// Capsules register uplinks (named endpoints) for platforms they bridge -/// (e.g. Discord, Slack) and then forward inbound user messages to the -/// kernel's processing pipeline. -interface uplink { - /// Register an uplink endpoint. - /// - /// Arguments: name, platform identifier, profile ("chat", "interactive", - /// "notify", "bridge"). Returns the assigned uplink UUID string. - /// Security-gated: requires uplink-register capability. - uplink-register: func(name: string, platform: string, profile: string) -> result; - - /// Send an inbound message through a registered uplink. - /// - /// Returns true if sent, false if the message was intentionally dropped - /// (e.g. no active session for the principal). - uplink-send: func(uplink-id: string, platform-user-id: string, content: string) -> result; -} - -/// Key-Value persistent storage. -/// -/// Keys are scoped per-principal and per-capsule. Each capsule sees only -/// its own namespace (`wasm:{capsule_id}`), and per-invocation principal -/// scoping ensures different users' data is isolated even when the same -/// capsule serves multiple principals. -interface kv { - /// Read a value by key. - /// - /// Returns the stored bytes, or none if the key does not exist. - kv-get: func(key: string) -> result>, string>; - - /// Write a value by key. - kv-set: func(key: string, value: list) -> result<_, string>; - - /// Delete a key. - kv-delete: func(key: string) -> result<_, string>; - - /// List all keys matching a prefix. - /// - /// Returns a list of matching key strings. - kv-list-keys: func(prefix: string) -> result, string>; - - /// Delete all keys matching a prefix. - /// - /// Returns the count of deleted keys. - kv-clear-prefix: func(prefix: string) -> result; -} - -/// Unix domain socket networking. -/// -/// The kernel pre-binds a single `UnixListener` per capsule. Capsules -/// accept client connections via the provided listener, then read/write -/// length-prefixed frames. Session token handshake authentication is -/// enforced on each accepted connection. -/// -/// Max 8 concurrent streams per capsule. -interface net { - use types.{net-read-status, shutdown-how}; - - /// Bind and activate the pre-provisioned Unix socket listener. - /// - /// Security-gated: requires net-bind capability. The listener handle - /// argument is ignored (single pre-bound listener per capsule). - /// Returns a listener handle. - net-bind-unix: func(listener-handle: u64) -> result; - - /// Blocking accept: wait for the next client connection. - /// - /// Performs peer credential verification (UID match on Unix) and - /// session token handshake. Blocks until a connection arrives or - /// the capsule is unloaded. Returns a stream handle ID. - net-accept: func(listener-handle: u64) -> result; - - /// Non-blocking accept with a 10ms timeout. - /// - /// Returns a stream handle ID if a connection was accepted, - /// or none if no pending connections or the capsule is unloading. - net-poll-accept: func(listener-handle: u64) -> result, string>; - - /// Read the next length-prefixed frame from a stream. - /// - /// Returns Data with payload, Closed if peer disconnected, or - /// Pending if no data available. Max frame size: 10 MB. - net-read: func(stream-handle: u64) -> result; - - /// Write a length-prefixed frame to a stream. - /// - /// Non-fatal on failure (dead stream is cleaned up on next read). - net-write: func(stream-handle: u64, data: list) -> result<_, string>; - - /// Close a stream handle, releasing host-side resources. - /// - /// Idempotent: closing an already-closed handle is a no-op. - /// Publishes a `client.v1.disconnect` IPC event. - net-close-stream: func(stream-handle: u64) -> result<_, string>; - - /// Open an outbound TCP connection to `host:port`. - /// - /// Returns a stream handle compatible with the existing - /// `net-read` / `net-write` / `net-close-stream` functions - /// — the same handle type returned by `net-accept`. - /// - /// Security gates (all checked before any TCP syscall): - /// - The capsule's `net_connect` capability allowlist must - /// contain a pattern matching `host:port`. Patterns are - /// `"host:port"` exact matches or `"host:*"` (any port for - /// the named host). Missing or empty allowlist denies all - /// outbound TCP (fail-closed). - /// - DNS resolution runs after the capability check. The - /// resolved IP is rejected if it falls into a private, - /// loopback, link-local, multicast, or unspecified range, - /// matching the airlock that gates `http-request` / - /// `http-stream-start`. - /// - The capsule's per-instance active-stream cap (default - /// 8, shared with `net-accept`) is enforced. - /// - Connect attempts time out (default 10s) rather than - /// holding the WASM guest in a host fn indefinitely. - net-connect-tcp: func(host: string, port: u16) -> result; - - /// Read up to `max-bytes` from `stream` without length-prefix framing. - /// - /// Mirrors `std::net::TcpStream::read` (and `::read`). - /// Returns the bytes that were available — may be shorter than - /// `max-bytes`. Empty list means no data is ready under the current - /// read timeout (default: non-blocking, ~50 ms internal poll). - /// - /// Use this for byte-stream protocols (WebSocket, MQTT, postgres, - /// HTTP/1.1, TLS, …). The existing `net-read` keeps the length-prefix - /// framing required by the uplink-proxy use case. - net-read-bytes: func(stream-handle: u64, max-bytes: u32) -> result, string>; - - /// Write `data` to `stream` without length-prefix framing. - /// - /// Mirrors `::write`. Returns the number of - /// bytes actually written — may be less than `data.len()` when the - /// kernel-side socket buffer is full. Callers loop until `data` is - /// drained (the SDK `write_all` wrapper handles this). - net-write-bytes: func(stream-handle: u64, data: list) -> result; - - /// Read up to `max-bytes` from `stream` without consuming them. - /// - /// Mirrors `std::net::TcpStream::peek`. Subsequent `net-read-bytes` - /// returns the same bytes again. Useful for protocol detection on - /// the first frame of a connection. - net-peek: func(stream-handle: u64, max-bytes: u32) -> result, string>; - - /// Shut down the read side, write side, or both halves of `stream`. - /// - /// Mirrors `std::net::TcpStream::shutdown`. After `shutdown-how::write` - /// the peer sees EOF on its read side; further sends on the local - /// side return an error. `shutdown-how::both` closes both directions - /// but leaves the handle entry intact so accessors (e.g. - /// `peer-addr`) still work until `net-close-stream` is called. - net-shutdown: func(stream-handle: u64, how: shutdown-how) -> result<_, string>; - - /// Return the remote peer address as `"ip:port"`. - /// - /// Mirrors `std::net::TcpStream::peer_addr`. Returns an error for - /// Unix-domain stream handles. - net-peer-addr: func(stream-handle: u64) -> result; - - /// Return the local socket address as `"ip:port"`. - /// - /// Mirrors `std::net::TcpStream::local_addr`. Returns an error for - /// Unix-domain stream handles. - net-local-addr: func(stream-handle: u64) -> result; - - /// Enable or disable the `TCP_NODELAY` option (Nagle's algorithm off - /// when `true`). - /// - /// Mirrors `std::net::TcpStream::set_nodelay`. Returns an error for - /// Unix-domain stream handles. - net-set-nodelay: func(stream-handle: u64, nodelay: bool) -> result<_, string>; - - /// Return the current `TCP_NODELAY` setting. - /// - /// Mirrors `std::net::TcpStream::nodelay`. - net-nodelay: func(stream-handle: u64) -> result; - - /// Set the read timeout. `none` clears the timeout (reads still - /// honour the host's internal cancellation token; this controls - /// only the user-visible blocking duration of each `net-read-bytes` - /// call). - /// - /// Mirrors `std::net::TcpStream::set_read_timeout`. Subsequent - /// `net-read-bytes` and `net-peek` calls honour the new value. - net-set-read-timeout: func(stream-handle: u64, timeout-ms: option) -> result<_, string>; - - /// Return the current read timeout in milliseconds, or `none` if - /// unset. - net-read-timeout: func(stream-handle: u64) -> result, string>; - - /// Set the write timeout. `none` clears it. - /// - /// Mirrors `std::net::TcpStream::set_write_timeout`. - net-set-write-timeout: func(stream-handle: u64, timeout-ms: option) -> result<_, string>; - - /// Return the current write timeout in milliseconds, or `none` if - /// unset. - net-write-timeout: func(stream-handle: u64) -> result, string>; - - /// Set the IP `TTL` field on outgoing packets. - /// - /// Mirrors `std::net::TcpStream::set_ttl`. Returns an error for - /// Unix-domain stream handles. - net-set-ttl: func(stream-handle: u64, ttl: u32) -> result<_, string>; - - /// Return the current IP TTL. - /// - /// Mirrors `std::net::TcpStream::ttl`. - net-ttl: func(stream-handle: u64) -> result; -} - -/// HTTP client operations with SSRF protection. -/// -/// DNS resolution blocks connections to private, loopback, link-local, -/// and multicast IP addresses. IPv4-mapped and IPv4-compatible IPv6 -/// addresses are also checked. The security gate enforces per-capsule -/// allow-lists and rate limits. -interface http { - use types.{http-request-data, http-response-data, http-stream-start-response}; - - /// Perform a buffered HTTP request (full response in memory). - /// - /// 30s timeout, max response body 10 MB. - /// Security-gated: requires http capability. - http-request: func(request: http-request-data) -> result; - - /// Start a streaming HTTP request (headers returned, body streamed). - /// - /// Max 4 concurrent HTTP streams per capsule. - /// Security-gated: requires http capability. - http-stream-start: func(request: http-request-data) -> result; - - /// Read the next chunk from a streaming HTTP response. - /// - /// Returns the chunk bytes, or an empty list on EOF. - /// 120s per-chunk timeout. - http-stream-read: func(stream-handle: u64) -> result, string>; - - /// Close a streaming HTTP response handle. - /// - /// Idempotent: closing an already-closed handle is a no-op. - http-stream-close: func(stream-handle: u64) -> result<_, string>; -} - -/// System-level runtime functions. -interface sys { - use types.{log-level, caller-context, capability-check-request, capability-check-response}; - - /// Read a configuration value from the capsule's manifest `[config]`. - /// - /// Returns the raw config value string, or empty string if not set. - /// String values are returned without JSON-encoding (no extra quotes). - get-config: func(key: string) -> result; - - /// Get the caller context for the current invocation. - /// - /// Returns the acting principal, originating capsule UUID, and message - /// timestamp. Returns an empty context if no caller is available. - get-caller: func() -> result; - - /// Trigger a hook/interceptor fan-out to other capsules. - /// - /// Input is a JSON request: `{"hook": "topic", "payload": {...}}`. - /// Finds all capsules with interceptors matching the hook topic, - /// invokes each (skipping the caller to prevent recursion), and - /// returns a JSON array of their responses. - trigger-hook: func(request-json: string) -> result; - - /// Emit a structured log message attributed to the calling capsule. - /// - /// Logs are routed to the current principal's log directory with - /// daily rotation. Cross-principal invocations write to the target - /// principal's log directory. - log: func(level: log-level, message: string); - - /// Signal that the capsule's run loop is ready. - /// - /// Called by the WASM guest after setting up IPC subscriptions. - /// Notifies the kernel to proceed with loading dependent capsules. - /// No-op if no readiness channel is configured. - signal-ready: func(); - - /// Get the current wall-clock time as milliseconds since UNIX epoch. - clock-ms: func() -> u64; - - /// Check whether a capsule has a specific manifest capability. - /// - /// Fail-closed: returns `allowed: false` for unknown UUIDs, - /// unknown capabilities, or unavailable registry. - check-capsule-capability: func(request: capability-check-request) -> result; -} - -/// Host-side process spawning with OS-level sandboxing. -/// -/// Commands are wrapped in platform-specific sandbox tools -/// (`sandbox-exec` on macOS, `bwrap` on Linux) scoped to the -/// workspace directory. All processes are tracked for cancellation. -/// Security-gated: requires host-process capability. -interface process { - use types.{spawn-request, process-result, spawn-background-result, read-logs-result, kill-process-result}; - - /// Spawn a synchronous (blocking) process. - /// - /// Blocks the WASM thread until the process exits or is cancelled. - /// Cancelled processes return exit_code -1. - spawn: func(request: spawn-request) -> result; - - /// Spawn a background (non-blocking) process. - /// - /// Returns a process handle for subsequent log/kill calls. - /// Max 8 concurrent background processes per capsule. - /// stdout/stderr are buffered (1 MB per stream, ring buffer). - spawn-background: func(request: spawn-request) -> result; - - /// Read buffered logs from a background process. - /// - /// Drains the buffers (subsequent reads return only new data). - read-logs: func(process-id: u64) -> result; - - /// Kill a background process. - /// - /// Sends SIGKILL to the process group (Unix) or kill (Windows). - kill: func(process-id: u64) -> result; -} - -/// Interactive user input collection during lifecycle hooks. -/// -/// Only callable during `#[install]` or `#[upgrade]` lifecycle phases. -/// Blocks the WASM thread until the frontend (TUI/CLI) collects user -/// input and publishes a response, or the request times out (120s). -interface elicit { - use types.{elicit-request}; - - /// Prompt the user for input (text, secret, select, or array). - /// - /// Returns a JSON response depending on the request type: - /// text/select: `{"value": "..."}`, - /// secret: `{"ok": true}` (value stored in SecretStore), - /// array: `{"values": [...]}`. - elicit: func(request: elicit-request) -> result; - - /// Check whether a secret key has been stored for this capsule. - /// - /// Uses the SecretStore abstraction (OS keychain with KV fallback). - has-secret: func(key: string) -> result; -} - -/// Human-in-the-loop approval for sensitive actions. -/// -/// Checks the AllowanceStore first (instant path for pre-approved -/// patterns), then publishes an ApprovalRequired IPC event and blocks -/// until the frontend user responds or the request times out (60s). -interface approval { - use types.{approval-request, approval-response}; - - /// Request human approval for a sensitive action. - /// - /// Decision values: "approve" (once), "approve_session", "approve_always", - /// "deny", "allowance" (auto-granted via existing allowance). - /// Action strings are sanitized (control chars stripped, max 256 chars). - /// Resource strings are sanitized (max 1024 chars). - request-approval: func(request: approval-request) -> result; -} - -/// Multi-platform identity resolution and linking. -/// -/// Maps external platform identities (Discord user, GitHub user, etc.) -/// to internal Astrid user IDs. All operations are security-gated per -/// operation type (resolve, link, unlink, create-user, list-links). -interface identity { - use types.{ - identity-resolve-request, identity-resolve-response, - identity-link-request, identity-unlink-request, - identity-create-user-request, identity-list-links-request, - identity-ok-response, - }; - - /// Resolve a platform identity to an Astrid user. - identity-resolve: func(request: identity-resolve-request) -> result; - - /// Link a platform identity to an Astrid user. - identity-link: func(request: identity-link-request) -> result; - - /// Unlink a platform identity. - identity-unlink: func(request: identity-unlink-request) -> result; - - /// Create a new Astrid user. - /// - /// Returns an ok-response; the created user ID is available in the - /// response JSON when accessed via the SDK. - identity-create-user: func(request: identity-create-user-request) -> result; - - /// List all platform links for an Astrid user. - /// - /// Returns an ok-response; the links are available in the - /// response JSON when accessed via the SDK. - identity-list-links: func(request: identity-list-links-request) -> result; -} - -// --------------------------------------------------------------------------- -// Capsule world — the full composed contract -// --------------------------------------------------------------------------- - -/// The capsule world — the full composed contract. -/// -/// A conforming capsule imports the domain-specific host interfaces and -/// exports the guest entry-point functions below. -world capsule { - use types.{capsule-result}; - - // Host-provided imports (49 functions across 11 interfaces). - import fs; - import ipc; - import uplink; - import kv; - import net; - import http; - import sys; - import process; - import elicit; - import approval; - import identity; - - // ── Guest exports ────────────────────────────────────────────── - - /// Execute an interceptor hook. - /// - /// This is the primary entry point for hook/interceptor execution. - /// The kernel passes the action name (interceptor handler to invoke) - /// and the event payload as raw bytes. The guest returns a result - /// directing the kernel how to proceed. - export astrid-hook-trigger: func(action: string, payload: list) -> capsule-result; - - /// Background run loop for long-lived capsules. - /// - /// Capsules that export `run` are started as background tasks. - /// The function should block indefinitely (event loop pattern), - /// processing IPC messages via subscriptions set up before - /// calling `signal-ready`. The kernel manages the lifecycle. - export run: func(); - - /// Lifecycle hook: first-time installation. - /// - /// Called once when a capsule is installed. May use `elicit` to - /// collect secrets and configuration from the user interactively. - /// Optional — capsules without this export skip the install phase. - export astrid-install: func(); - - /// Lifecycle hook: upgrade from a previous version. - /// - /// Called when a capsule is upgraded. May use `elicit` to collect - /// new configuration. Optional — capsules without this export - /// skip the upgrade phase. - export astrid-upgrade: func(); -} diff --git a/host/elicit@1.0.0.wit b/host/elicit@1.0.0.wit new file mode 100644 index 0000000..c4182eb --- /dev/null +++ b/host/elicit@1.0.0.wit @@ -0,0 +1,39 @@ +/// Interactive user input collection during lifecycle hooks. +/// +/// Only callable during `#[install]` or `#[upgrade]` lifecycle phases. +/// Blocks the WASM thread until the frontend (TUI/CLI) collects user +/// input and publishes a response, or the request times out (120s). +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:elicit@1.0.0; + +interface host { + /// Request for user input during capsule lifecycle. + record elicit-request { + /// Input type: "text", "secret", "select", or "array". + elicit-type: string, + /// Key for storing the collected value. + key: string, + /// Human-readable prompt description. + description: string, + /// Options for select-type inputs. + options: option>, + /// Default value. + default-value: option, + } + + /// Prompt the user for input (text, secret, select, or array). + /// + /// Returns a JSON response depending on the request type: + /// text/select: `{"value": "..."}`, + /// secret: `{"ok": true}` (value stored in SecretStore), + /// array: `{"values": [...]}`. + elicit: func(request: elicit-request) -> result; + + /// Check whether a secret key has been stored for this capsule. + /// + /// Uses the SecretStore abstraction (OS keychain with KV fallback). + has-secret: func(key: string) -> result; +} diff --git a/host/fs@1.0.0.wit b/host/fs@1.0.0.wit new file mode 100644 index 0000000..2b356b6 --- /dev/null +++ b/host/fs@1.0.0.wit @@ -0,0 +1,62 @@ +/// Filesystem operations within the capsule's workspace boundary. +/// +/// All paths are resolved relative to the workspace root. The `home://` +/// URI scheme accesses the principal's home directory. Paths under `/tmp/` +/// access per-principal temporary storage. Attempts to escape any boundary +/// return an error. Symlink traversal is canonicalized to prevent bypass. +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:fs@1.0.0; + +interface host { + /// File metadata returned by `fs-stat`. + record file-stat { + /// File size in bytes. + size: u64, + /// Whether this entry is a directory. + is-dir: bool, + /// Last modification time (UNIX epoch seconds), if available. + mtime: option, + } + + /// Check whether a file or directory exists at the given path. + /// + /// Returns true if the path exists, false otherwise. + /// Security-gated: requires file-read capability. + fs-exists: func(path: string) -> result; + + /// Create a directory (and parents) at the given path. + /// + /// Security-gated: requires file-write capability. + fs-mkdir: func(path: string) -> result<_, string>; + + /// List entries in a directory. + /// + /// Returns a list of entry name strings. + /// Security-gated: requires file-read capability. + fs-readdir: func(path: string) -> result, string>; + + /// Get file metadata (size, type, mtime). + /// + /// Security-gated: requires file-read capability. + fs-stat: func(path: string) -> result; + + /// Remove a file at the given path. + /// + /// Security-gated: requires file-write capability. + fs-unlink: func(path: string) -> result<_, string>; + + /// Read a file's contents as bytes. + /// + /// Files larger than 10 MB are rejected. Paths are resolved through + /// the VFS layer with sandbox boundary enforcement. + /// Security-gated: requires file-read capability. + read-file: func(path: string) -> result, string>; + + /// Write content to a file (truncating if it exists, creating if not). + /// + /// Security-gated: requires file-write capability. + write-file: func(path: string, content: list) -> result<_, string>; +} diff --git a/host/guest@1.0.0.wit b/host/guest@1.0.0.wit new file mode 100644 index 0000000..051e457 --- /dev/null +++ b/host/guest@1.0.0.wit @@ -0,0 +1,67 @@ +/// Guest export contract — lifecycle and interceptor entry points the +/// kernel calls into a capsule. +/// +/// A capsule that handles interceptor hooks, runs a background loop, or +/// participates in install/upgrade lifecycle phases includes the `exports` +/// world below in its own world declaration. Each export is optional at +/// the kernel level — the kernel introspects which exports the capsule +/// actually provides and only calls those. +/// +/// The package is named `astrid:guest` to mirror the per-domain `host` +/// packages: `host` interfaces are kernel-side (imported by capsules), +/// `guest` exports are capsule-side (called by the kernel). Note the +/// distinct `astrid:hook@1.0.0` in `interfaces/hook.wit` — that one is a +/// capsule-to-capsule IPC contract for lifecycle fan-out, not a guest +/// export contract. +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:guest@1.0.0; + +interface lifecycle { + /// Result returned by a capsule after hook execution. + record capsule-result { + /// Action directive (e.g. "continue", "abort", "modify"). + action: string, + /// Optional payload as a JSON string. + data: option, + } +} + +/// Guest exports a capsule provides to the kernel. +/// +/// Capsule worlds `include astrid:guest/exports@1.0.0` to declare these +/// entry points. The kernel calls only the exports a capsule actually +/// implements; unimplemented exports are skipped without error. +world exports { + use lifecycle.{capsule-result}; + + /// Execute an interceptor hook. + /// + /// The primary entry point for hook/interceptor execution. The kernel + /// passes the action name (interceptor handler to invoke) and the event + /// payload as raw bytes. The guest returns a result directing the kernel + /// how to proceed. + export astrid-hook-trigger: func(action: string, payload: list) -> capsule-result; + + /// Background run loop for long-lived capsules. + /// + /// Capsules that export `run` are started as background tasks. The + /// function should block indefinitely (event loop pattern), processing + /// IPC messages via subscriptions set up before calling `signal-ready`. + /// The kernel manages the lifecycle. + export run: func(); + + /// Lifecycle hook: first-time installation. + /// + /// Called once when a capsule is installed. May use `elicit` to collect + /// secrets and configuration from the user interactively. + export astrid-install: func(); + + /// Lifecycle hook: upgrade from a previous version. + /// + /// Called when a capsule is upgraded. May use `elicit` to collect new + /// configuration. + export astrid-upgrade: func(); +} diff --git a/host/http@1.0.0.wit b/host/http@1.0.0.wit new file mode 100644 index 0000000..17bdab4 --- /dev/null +++ b/host/http@1.0.0.wit @@ -0,0 +1,74 @@ +/// HTTP client operations with SSRF protection. +/// +/// DNS resolution blocks connections to private, loopback, link-local, +/// and multicast IP addresses. IPv4-mapped and IPv4-compatible IPv6 +/// addresses are also checked. The security gate enforces per-capsule +/// allow-lists and rate limits. +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:http@1.0.0; + +interface host { + /// A key-value pair used for typed header lists. + record key-value-pair { + key: string, + value: string, + } + + /// HTTP request sent by a capsule to the host. + record http-request-data { + /// Target URL. + url: string, + /// HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS). + method: string, + /// Request headers as key-value pairs. + headers: list, + /// Optional request body. + body: option, + } + + /// HTTP response returned by the host. + record http-response-data { + /// HTTP status code. + status: u16, + /// Response headers. + headers: list, + /// Response body as raw bytes (may contain non-UTF-8 binary data). + body: list, + } + + /// HTTP streaming response metadata returned on stream start. + record http-stream-start-response { + /// Opaque handle for subsequent read/close calls. + handle: u64, + /// HTTP status code. + status: u16, + /// Response headers. + headers: list, + } + + /// Perform a buffered HTTP request (full response in memory). + /// + /// 30s timeout, max response body 10 MB. + /// Security-gated: requires http capability. + http-request: func(request: http-request-data) -> result; + + /// Start a streaming HTTP request (headers returned, body streamed). + /// + /// Max 4 concurrent HTTP streams per capsule. + /// Security-gated: requires http capability. + http-stream-start: func(request: http-request-data) -> result; + + /// Read the next chunk from a streaming HTTP response. + /// + /// Returns the chunk bytes, or an empty list on EOF. + /// 120s per-chunk timeout. + http-stream-read: func(stream-handle: u64) -> result, string>; + + /// Close a streaming HTTP response handle. + /// + /// Idempotent: closing an already-closed handle is a no-op. + http-stream-close: func(stream-handle: u64) -> result<_, string>; +} diff --git a/host/identity@1.0.0.wit b/host/identity@1.0.0.wit new file mode 100644 index 0000000..9713116 --- /dev/null +++ b/host/identity@1.0.0.wit @@ -0,0 +1,99 @@ +/// Multi-platform identity resolution and linking. +/// +/// Maps external platform identities (Discord user, GitHub user, etc.) +/// to internal Astrid user IDs. All operations are security-gated per +/// operation type (resolve, link, unlink, create-user, list-links). +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:identity@1.0.0; + +interface host { + /// Request to resolve a platform identity to an Astrid user. + record identity-resolve-request { + /// External platform (e.g. "discord", "github"). + platform: string, + /// User ID on the external platform. + platform-user-id: string, + } + + /// Result of an identity resolve operation. + record identity-resolve-response { + /// Whether a matching user was found. + found: bool, + /// The Astrid user ID, if found. + user-id: option, + /// The user's display name, if available. + display-name: option, + /// Error message, if the operation failed. + error: option, + } + + /// Request to link a platform identity to an Astrid user. + record identity-link-request { + /// External platform (e.g. "discord", "github"). + platform: string, + /// User ID on the external platform. + platform-user-id: string, + /// Internal Astrid user ID to link to. + astrid-user-id: string, + /// Authentication method (e.g. "passkey", "token"). + method: string, + } + + /// Request to unlink a platform identity. + record identity-unlink-request { + /// External platform. + platform: string, + /// User ID on the external platform. + platform-user-id: string, + } + + /// Request to create a new Astrid user. + record identity-create-user-request { + /// Optional display name for the new user. + display-name: option, + } + + /// Request to list platform links for an Astrid user. + record identity-list-links-request { + /// Internal Astrid user ID. + astrid-user-id: string, + } + + /// Result of an identity mutation (link, unlink, create-user, list-links). + record identity-ok-response { + /// Whether the operation succeeded. + ok: bool, + /// Error message, if the operation failed. + error: option, + /// Created user ID (set by create-user on success). + user-id: option, + /// Whether a link was removed (set by unlink on success). + removed: option, + /// JSON-encoded list of platform links (set by list-links on success). + links-json: option, + } + + /// Resolve a platform identity to an Astrid user. + identity-resolve: func(request: identity-resolve-request) -> result; + + /// Link a platform identity to an Astrid user. + identity-link: func(request: identity-link-request) -> result; + + /// Unlink a platform identity. + identity-unlink: func(request: identity-unlink-request) -> result; + + /// Create a new Astrid user. + /// + /// Returns an ok-response; the created user ID is available in the + /// response JSON when accessed via the SDK. + identity-create-user: func(request: identity-create-user-request) -> result; + + /// List all platform links for an Astrid user. + /// + /// Returns an ok-response; the links are available in the + /// response JSON when accessed via the SDK. + identity-list-links: func(request: identity-list-links-request) -> result; +} diff --git a/host/ipc@1.0.0.wit b/host/ipc@1.0.0.wit new file mode 100644 index 0000000..195b255 --- /dev/null +++ b/host/ipc@1.0.0.wit @@ -0,0 +1,113 @@ +/// Inter-Process Communication (IPC) event bus. +/// +/// Capsules communicate via a publish/subscribe event bus. Topics are +/// dot-delimited strings (max 8 segments, max 256 bytes). Publishing +/// and subscribing are independently ACL-gated via `ipc_publish` and +/// `ipc_subscribe` patterns in `Capsule.toml [capabilities]`. +/// +/// Provenance is tracked via `ipc-message.source-id` (the capsule's +/// session UUID), not topic namespacing. +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:ipc@1.0.0; + +interface host { + /// IPC message envelope returned by `ipc-poll` and `ipc-recv`. + record ipc-envelope { + /// List of IPC messages received since last poll/recv. + messages: list, + /// Number of messages dropped due to buffer overflow. + dropped: u64, + /// Cumulative lag (messages missed due to slow consumption). + lagged: u64, + } + + /// A single IPC message. + record ipc-message { + /// Topic the message was published on. + topic: string, + /// Message payload as JSON. + payload: string, + /// UUID of the capsule that sent this message. + source-id: string, + /// Principal attributed to the publisher of this message. + /// + /// For messages published via `ipc-publish`, this is the publishing + /// capsule's invocation principal (whoever the host attributed the + /// call to). For messages published via `ipc-publish-as`, this is + /// the principal the uplink claimed on behalf of an external caller. + /// + /// `none` for system / kernel-originated events that have no + /// attributable principal, and for legacy messages that predate + /// this field. Subscribers processing multi-message recv batches + /// should read this per-message rather than relying on the + /// invocation context (which only reflects the first message's + /// publisher). + principal: option, + } + + /// Pre-registered interceptor handle mapping. + record interceptor-handle { + /// Subscription handle ID. + handle-id: u64, + /// Interceptor action name. + action: string, + /// IPC topic pattern this handle is subscribed to. + topic: string, + } + + /// Publish a message to a topic. + /// + /// Payload must be valid JSON. Rate-limited per capsule. The principal + /// is automatically propagated from the caller context. + ipc-publish: func(topic: string, payload: string) -> result<_, string>; + + /// Publish a message on behalf of a specific principal. + /// + /// Like `ipc-publish` but stamps the outgoing envelope with the + /// supplied principal instead of the capsule's own — used by + /// uplinks (CLI proxy, Telegram bridge, etc.) to relay messages + /// from external clients while preserving the calling identity + /// for downstream capability checks. Gated on `uplink = true` in + /// the manifest's `[capabilities]` block. The trust model is + /// "trust the uplink": the kernel does not verify that the uplink + /// actually authenticated the claimed principal. Real cross- + /// principal auth lives in issue #658. + ipc-publish-as: func( + topic: string, + payload: string, + principal: string, + ) -> result<_, string>; + + /// Subscribe to a topic pattern and receive a handle. + /// + /// Supports exact matches and trailing-suffix wildcards (`foo.bar.*`). + /// Mid-segment wildcards (`a.*.b`) are rejected. Returns a handle ID + /// for use with `ipc-poll`, `ipc-recv`, and `ipc-unsubscribe`. + /// Max 128 subscriptions per capsule. + ipc-subscribe: func(topic-pattern: string) -> result; + + /// Unsubscribe from a topic by handle ID. + /// + /// Runtime-owned interceptor handles cannot be unsubscribed. + ipc-unsubscribe: func(handle-id: u64) -> result<_, string>; + + /// Non-blocking poll for messages on a subscription. + /// + /// Returns the messages received since last poll, plus drop/lag counts. + ipc-poll: func(handle-id: u64) -> result; + + /// Blocking receive on a subscription with timeout. + /// + /// Blocks the WASM thread until a message arrives, the timeout expires + /// (capped at 60s), or the capsule is unloaded. + ipc-recv: func(handle-id: u64, timeout-ms: u64) -> result; + + /// Get pre-registered interceptor handle mappings for run-loop capsules. + /// + /// Returns a list of interceptor handle objects describing which IPC + /// subscription handles correspond to interceptor actions. + get-interceptor-handles: func() -> result, string>; +} diff --git a/host/kv@1.0.0.wit b/host/kv@1.0.0.wit new file mode 100644 index 0000000..974c158 --- /dev/null +++ b/host/kv@1.0.0.wit @@ -0,0 +1,34 @@ +/// Key-Value persistent storage. +/// +/// Keys are scoped per-principal and per-capsule. Each capsule sees only +/// its own namespace (`wasm:{capsule_id}`), and per-invocation principal +/// scoping ensures different users' data is isolated even when the same +/// capsule serves multiple principals. +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:kv@1.0.0; + +interface host { + /// Read a value by key. + /// + /// Returns the stored bytes, or none if the key does not exist. + kv-get: func(key: string) -> result>, string>; + + /// Write a value by key. + kv-set: func(key: string, value: list) -> result<_, string>; + + /// Delete a key. + kv-delete: func(key: string) -> result<_, string>; + + /// List all keys matching a prefix. + /// + /// Returns a list of matching key strings. + kv-list-keys: func(prefix: string) -> result, string>; + + /// Delete all keys matching a prefix. + /// + /// Returns the count of deleted keys. + kv-clear-prefix: func(prefix: string) -> result; +} diff --git a/host/net@1.0.0.wit b/host/net@1.0.0.wit new file mode 100644 index 0000000..ec2ac3a --- /dev/null +++ b/host/net@1.0.0.wit @@ -0,0 +1,193 @@ +/// Networking — Unix domain sockets and outbound TCP. +/// +/// The kernel pre-binds a single `UnixListener` per capsule. Capsules +/// accept client connections via the provided listener, then read/write +/// length-prefixed frames. Session token handshake authentication is +/// enforced on each accepted connection. Outbound TCP connect is gated +/// by `net_connect` allowlists and DNS airlock checks. +/// +/// Max 8 concurrent streams per capsule. +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:net@1.0.0; + +interface host { + /// Status of a network read operation. + variant net-read-status { + /// Data frame received. + data(list), + /// Stream closed by peer. + closed, + /// No data available (non-blocking). + pending, + } + + /// Direction argument for `net-shutdown` — mirrors `std::net::Shutdown`. + enum shutdown-how { + /// Half-close the read side. Subsequent reads return EOF; writes + /// continue. + read, + /// Half-close the write side. The peer sees EOF on its read side; + /// reads continue. + write, + /// Close both directions. Equivalent to `net-close-stream` except + /// the handle entry is retained so getters (`peer-addr`, etc.) + /// still work until the explicit close call. + both, + } + + /// Bind and activate the pre-provisioned Unix socket listener. + /// + /// Security-gated: requires net-bind capability. The listener handle + /// argument is ignored (single pre-bound listener per capsule). + /// Returns a listener handle. + net-bind-unix: func(listener-handle: u64) -> result; + + /// Blocking accept: wait for the next client connection. + /// + /// Performs peer credential verification (UID match on Unix) and + /// session token handshake. Blocks until a connection arrives or + /// the capsule is unloaded. Returns a stream handle ID. + net-accept: func(listener-handle: u64) -> result; + + /// Non-blocking accept with a 10ms timeout. + /// + /// Returns a stream handle ID if a connection was accepted, + /// or none if no pending connections or the capsule is unloading. + net-poll-accept: func(listener-handle: u64) -> result, string>; + + /// Read the next length-prefixed frame from a stream. + /// + /// Returns Data with payload, Closed if peer disconnected, or + /// Pending if no data available. Max frame size: 10 MB. + net-read: func(stream-handle: u64) -> result; + + /// Write a length-prefixed frame to a stream. + /// + /// Non-fatal on failure (dead stream is cleaned up on next read). + net-write: func(stream-handle: u64, data: list) -> result<_, string>; + + /// Close a stream handle, releasing host-side resources. + /// + /// Idempotent: closing an already-closed handle is a no-op. + /// Publishes a `client.v1.disconnect` IPC event. + net-close-stream: func(stream-handle: u64) -> result<_, string>; + + /// Open an outbound TCP connection to `host:port`. + /// + /// Returns a stream handle compatible with the existing + /// `net-read` / `net-write` / `net-close-stream` functions + /// — the same handle type returned by `net-accept`. + /// + /// Security gates (all checked before any TCP syscall): + /// - The capsule's `net_connect` capability allowlist must + /// contain a pattern matching `host:port`. Patterns are + /// `"host:port"` exact matches or `"host:*"` (any port for + /// the named host). Missing or empty allowlist denies all + /// outbound TCP (fail-closed). + /// - DNS resolution runs after the capability check. The + /// resolved IP is rejected if it falls into a private, + /// loopback, link-local, multicast, or unspecified range, + /// matching the airlock that gates `http-request` / + /// `http-stream-start`. + /// - The capsule's per-instance active-stream cap (default + /// 8, shared with `net-accept`) is enforced. + /// - Connect attempts time out (default 10s) rather than + /// holding the WASM guest in a host fn indefinitely. + net-connect-tcp: func(host: string, port: u16) -> result; + + /// Read up to `max-bytes` from `stream` without length-prefix framing. + /// + /// Mirrors `std::net::TcpStream::read` (and `::read`). + /// Returns the bytes that were available — may be shorter than + /// `max-bytes`. Empty list means no data is ready under the current + /// read timeout (default: non-blocking, ~50 ms internal poll). + /// + /// Use this for byte-stream protocols (WebSocket, MQTT, postgres, + /// HTTP/1.1, TLS, …). The existing `net-read` keeps the length-prefix + /// framing required by the uplink-proxy use case. + net-read-bytes: func(stream-handle: u64, max-bytes: u32) -> result, string>; + + /// Write `data` to `stream` without length-prefix framing. + /// + /// Mirrors `::write`. Returns the number of + /// bytes actually written — may be less than `data.len()` when the + /// kernel-side socket buffer is full. Callers loop until `data` is + /// drained (the SDK `write_all` wrapper handles this). + net-write-bytes: func(stream-handle: u64, data: list) -> result; + + /// Read up to `max-bytes` from `stream` without consuming them. + /// + /// Mirrors `std::net::TcpStream::peek`. Subsequent `net-read-bytes` + /// returns the same bytes again. Useful for protocol detection on + /// the first frame of a connection. + net-peek: func(stream-handle: u64, max-bytes: u32) -> result, string>; + + /// Shut down the read side, write side, or both halves of `stream`. + /// + /// Mirrors `std::net::TcpStream::shutdown`. After `shutdown-how::write` + /// the peer sees EOF on its read side; further sends on the local + /// side return an error. `shutdown-how::both` closes both directions + /// but leaves the handle entry intact so accessors (e.g. + /// `peer-addr`) still work until `net-close-stream` is called. + net-shutdown: func(stream-handle: u64, how: shutdown-how) -> result<_, string>; + + /// Return the remote peer address as `"ip:port"`. + /// + /// Mirrors `std::net::TcpStream::peer_addr`. Returns an error for + /// Unix-domain stream handles. + net-peer-addr: func(stream-handle: u64) -> result; + + /// Return the local socket address as `"ip:port"`. + /// + /// Mirrors `std::net::TcpStream::local_addr`. Returns an error for + /// Unix-domain stream handles. + net-local-addr: func(stream-handle: u64) -> result; + + /// Enable or disable the `TCP_NODELAY` option (Nagle's algorithm off + /// when `true`). + /// + /// Mirrors `std::net::TcpStream::set_nodelay`. Returns an error for + /// Unix-domain stream handles. + net-set-nodelay: func(stream-handle: u64, nodelay: bool) -> result<_, string>; + + /// Return the current `TCP_NODELAY` setting. + /// + /// Mirrors `std::net::TcpStream::nodelay`. + net-nodelay: func(stream-handle: u64) -> result; + + /// Set the read timeout. `none` clears the timeout (reads still + /// honour the host's internal cancellation token; this controls + /// only the user-visible blocking duration of each `net-read-bytes` + /// call). + /// + /// Mirrors `std::net::TcpStream::set_read_timeout`. Subsequent + /// `net-read-bytes` and `net-peek` calls honour the new value. + net-set-read-timeout: func(stream-handle: u64, timeout-ms: option) -> result<_, string>; + + /// Return the current read timeout in milliseconds, or `none` if + /// unset. + net-read-timeout: func(stream-handle: u64) -> result, string>; + + /// Set the write timeout. `none` clears it. + /// + /// Mirrors `std::net::TcpStream::set_write_timeout`. + net-set-write-timeout: func(stream-handle: u64, timeout-ms: option) -> result<_, string>; + + /// Return the current write timeout in milliseconds, or `none` if + /// unset. + net-write-timeout: func(stream-handle: u64) -> result, string>; + + /// Set the IP `TTL` field on outgoing packets. + /// + /// Mirrors `std::net::TcpStream::set_ttl`. Returns an error for + /// Unix-domain stream handles. + net-set-ttl: func(stream-handle: u64, ttl: u32) -> result<_, string>; + + /// Return the current IP TTL. + /// + /// Mirrors `std::net::TcpStream::ttl`. + net-ttl: func(stream-handle: u64) -> result; +} diff --git a/host/process@1.0.0.wit b/host/process@1.0.0.wit new file mode 100644 index 0000000..32b731b --- /dev/null +++ b/host/process@1.0.0.wit @@ -0,0 +1,84 @@ +/// Host-side process spawning with OS-level sandboxing. +/// +/// Commands are wrapped in platform-specific sandbox tools +/// (`sandbox-exec` on macOS, `bwrap` on Linux) scoped to the +/// workspace directory. All processes are tracked for cancellation. +/// Security-gated: requires host-process capability. +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:process@1.0.0; + +interface host { + /// Request to spawn a host process. + record spawn-request { + /// Command to execute. + cmd: string, + /// Command arguments. + args: list, + } + + /// Result of a synchronous process execution. + record process-result { + /// Captured standard output. + stdout: string, + /// Captured standard error. + stderr: string, + /// Process exit code (-1 if killed or unknown). + exit-code: s32, + } + + /// Result of spawning a background process. + record spawn-background-result { + /// Opaque process handle for subsequent log/kill calls. + id: u64, + } + + /// Logs and status from a background process. + record read-logs-result { + /// Buffered stdout since last read. + stdout: string, + /// Buffered stderr since last read. + stderr: string, + /// Whether the process is still running. + running: bool, + /// Exit code if the process has terminated. + exit-code: option, + } + + /// Result of killing a background process. + record kill-process-result { + /// Whether the process was successfully killed. + killed: bool, + /// Exit code if available. + exit-code: option, + /// Final buffered stdout. + stdout: string, + /// Final buffered stderr. + stderr: string, + } + + /// Spawn a synchronous (blocking) process. + /// + /// Blocks the WASM thread until the process exits or is cancelled. + /// Cancelled processes return exit_code -1. + spawn: func(request: spawn-request) -> result; + + /// Spawn a background (non-blocking) process. + /// + /// Returns a process handle for subsequent log/kill calls. + /// Max 8 concurrent background processes per capsule. + /// stdout/stderr are buffered (1 MB per stream, ring buffer). + spawn-background: func(request: spawn-request) -> result; + + /// Read buffered logs from a background process. + /// + /// Drains the buffers (subsequent reads return only new data). + read-logs: func(process-id: u64) -> result; + + /// Kill a background process. + /// + /// Sends SIGKILL to the process group (Unix) or kill (Windows). + kill: func(process-id: u64) -> result; +} diff --git a/host/sys@1.0.0.wit b/host/sys@1.0.0.wit new file mode 100644 index 0000000..6fdd4ac --- /dev/null +++ b/host/sys@1.0.0.wit @@ -0,0 +1,85 @@ +/// System-level runtime functions: logging, config, time, caller context, +/// hook fan-out, capability introspection. +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:sys@1.0.0; + +interface host { + /// Log severity level for structured capsule logging. + enum log-level { + trace, + debug, + info, + warn, + error, + } + + /// Caller context returned by `get-caller`. + record caller-context { + /// The acting principal for this invocation. + principal: option, + /// UUID of the capsule that originated the IPC message. + source-id: string, + /// ISO 8601 timestamp of the originating message. + timestamp: string, + } + + /// Request to check a capsule's capability. + record capability-check-request { + /// UUID of the capsule to check. + source-uuid: string, + /// Capability name to check. + capability: string, + } + + /// Response from a capability check. + record capability-check-response { + /// Whether the capability is allowed. + allowed: bool, + } + + /// Read a configuration value from the capsule's manifest `[config]`. + /// + /// Returns the raw config value string, or empty string if not set. + /// String values are returned without JSON-encoding (no extra quotes). + get-config: func(key: string) -> result; + + /// Get the caller context for the current invocation. + /// + /// Returns the acting principal, originating capsule UUID, and message + /// timestamp. Returns an empty context if no caller is available. + get-caller: func() -> result; + + /// Trigger a hook/interceptor fan-out to other capsules. + /// + /// Input is a JSON request: `{"hook": "topic", "payload": {...}}`. + /// Finds all capsules with interceptors matching the hook topic, + /// invokes each (skipping the caller to prevent recursion), and + /// returns a JSON array of their responses. + trigger-hook: func(request-json: string) -> result; + + /// Emit a structured log message attributed to the calling capsule. + /// + /// Logs are routed to the current principal's log directory with + /// daily rotation. Cross-principal invocations write to the target + /// principal's log directory. + log: func(level: log-level, message: string); + + /// Signal that the capsule's run loop is ready. + /// + /// Called by the WASM guest after setting up IPC subscriptions. + /// Notifies the kernel to proceed with loading dependent capsules. + /// No-op if no readiness channel is configured. + signal-ready: func(); + + /// Get the current wall-clock time as milliseconds since UNIX epoch. + clock-ms: func() -> u64; + + /// Check whether a capsule has a specific manifest capability. + /// + /// Fail-closed: returns `allowed: false` for unknown UUIDs, + /// unknown capabilities, or unavailable registry. + check-capsule-capability: func(request: capability-check-request) -> result; +} diff --git a/host/uplink@1.0.0.wit b/host/uplink@1.0.0.wit new file mode 100644 index 0000000..b82a924 --- /dev/null +++ b/host/uplink@1.0.0.wit @@ -0,0 +1,25 @@ +/// Uplink communications — inbound message ingestion from external platforms. +/// +/// Capsules register uplinks (named endpoints) for platforms they bridge +/// (e.g. Discord, Slack) and then forward inbound user messages to the +/// kernel's processing pipeline. +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:uplink@1.0.0; + +interface host { + /// Register an uplink endpoint. + /// + /// Arguments: name, platform identifier, profile ("chat", "interactive", + /// "notify", "bridge"). Returns the assigned uplink UUID string. + /// Security-gated: requires uplink-register capability. + uplink-register: func(name: string, platform: string, profile: string) -> result; + + /// Send an inbound message through a registered uplink. + /// + /// Returns true if sent, false if the message was intentionally dropped + /// (e.g. no active session for the principal). + uplink-send: func(uplink-id: string, platform-user-id: string, content: string) -> result; +} diff --git a/scripts/lint-wit-immutability.sh b/scripts/lint-wit-immutability.sh new file mode 100755 index 0000000..eba678e --- /dev/null +++ b/scripts/lint-wit-immutability.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# lint-wit-immutability.sh — enforces the frozen-WIT-file rule. +# +# Once a WIT file is named with an embedded version (`@X.Y.Z.wit`), +# that file is published — its shape is committed to forever. Shape changes +# ship as a new file at a new version path. +# +# This script fails if any *@X.Y.Z.wit file present on the base ref was +# modified or deleted between the base ref and HEAD. New files matching the +# pattern are allowed (that is the legitimate evolution path). +# +# Usage: +# scripts/lint-wit-immutability.sh [BASE_REF] +# +# BASE_REF defaults to origin/main. + +set -euo pipefail + +BASE_REF="${1:-origin/main}" + +if ! git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then + echo "error: base ref '$BASE_REF' not resolvable. Run 'git fetch' or pass a valid ref." >&2 + exit 2 +fi + +PATTERN='@[0-9]+\.[0-9]+\.[0-9]+\.wit$' + +violations=$(git diff --name-status "$BASE_REF"...HEAD \ + | awk -v pat="$PATTERN" ' + $1 == "M" || $1 == "D" { + for (i = 2; i <= NF; i++) { + if ($i ~ pat) { print $1 "\t" $i } + } + } + $1 ~ /^R/ { + if ($2 ~ pat) { print "R\t" $2 " -> " $3 } + } + ') + +if [[ -z "$violations" ]]; then + echo "wit immutability lint: ok" + exit 0 +fi + +cat >&2 < Date: Thu, 21 May 2026 18:41:30 +0400 Subject: [PATCH 02/14] refactor(guest): split astrid:guest into per-export worlds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single `exports` world (all 4 entry points mandatory) with four worlds — `interceptor`, `background`, `installable`, `upgradable` — that capsules `include` independently based on what they implement. Motivation: the wasm32-wasip2 toolchain auto-stubs every export declared in the world a component targets. With a single mandatory world the toolchain emitted stub functions for the entry points a given capsule did not implement, and the kernel had to parse the wasm binary to detect them (see `core/crates/astrid-capsule/src/engine/wasm/mod.rs` `STUB_PRONE_EXPORTS` — a stub-detection hack that compares export function indices to find aliased nops). Per-export worlds put the declaration with the implementation: an export only appears in the wasm binary when the capsule actually implements it, so the kernel can drop the parsing hack and use plain export-presence checks. Verified with `wasm-tools component wit` on a synthetic consumer world that includes interceptor + background — resolves to exactly those two exports plus the auto-imported lifecycle types. Parity audit before this change confirmed the split file matches the deleted monolithic file: 65/65 host functions identical, 30/30 referenced types preserved, 4 dead WIT records dropped (capsule-context, tool-input, tool-output, tool-definition — verified zero references anywhere in WIT; the kernel uses JSON-serialized Rust types over list for the hook context, not the WIT record). --- README.md | 22 +++++++++--- host/guest@1.0.0.wit | 82 ++++++++++++++++++++++++++++---------------- 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 87e00dc..26dde43 100644 --- a/README.md +++ b/README.md @@ -27,19 +27,31 @@ Each domain is its own package, frozen at a per-file version. A capsule imports | `host/elicit@1.0.0.wit` | `astrid:elicit@1.0.0` | Interactive user input during install/upgrade lifecycle. | | `host/approval@1.0.0.wit` | `astrid:approval@1.0.0` | Human-in-the-loop approval gate for sensitive actions. | | `host/identity@1.0.0.wit` | `astrid:identity@1.0.0` | Multi-platform identity resolve and link. | -| `host/guest@1.0.0.wit` | `astrid:guest@1.0.0` | Guest export contract — `astrid-hook-trigger`, `run`, `astrid-install`, `astrid-upgrade`. Capsule worlds `include astrid:guest/exports@1.0.0`. | +| `host/guest@1.0.0.wit` | `astrid:guest@1.0.0` | Guest export contract — `astrid-hook-trigger`, `run`, `astrid-install`, `astrid-upgrade`. Each entry point lives in its own world (`interceptor`, `background`, `installable`, `upgradable`) so capsules `include` only what they implement. | -A capsule's world declares only the imports it uses plus the guest export include: +A capsule's world declares only the imports it uses plus the guest-export worlds it actually implements: ```wit -world my-capsule { - include astrid:guest/exports@1.0.0; +// Interceptor-only capsule: +world router { + include astrid:guest/interceptor@1.0.0; import astrid:ipc/host@1.0.0; - import astrid:kv/host@1.0.0; // intentionally not importing net, http, identity, … } + +// Run-loop capsule with an install hook: +world cli { + include astrid:guest/interceptor@1.0.0; + include astrid:guest/background@1.0.0; + include astrid:guest/installable@1.0.0; + import astrid:ipc/host@1.0.0; + import astrid:uplink/host@1.0.0; + import astrid:net/host@1.0.0; +} ``` +Per-export worlds matter: the wasm32-wasip2 toolchain auto-stubs every export declared in a world the component targets. Bundling all four entry points into one mandatory world forced stubs for the unused ones, which then required kernel-side parsing to distinguish real implementations from toolchain stubs. With per-export worlds, an export only appears in the wasm binary when the capsule actually implements it. + ### Evolution discipline Once a `host/@X.Y.Z.wit` file is shipped (i.e. on `main`), it is **immutable forever**. The wasmtime Component Model linker enforces structural typing on every `(package, version)` pair, so any record-field add or function add in a published WIT file causes every capsule built against the prior shape to fail to instantiate. The fix is to never edit a published file in place. diff --git a/host/guest@1.0.0.wit b/host/guest@1.0.0.wit index 051e457..b4caafd 100644 --- a/host/guest@1.0.0.wit +++ b/host/guest@1.0.0.wit @@ -1,11 +1,32 @@ /// Guest export contract — lifecycle and interceptor entry points the /// kernel calls into a capsule. /// -/// A capsule that handles interceptor hooks, runs a background loop, or -/// participates in install/upgrade lifecycle phases includes the `exports` -/// world below in its own world declaration. Each export is optional at -/// the kernel level — the kernel introspects which exports the capsule -/// actually provides and only calls those. +/// Each entry point lives in its own world so capsules `include` only the +/// ones they actually implement. The CM toolchain auto-stubs every export +/// declared in a world the component targets; co-mingling optional exports +/// in a single world forces stubs for unused ones and pushes the kernel +/// into parsing the wasm binary to detect them. Per-export worlds put the +/// declaration where the implementation is, and the kernel sees exports +/// only when they are real. +/// +/// Typical capsule worlds: +/// +/// ```wit +/// // Interceptor-only capsule (e.g. router): +/// world my-capsule { +/// include astrid:guest/interceptor@1.0.0; +/// import astrid:ipc/host@1.0.0; +/// } +/// +/// // Run-loop capsule with install hook (e.g. cli uplink): +/// world my-capsule { +/// include astrid:guest/interceptor@1.0.0; +/// include astrid:guest/background@1.0.0; +/// include astrid:guest/installable@1.0.0; +/// import astrid:ipc/host@1.0.0; +/// import astrid:uplink/host@1.0.0; +/// } +/// ``` /// /// The package is named `astrid:guest` to mirror the per-domain `host` /// packages: `host` interfaces are kernel-side (imported by capsules), @@ -29,39 +50,40 @@ interface lifecycle { } } -/// Guest exports a capsule provides to the kernel. +/// Interceptor entry point. /// -/// Capsule worlds `include astrid:guest/exports@1.0.0` to declare these -/// entry points. The kernel calls only the exports a capsule actually -/// implements; unimplemented exports are skipped without error. -world exports { +/// Almost every capsule includes this. The kernel calls `astrid-hook-trigger` +/// with an action name (interceptor handler to invoke) and an event payload +/// as raw bytes. The guest returns a `capsule-result` directing the kernel +/// how to proceed. +world interceptor { use lifecycle.{capsule-result}; - /// Execute an interceptor hook. - /// - /// The primary entry point for hook/interceptor execution. The kernel - /// passes the action name (interceptor handler to invoke) and the event - /// payload as raw bytes. The guest returns a result directing the kernel - /// how to proceed. export astrid-hook-trigger: func(action: string, payload: list) -> capsule-result; +} - /// Background run loop for long-lived capsules. - /// - /// Capsules that export `run` are started as background tasks. The - /// function should block indefinitely (event loop pattern), processing - /// IPC messages via subscriptions set up before calling `signal-ready`. - /// The kernel manages the lifecycle. +/// Background run loop. +/// +/// Capsules that export `run` are started as background tasks. The function +/// should block indefinitely (event loop pattern), processing IPC messages +/// via subscriptions set up before calling `signal-ready`. The kernel +/// manages the lifecycle. +world background { export run: func(); +} - /// Lifecycle hook: first-time installation. - /// - /// Called once when a capsule is installed. May use `elicit` to collect - /// secrets and configuration from the user interactively. +/// First-time installation lifecycle hook. +/// +/// Called once when a capsule is installed. May use `elicit` to collect +/// secrets and configuration from the user interactively. +world installable { export astrid-install: func(); +} - /// Lifecycle hook: upgrade from a previous version. - /// - /// Called when a capsule is upgraded. May use `elicit` to collect new - /// configuration. +/// Upgrade lifecycle hook. +/// +/// Called when a capsule is upgraded from a previous version. May use +/// `elicit` to collect new configuration. +world upgradable { export astrid-upgrade: func(); } From f521cf2e9a1d07967502477cf3514a14e261368e Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 21 May 2026 19:52:54 +0400 Subject: [PATCH 03/14] ci: tighten lint awk parsing + scope parse job to host/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Address Gemini review on PR #7: switch the lint awk to tab-field parsing (`-F'\t'`) so paths containing spaces are handled correctly. Same behaviour for the common case; the previous default-whitespace splitting would have broken on a path with embedded spaces. - Scope the workflow parse step to host/*.wit. Each host package is self-contained — every type lives inside the same package's host interface, no cross-package `use` — so single-file `wasm-tools component wit` resolves cleanly. interfaces/*.wit files cross-reference each other (`use astrid:types/types.{message}` etc.) and would require a deps/ tree to resolve in single-file mode; they predate this CI and are validated by downstream SDK builds, so the workflow no longer pretends to validate them. --- .github/workflows/lint.yml | 15 ++++++++++----- scripts/lint-wit-immutability.sh | 12 +++--------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8568345..5877a4f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,16 +36,21 @@ jobs: - name: Install wasm-tools run: cargo install --locked wasm-tools - - name: Parse every WIT file + - name: Parse every host/*.wit file + # Only host/ — each host package is self-contained (no cross-package + # `use`), so single-file mode works. interfaces/ files cross-reference + # each other (e.g. `use astrid:types/types.{...}`) and would require a + # deps/ tree to resolve; they predate this CI and are validated by + # downstream SDK builds. run: | fail=0 - for f in $(find host interfaces -name '*.wit' 2>/dev/null); do - if ! wasm-tools component wit "$f" >/dev/null 2>&1; then + for f in host/*.wit; do + if wasm-tools component wit "$f" >/dev/null 2>&1; then + echo "OK: $f" + else echo "FAIL: $f" wasm-tools component wit "$f" 2>&1 | head -20 fail=1 - else - echo "OK: $f" fi done exit $fail diff --git a/scripts/lint-wit-immutability.sh b/scripts/lint-wit-immutability.sh index eba678e..281decc 100755 --- a/scripts/lint-wit-immutability.sh +++ b/scripts/lint-wit-immutability.sh @@ -27,15 +27,9 @@ fi PATTERN='@[0-9]+\.[0-9]+\.[0-9]+\.wit$' violations=$(git diff --name-status "$BASE_REF"...HEAD \ - | awk -v pat="$PATTERN" ' - $1 == "M" || $1 == "D" { - for (i = 2; i <= NF; i++) { - if ($i ~ pat) { print $1 "\t" $i } - } - } - $1 ~ /^R/ { - if ($2 ~ pat) { print "R\t" $2 " -> " $3 } - } + | awk -F'\t' -v pat="$PATTERN" ' + ($1 == "M" || $1 == "D") && $2 ~ pat { print $1 "\t" $2 } + $1 ~ /^R/ && $2 ~ pat { print "R\t" $2 " -> " $3 } ') if [[ -z "$violations" ]]; then From 8bf8d1251a41fceb3179c0ff857dbb8b368d8cdf Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 21 May 2026 21:41:29 +0400 Subject: [PATCH 04/14] docs: call out WASI primitives are also available The kernel adds wasmtime_wasi::p2 to the linker, so capsule authors see wasi:random, wasi:clocks/monotonic-clock, wasi:clocks/wall-clock, and wasi:io/streams alongside astrid:*. astrid:* exists where Astrid layers capability gating, principal scoping, or audit on top of what would otherwise be a syscall; where no such layering is needed, capsules should use WASI directly rather than expecting Astrid to re-expose it. --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 26dde43..1291f25 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,20 @@ The capsule-to-capsule contracts. Capsules declare which they import/export in ` | `interfaces/types.wit` | `astrid:types@1.0.0` | Shared types used across interfaces | | `interfaces/users.wit` | `astrid:users@1.0.0` | Within-principal user identity store — platform-to-AstridUserId mapping | +## WASI is also available + +Capsules see WASI alongside the `astrid:*` packages. The kernel adds `wasmtime_wasi::p2` to the linker, so reach for the standard WASI imports before assuming something is missing from `astrid:*`: + +| Need | WASI package | +|------|--------------| +| Cryptographically secure random bytes | `wasi:random/random` | +| Monotonic clock (timeouts, latency, scheduling) | `wasi:clocks/monotonic-clock` | +| Wall clock with structured datetime | `wasi:clocks/wall-clock` | +| Sleep / await duration | `wasi:clocks/monotonic-clock.subscribe-duration` | +| Stream I/O abstractions | `wasi:io/streams` | + +`astrid:*` exists where Astrid layers capability gating, principal scoping, or audit on top of what would otherwise be a syscall (`astrid:fs` for VFS-scheme resolution, `astrid:net` for `net_connect` allowlists, etc.). Where no such layering is needed, capsules use WASI directly — there is no `astrid:random` or `astrid:clock-monotonic` because there is nothing to add to the WASI version. + ## How capsules use these Capsules declare which interfaces they import and export in their `Capsule.toml`: From b1a451b939405f06ce109b48a2e607775d41c6c2 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 21 May 2026 22:49:52 +0400 Subject: [PATCH 05/14] feat(sys)!: drop trigger-hook, drop WASI callout; add random-bytes, clock-monotonic-ns, sleep-ns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIT changes on astrid:sys@1.0.0 (still pre-merge — freeze rule doesn't apply yet to files on this branch). Removed: - trigger-hook. Synchronous hook fan-out + response aggregation is orchestration, not routing. Capsules implement the equivalent on the IPC bus (publish + subscribe scoped reply topic + collect-N-responses- with-timeout) via an SDK helper that's coming alongside the kernel- side removal. Two callers (capsule-prompt-builder, capsule-hook-bridge) migrate in their own commits. Added: - random-bytes(length) — cryptographically secure entropy from the host CSPRNG. Length capped at 4096 per call. - clock-monotonic-ns() — monotonic clock reading in nanoseconds. Replaces the WASI route for elapsed-time measurement. - sleep-ns(duration) — block the calling guest task for the given duration. Capped server-side at 60s per call. These exist because Astrid no longer exposes wasi:random or wasi:clocks to capsules. Every host call goes through the astrid:* surface so it's capability-gated, principal-scoped, and audited; running a second un-audited host surface (WASI) alongside ours defeats that. WASI's generic primitives now have explicit astrid:sys equivalents. README: - Drop the 'WASI is also available' section. It was premature without an audit of the WASI surface and conflicts with the new policy of astrid:* being the single host ABI. - Update astrid:sys row to reflect the new function list. --- README.md | 16 +--------------- host/sys@1.0.0.wit | 39 ++++++++++++++++++++++++++++++--------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 1291f25..dfcc74f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Each domain is its own package, frozen at a per-file version. A capsule imports | `host/kv@1.0.0.wit` | `astrid:kv@1.0.0` | Per-capsule, per-principal key-value storage. | | `host/net@1.0.0.wit` | `astrid:net@1.0.0` | Unix-domain sockets and gated outbound TCP. | | `host/http@1.0.0.wit` | `astrid:http@1.0.0` | HTTP client with SSRF protection and streaming. | -| `host/sys@1.0.0.wit` | `astrid:sys@1.0.0` | Logging, config, time, caller context, capability introspection. | +| `host/sys@1.0.0.wit` | `astrid:sys@1.0.0` | Logging, config, time, caller context, entropy, sleep, capability introspection. | | `host/process@1.0.0.wit` | `astrid:process@1.0.0` | OS-sandboxed host process spawn / kill / read-logs. | | `host/elicit@1.0.0.wit` | `astrid:elicit@1.0.0` | Interactive user input during install/upgrade lifecycle. | | `host/approval@1.0.0.wit` | `astrid:approval@1.0.0` | Human-in-the-loop approval gate for sensitive actions. | @@ -94,20 +94,6 @@ The capsule-to-capsule contracts. Capsules declare which they import/export in ` | `interfaces/types.wit` | `astrid:types@1.0.0` | Shared types used across interfaces | | `interfaces/users.wit` | `astrid:users@1.0.0` | Within-principal user identity store — platform-to-AstridUserId mapping | -## WASI is also available - -Capsules see WASI alongside the `astrid:*` packages. The kernel adds `wasmtime_wasi::p2` to the linker, so reach for the standard WASI imports before assuming something is missing from `astrid:*`: - -| Need | WASI package | -|------|--------------| -| Cryptographically secure random bytes | `wasi:random/random` | -| Monotonic clock (timeouts, latency, scheduling) | `wasi:clocks/monotonic-clock` | -| Wall clock with structured datetime | `wasi:clocks/wall-clock` | -| Sleep / await duration | `wasi:clocks/monotonic-clock.subscribe-duration` | -| Stream I/O abstractions | `wasi:io/streams` | - -`astrid:*` exists where Astrid layers capability gating, principal scoping, or audit on top of what would otherwise be a syscall (`astrid:fs` for VFS-scheme resolution, `astrid:net` for `net_connect` allowlists, etc.). Where no such layering is needed, capsules use WASI directly — there is no `astrid:random` or `astrid:clock-monotonic` because there is nothing to add to the WASI version. - ## How capsules use these Capsules declare which interfaces they import and export in their `Capsule.toml`: diff --git a/host/sys@1.0.0.wit b/host/sys@1.0.0.wit index 6fdd4ac..bbfe2c2 100644 --- a/host/sys@1.0.0.wit +++ b/host/sys@1.0.0.wit @@ -1,5 +1,11 @@ /// System-level runtime functions: logging, config, time, caller context, -/// hook fan-out, capability introspection. +/// entropy, sleep, capability introspection. +/// +/// Astrid does not expose `wasi:random`, `wasi:clocks`, or other generic +/// WASI primitives to capsules. Every host call is gated, principal-scoped, +/// and audited — having a second un-audited surface (WASI) would defeat +/// that. Primitives capsules need (random bytes, monotonic clock, sleep) +/// live here. /// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. @@ -52,14 +58,6 @@ interface host { /// timestamp. Returns an empty context if no caller is available. get-caller: func() -> result; - /// Trigger a hook/interceptor fan-out to other capsules. - /// - /// Input is a JSON request: `{"hook": "topic", "payload": {...}}`. - /// Finds all capsules with interceptors matching the hook topic, - /// invokes each (skipping the caller to prevent recursion), and - /// returns a JSON array of their responses. - trigger-hook: func(request-json: string) -> result; - /// Emit a structured log message attributed to the calling capsule. /// /// Logs are routed to the current principal's log directory with @@ -77,6 +75,29 @@ interface host { /// Get the current wall-clock time as milliseconds since UNIX epoch. clock-ms: func() -> u64; + /// Get the current monotonic clock reading in nanoseconds. + /// + /// Suitable for measuring elapsed time within a process. Does not jump + /// with NTP adjustments. The absolute value is meaningless across + /// processes or capsule reloads — only differences are. + clock-monotonic-ns: func() -> u64; + + /// Block the calling guest task for the given duration in nanoseconds. + /// + /// Capped server-side at 60 seconds per call (callers needing longer + /// waits loop on shorter sleeps and check for cancellation between + /// iterations). Returns when the duration has elapsed or the capsule + /// is unloading. + sleep-ns: func(duration-ns: u64) -> result<_, string>; + + /// Fill the caller's requested length with cryptographically secure + /// random bytes from the host's OS-level CSPRNG. + /// + /// `length` is capped at 4096 bytes per call (cryptographic use cases + /// — key material, nonces, identifiers — fit comfortably; bulk entropy + /// callers loop). Larger requests return an error. + random-bytes: func(length: u32) -> result, string>; + /// Check whether a capsule has a specific manifest capability. /// /// Fail-closed: returns `allowed: false` for unknown UUIDs, From 95ab1c62b0775388594835d561b8691ad63d3bd7 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 21 May 2026 23:10:15 +0400 Subject: [PATCH 06/14] =?UTF-8?q?feat(host)!:=20round=20out=20the=201.0=20?= =?UTF-8?q?syscall=20surface=20=E2=80=94=20fs=20ops,=20UDP,=20process,=20k?= =?UTF-8?q?v-cas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filesystem (astrid:fs): - fs-append for non-RMW log/journal writes - fs-copy + fs-rename for file management; rename is single-VFS-scheme only to preserve atomic-rename contracts - fs-remove-dir-all for recursive directory removal; refuses to follow symlinks to prevent VFS-sandbox escapes Networking (astrid:net): - UDP datagram set: udp-bind, udp-send-to, udp-recv-from, udp-close, plus udp-set-read-timeout and udp-local-addr accessors - New udp-datagram record carries (data, peer-host, peer-port) - Gated by a separate net_udp capability (distinct from net / net_connect) so capsule authors opt in deliberately - SSRF airlock applies on send-to / recv-from peer resolution - Unblocks P2P, DNS, QUIC, voice/video, and any UDP-protocol bridge capsule Process (astrid:process): - spawn-request gains stdin (optional pre-piped buffer), env (explicit env-var list replacing host's default sandbox env), cwd (workspace- relative working directory) - New signal(process-id, signal-name) for SIGTERM/SIGHUP/SIGUSR1/2/INT, separate from kill (SIGKILL) so graceful shutdown is expressible - Required for any capsule that wraps a real CLI tool (git, npm, kubectl) — without stdin/env/cwd the spawned process couldn't be driven properly KV (astrid:kv): - kv-cas(key, expected, new) for atomic compare-and-swap. expected: none means create-if-absent. Required for any concurrent coordination on shared state — the kernel runs capsule invocations on the multi-threaded tokio worker pool, so RMW patterns on shared keys race silently today Total host fn additions: +13 (fs +4, net +6, process +1, kv +1, plus spawn-request gains 3 record fields). Contract-first; kernel implementations land in the core branch. The new functions are part of the rounded 1.0 syscall surface so that when third-party capsule authors arrive they find what they expect based on std::* equivalents. --- README.md | 4 ++-- host/fs@1.0.0.wit | 32 ++++++++++++++++++++++++++ host/kv@1.0.0.wit | 22 ++++++++++++++++++ host/net@1.0.0.wit | 52 ++++++++++++++++++++++++++++++++++++++++++ host/process@1.0.0.wit | 34 +++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dfcc74f..8ef337d 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,10 @@ Each domain is its own package, frozen at a per-file version. A capsule imports | `host/ipc@1.0.0.wit` | `astrid:ipc@1.0.0` | Publish/subscribe IPC event bus. | | `host/uplink@1.0.0.wit` | `astrid:uplink@1.0.0` | Inbound message ingestion from external platforms. | | `host/kv@1.0.0.wit` | `astrid:kv@1.0.0` | Per-capsule, per-principal key-value storage. | -| `host/net@1.0.0.wit` | `astrid:net@1.0.0` | Unix-domain sockets and gated outbound TCP. | +| `host/net@1.0.0.wit` | `astrid:net@1.0.0` | Unix-domain sockets, gated outbound TCP, and gated UDP. | | `host/http@1.0.0.wit` | `astrid:http@1.0.0` | HTTP client with SSRF protection and streaming. | | `host/sys@1.0.0.wit` | `astrid:sys@1.0.0` | Logging, config, time, caller context, entropy, sleep, capability introspection. | -| `host/process@1.0.0.wit` | `astrid:process@1.0.0` | OS-sandboxed host process spawn / kill / read-logs. | +| `host/process@1.0.0.wit` | `astrid:process@1.0.0` | OS-sandboxed host process spawn (with stdin/env/cwd), signal, kill, read-logs. | | `host/elicit@1.0.0.wit` | `astrid:elicit@1.0.0` | Interactive user input during install/upgrade lifecycle. | | `host/approval@1.0.0.wit` | `astrid:approval@1.0.0` | Human-in-the-loop approval gate for sensitive actions. | | `host/identity@1.0.0.wit` | `astrid:identity@1.0.0` | Multi-platform identity resolve and link. | diff --git a/host/fs@1.0.0.wit b/host/fs@1.0.0.wit index 2b356b6..54c7f49 100644 --- a/host/fs@1.0.0.wit +++ b/host/fs@1.0.0.wit @@ -59,4 +59,36 @@ interface host { /// /// Security-gated: requires file-write capability. write-file: func(path: string, content: list) -> result<_, string>; + + /// Append content to a file, creating it if it does not exist. + /// + /// Logs, journals, and other accumulating writes use this to avoid the + /// read-modify-write hazard of `read-file` + `write-file`. Subject to + /// the same 10 MB upper bound on the appended chunk as `write-file`; + /// callers writing large logs split into multiple appends. + /// Security-gated: requires file-write capability. + fs-append: func(path: string, content: list) -> result<_, string>; + + /// Copy a file from `src` to `dst`. Both paths resolve through the + /// VFS, so cross-scheme copies are subject to the same capability + /// checks as a `read-file` of `src` plus a `write-file` of `dst`. + /// Overwrites `dst` if it exists. Directory copies are not supported; + /// `src` must name a regular file. + /// Security-gated: requires file-read + file-write capabilities. + fs-copy: func(src: string, dst: string) -> result<_, string>; + + /// Rename (move) a file or directory from `src` to `dst`. Both paths + /// must resolve to the same VFS scheme — cross-scheme renames are + /// rejected because they would require a copy + delete and break the + /// atomic-rename contract callers expect. + /// Security-gated: requires file-write capability on both paths. + fs-rename: func(src: string, dst: string) -> result<_, string>; + + /// Remove a directory and all its contents recursively. + /// + /// Refuses to traverse symlinks (no following) to prevent escapes + /// outside the VFS sandbox. Returns the count of removed entries + /// (files + subdirectories). + /// Security-gated: requires file-write capability. + fs-remove-dir-all: func(path: string) -> result; } diff --git a/host/kv@1.0.0.wit b/host/kv@1.0.0.wit index 974c158..b6f0608 100644 --- a/host/kv@1.0.0.wit +++ b/host/kv@1.0.0.wit @@ -31,4 +31,26 @@ interface host { /// /// Returns the count of deleted keys. kv-clear-prefix: func(prefix: string) -> result; + + /// Atomically compare-and-swap a key. + /// + /// If the key's current value equals `expected`, replace it with + /// `new` and return `true`. Otherwise leave the store unchanged and + /// return `false`. `expected` of `none` means "swap only if the key + /// does not currently exist" (create-if-absent). Use the read-then- + /// cas pattern for unconditional updates that need concurrency + /// safety: + /// + /// ```text + /// loop { + /// current = kv-get(key)?; + /// next = mutate(current); + /// if kv-cas(key, current, next)? { break; } + /// } + /// ``` + /// + /// Required for any concurrent coordination on shared state — the + /// kernel does not guarantee single-threaded execution across + /// invocations of the same capsule. + kv-cas: func(key: string, expected: option>, new: list) -> result; } diff --git a/host/net@1.0.0.wit b/host/net@1.0.0.wit index ec2ac3a..f79a055 100644 --- a/host/net@1.0.0.wit +++ b/host/net@1.0.0.wit @@ -190,4 +190,56 @@ interface host { /// /// Mirrors `std::net::TcpStream::ttl`. net-ttl: func(stream-handle: u64) -> result; + + // ----------------------------------------------------------------- + // UDP — datagram sockets + // ----------------------------------------------------------------- + // + // Mirrors `std::net::UdpSocket`. Gated by a separate `net_udp` + // capability allowlist (distinct from `net_connect` for TCP) so + // capsule authors opt into datagram networking explicitly. The + // SSRF airlock applies on send-to / recv-from: peer addresses + // resolving to private/loopback/link-local/multicast ranges are + // rejected. Max 4 concurrent UDP sockets per capsule. + + /// Datagram returned by `udp-recv-from`. + record udp-datagram { + /// Payload bytes received. + data: list, + /// Peer host (numeric `"ip"` form, IPv4 or IPv6). + peer-host: string, + /// Peer UDP port. + peer-port: u16, + } + + /// Bind a UDP socket to `host:port`. Pass `"0.0.0.0"` / `"::"` for + /// any interface and `0` for an ephemeral port. Returns a socket + /// handle. + /// + /// Security-gated: requires `net_udp` capability with a matching + /// `bind:host:port` pattern. + udp-bind: func(host: string, port: u16) -> result; + + /// Send `data` to `peer-host:peer-port`. Returns the byte count sent + /// (UDP semantics: short writes are unusual but possible). + /// + /// Security-gated: peer address goes through the SSRF airlock and + /// must match a `send:host:port` pattern in `net_udp`. + udp-send-to: func(socket: u64, data: list, peer-host: string, peer-port: u16) -> result; + + /// Receive up to `max-bytes` from the socket. Blocks up to the + /// socket's read timeout (defaults to non-blocking ~50 ms poll). + /// Returns an empty data list with sentinel peer (`""`/`0`) if no + /// datagram was available. + udp-recv-from: func(socket: u64, max-bytes: u32) -> result; + + /// Set the read timeout for `udp-recv-from`. `none` clears it + /// (recv returns immediately when no data is queued). + udp-set-read-timeout: func(socket: u64, timeout-ms: option) -> result<_, string>; + + /// Return the local socket address as `"ip:port"`. + udp-local-addr: func(socket: u64) -> result; + + /// Close a UDP socket handle. Idempotent. + udp-close: func(socket: u64) -> result<_, string>; } diff --git a/host/process@1.0.0.wit b/host/process@1.0.0.wit index 32b731b..6e8bac5 100644 --- a/host/process@1.0.0.wit +++ b/host/process@1.0.0.wit @@ -11,12 +11,34 @@ package astrid:process@1.0.0; interface host { + /// Environment variable to pass to a spawned process. + record env-var { + /// Variable name. + key: string, + /// Variable value. + value: string, + } + /// Request to spawn a host process. record spawn-request { /// Command to execute. cmd: string, /// Command arguments. args: list, + /// Optional stdin bytes piped to the spawned process. The full + /// buffer is delivered to the child's stdin and then stdin is + /// closed; the child sees EOF after consuming. + stdin: option>, + /// Environment variables to set in the child. Replaces (not + /// merges with) the host's default sandbox environment, except + /// for a small allowlist the kernel always passes through + /// (e.g. `PATH` segments needed by sandbox-exec / bwrap). + env: list, + /// Working directory for the child, relative to the workspace. + /// Must resolve inside the workspace sandbox; absolute paths + /// and `..` escapes are rejected. `none` keeps the workspace + /// root. + cwd: option, } /// Result of a synchronous process execution. @@ -81,4 +103,16 @@ interface host { /// /// Sends SIGKILL to the process group (Unix) or kill (Windows). kill: func(process-id: u64) -> result; + + /// Signal a background process by symbolic name. + /// + /// Accepted names (Unix semantics): `"term"` (SIGTERM, default + /// graceful shutdown), `"hup"` (SIGHUP, reload), `"usr1"` (SIGUSR1), + /// `"usr2"` (SIGUSR2), `"int"` (SIGINT). On Windows the kernel maps + /// to the closest equivalent; unsupported signals return an error. + /// + /// `kill` (SIGKILL) remains a separate function because it is + /// non-graceful and returns drained stdout/stderr buffers; `signal` + /// is fire-and-forget. + signal: func(process-id: u64, signal-name: string) -> result<_, string>; } From 341ecee38051802b8bff35ed4fea5e0d03d6e155 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 21 May 2026 23:26:58 +0400 Subject: [PATCH 07/14] =?UTF-8?q?feat(host)!:=20tighten=201.0=20ABI=20shap?= =?UTF-8?q?e=20=E2=80=94=20file=20handles,=20metadata,=20DNS,=20std-shaped?= =?UTF-8?q?=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last-mile pre-freeze polish driven by an exhaustive audit against std::*. Every change here is a 'one-way door' — once 1.0 lands, the shape is committed to forever, so this commit lands them all together. Std-shape corrections (mostly type fixes — small but irreversible): - file-stat extended: was just (size, is-dir, mtime). Now carries is-file, is-symlink, mode (POSIX bits), mtime-ns + ctime-ns + atime-ns. Every build/script/sync capsule needs 'is this a regular file', and after freeze no capsule can ever get it. - fs-stat-symlink added — lstat counterpart, doesn't follow symlinks. - http-request-data.body: option → option>. The string shape silently lossy-converts arbitrary bytes; first PNG / protobuf / multipart upload would have hit it. - process-result.exit-code: s32-with-(-1)-sentinel → option. Matches read-logs-result.exit-code shape, drops the magic-value pattern. - process.signal(process-id, signal-name: string) → signal(process-id, sig: process-signal) with a typed enum (term/hup/usr1/usr2/int). The documented allowlist is now in the contract. - udp-recv-from: sentinel-on-no-data → option. Matches net-poll-accept idiom; sentinel pattern dropped. - get-config: empty-string-on-not-set → option. Empty values are legal and now distinguishable from 'not set'. - net-bind-unix(listener-handle: u64) → net-bind-unix(). The arg was documented as ignored. - net-poll-accept gains a caller-controlled timeout (was hardcoded 10ms, no name signal). Matches the rest of the timeout-bearing fns. New host primitives that match std::* and capsule authors will reach for: - fs-open / fs-read-at / fs-write-at / fs-flush / fs-close — file handles with positional I/O (mirrors std::fs::File + FileExt::read_at/write_at). Escape valve for the 10 MB whole-file cap; mandatory for capsules processing large blobs (logs, DBs, parquet, media). - fs-canonicalize / fs-read-link — mirrors std::fs::canonicalize and std::fs::read_link. Both enforce VFS-scheme boundaries: resolutions escaping the input's scheme return an error rather than leaking host- filesystem information. Deliberately omitted: fs-symlink and fs-hard-link — symlink/hard-link creation is the highest-blast-radius sandbox-escape vector and the agent-runtime demand is near zero; if real demand emerges, they ship in astrid:fs@1.1.0 with a designed security model. - net-lookup-host — DNS resolution (std::net::ToSocketAddrs equivalent). SSRF airlock filters resolved IPs the same as connect-tcp. - process-write-stdin / process-close-stdin — streaming stdin to a long-lived background process. Required for REPL-style children (python -i, psql, MCP stdio bridges). - process-wait — std::process::Child::wait equivalent. timeout-ms: option; returns Some(exit_code) if exited within window, None if timeout elapsed. Avoids the IPC-burn of polling read-logs. - kv-list-keys-page — paginated listing. Prevents OOM on large per- principal stores. kv-list-keys retained for small-store convenience but capped server-side at 1024 keys. Deferred to 1.x (specific, not blocking): - TCP listener (net-bind-tcp). Team has discussed remote-hosted capsules going non-WASM; not shipping inbound listener with WASM 1.0. - Socket options (SO_KEEPALIVE, SO_LINGER, etc.). Defaults work for the typical agent capsule; add when a real consumer needs tuning. - UDP connect / multicast. UDP just shipped — no demand signal yet. - File locking (flock). kv-cas covers in-runtime concurrency; cross- process locking is niche for a sandboxed agent runtime. Total host fn count: 78 → 92 (+14 net additions across fs/net/process/kv/ sys). All function names mirror std::* where a direct equivalent exists. Contract-first; kernel implementations land in the core branch. --- README.md | 8 ++-- host/fs@1.0.0.wit | 105 +++++++++++++++++++++++++++++++++++++++-- host/http@1.0.0.wit | 7 ++- host/kv@1.0.0.wit | 23 ++++++++- host/net@1.0.0.wit | 35 +++++++++----- host/process@1.0.0.wit | 66 +++++++++++++++++++++----- host/sys@1.0.0.wit | 8 ++-- 7 files changed, 215 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 8ef337d..a3c4b99 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ Each domain is its own package, frozen at a per-file version. A capsule imports | File | Package | Description | |------|---------|-------------| -| `host/fs@1.0.0.wit` | `astrid:fs@1.0.0` | Filesystem operations within the workspace boundary. | +| `host/fs@1.0.0.wit` | `astrid:fs@1.0.0` | Filesystem operations within the workspace boundary — whole-file IO, file handles with positional read/write, metadata, canonicalize, read-link. | | `host/ipc@1.0.0.wit` | `astrid:ipc@1.0.0` | Publish/subscribe IPC event bus. | | `host/uplink@1.0.0.wit` | `astrid:uplink@1.0.0` | Inbound message ingestion from external platforms. | -| `host/kv@1.0.0.wit` | `astrid:kv@1.0.0` | Per-capsule, per-principal key-value storage. | -| `host/net@1.0.0.wit` | `astrid:net@1.0.0` | Unix-domain sockets, gated outbound TCP, and gated UDP. | +| `host/kv@1.0.0.wit` | `astrid:kv@1.0.0` | Per-capsule, per-principal key-value storage with atomic compare-and-swap and paginated key listing. | +| `host/net@1.0.0.wit` | `astrid:net@1.0.0` | Unix-domain sockets, gated outbound TCP, gated UDP, gated DNS resolution. | | `host/http@1.0.0.wit` | `astrid:http@1.0.0` | HTTP client with SSRF protection and streaming. | | `host/sys@1.0.0.wit` | `astrid:sys@1.0.0` | Logging, config, time, caller context, entropy, sleep, capability introspection. | -| `host/process@1.0.0.wit` | `astrid:process@1.0.0` | OS-sandboxed host process spawn (with stdin/env/cwd), signal, kill, read-logs. | +| `host/process@1.0.0.wit` | `astrid:process@1.0.0` | OS-sandboxed host process spawn (with stdin/env/cwd), wait, signal, kill, read-logs, stdin streaming. | | `host/elicit@1.0.0.wit` | `astrid:elicit@1.0.0` | Interactive user input during install/upgrade lifecycle. | | `host/approval@1.0.0.wit` | `astrid:approval@1.0.0` | Human-in-the-loop approval gate for sensitive actions. | | `host/identity@1.0.0.wit` | `astrid:identity@1.0.0` | Multi-platform identity resolve and link. | diff --git a/host/fs@1.0.0.wit b/host/fs@1.0.0.wit index 54c7f49..248fc09 100644 --- a/host/fs@1.0.0.wit +++ b/host/fs@1.0.0.wit @@ -11,14 +11,40 @@ package astrid:fs@1.0.0; interface host { - /// File metadata returned by `fs-stat`. + /// Mode for `fs-open`. + enum open-mode { + /// Open existing file for reading only. Fails if absent. + read, + /// Open or create for writing; truncates existing content. + write, + /// Open or create for writing; appends to existing content. + append, + /// Open existing file for both reading and writing. Fails if absent. + read-write, + } + + /// File metadata returned by `fs-stat` and `fs-stat-symlink`. record file-stat { /// File size in bytes. size: u64, /// Whether this entry is a directory. is-dir: bool, - /// Last modification time (UNIX epoch seconds), if available. - mtime: option, + /// Whether this entry is a regular file. + is-file: bool, + /// Whether this entry is a symbolic link. `is-symlink` and either + /// `is-dir` or `is-file` may both be true when the link is followed + /// by `fs-stat`; `fs-stat-symlink` (lstat) reports the link itself. + is-symlink: bool, + /// POSIX mode bits (file type + permissions). Zero on platforms + /// without a mode concept. + mode: u32, + /// Last modification time, nanoseconds since UNIX epoch. `none` + /// if the host filesystem doesn't track it. + mtime-ns: option, + /// Creation time (birth time), nanoseconds since UNIX epoch. + ctime-ns: option, + /// Last access time, nanoseconds since UNIX epoch. + atime-ns: option, } /// Check whether a file or directory exists at the given path. @@ -38,11 +64,17 @@ interface host { /// Security-gated: requires file-read capability. fs-readdir: func(path: string) -> result, string>; - /// Get file metadata (size, type, mtime). + /// Get file metadata (size, type, mtime). Follows symlinks. /// /// Security-gated: requires file-read capability. fs-stat: func(path: string) -> result; + /// Like `fs-stat` but does NOT follow symlinks — reports the symlink + /// itself when `path` is a link. Mirrors `std::fs::symlink_metadata`. + /// + /// Security-gated: requires file-read capability. + fs-stat-symlink: func(path: string) -> result; + /// Remove a file at the given path. /// /// Security-gated: requires file-write capability. @@ -91,4 +123,69 @@ interface host { /// (files + subdirectories). /// Security-gated: requires file-write capability. fs-remove-dir-all: func(path: string) -> result; + + /// Resolve a path to its canonical absolute form, following symlinks. + /// Mirrors `std::fs::canonicalize`. + /// + /// The returned path is guaranteed to resolve within the same VFS + /// scheme as the input. Resolutions that would escape the input's + /// VFS scope return an error rather than leak host-filesystem + /// information. + /// Security-gated: requires file-read capability. + fs-canonicalize: func(path: string) -> result; + + /// Read the target of a symbolic link without following it. + /// Mirrors `std::fs::read_link`. + /// + /// The returned target is guaranteed to resolve within the same VFS + /// scheme as the input. Links pointing outside the scope return an + /// error. + /// Security-gated: requires file-read capability. + fs-read-link: func(path: string) -> result; + + // ----------------------------------------------------------------- + // File handles — for streamed and positional I/O on large files + // ----------------------------------------------------------------- + // + // The whole-file `read-file` / `write-file` / `fs-append` calls have + // a 10 MB upper bound; handle-based I/O is the escape valve for + // capsules processing large blobs (logs, models, databases, parquet, + // image/video media). Max 16 open file handles per capsule. + + /// Open a file and return a handle. Mirrors `std::fs::OpenOptions`. + /// + /// `mode` controls read/write intent (see `open-mode`). The handle + /// must be closed with `fs-close` when no longer needed; the host + /// reaps handles on capsule unload. + /// Security-gated: requires file-read and/or file-write capability + /// matching the open mode. + fs-open: func(path: string, mode: open-mode) -> result; + + /// Read up to `max-bytes` from the file at byte offset `offset`. + /// Mirrors POSIX `pread(2)`. Returns the bytes read; an empty list + /// signals EOF at the requested offset. + /// + /// Per-call payload capped at 1 MB so the WASM linear memory stays + /// healthy on large files; callers loop with advancing offsets. + /// Security-gated: the handle must have been opened with read + /// permission. + fs-read-at: func(handle: u64, offset: u64, max-bytes: u32) -> result, string>; + + /// Write `data` to the file at byte offset `offset`. Mirrors POSIX + /// `pwrite(2)`. Returns the number of bytes actually written. + /// + /// Per-call payload capped at 1 MB. + /// Security-gated: the handle must have been opened with write + /// permission. + fs-write-at: func(handle: u64, offset: u64, data: list) -> result; + + /// Flush buffered writes for `handle` to disk. Mirrors + /// `std::fs::File::sync_data`. + /// Security-gated: the handle must have been opened with write + /// permission. + fs-flush: func(handle: u64) -> result<_, string>; + + /// Close a file handle. Idempotent — closing an already-closed + /// handle is a no-op. + fs-close: func(handle: u64) -> result<_, string>; } diff --git a/host/http@1.0.0.wit b/host/http@1.0.0.wit index 17bdab4..632e5bb 100644 --- a/host/http@1.0.0.wit +++ b/host/http@1.0.0.wit @@ -25,8 +25,11 @@ interface host { method: string, /// Request headers as key-value pairs. headers: list, - /// Optional request body. - body: option, + /// Optional request body as raw bytes. JSON/text callers + /// encode their string payload to UTF-8 bytes; binary uploads + /// (image, protobuf, multipart) pass their bytes directly + /// without lossy string conversion. + body: option>, } /// HTTP response returned by the host. diff --git a/host/kv@1.0.0.wit b/host/kv@1.0.0.wit index b6f0608..93b6fb3 100644 --- a/host/kv@1.0.0.wit +++ b/host/kv@1.0.0.wit @@ -22,11 +22,30 @@ interface host { /// Delete a key. kv-delete: func(key: string) -> result<_, string>; - /// List all keys matching a prefix. + /// A page of keys returned by `kv-list-keys-page`. + record key-page { + /// Keys matching the prefix in this page. + keys: list, + /// Cursor to pass to the next `kv-list-keys-page` call. `none` + /// when this is the last page. + next-cursor: option, + } + + /// List all keys matching a prefix. Convenience for small stores + /// where the caller is sure the result fits. /// - /// Returns a list of matching key strings. + /// Capped server-side at 1024 keys per call — larger result sets + /// return an error directing the caller to `kv-list-keys-page`. kv-list-keys: func(prefix: string) -> result, string>; + /// Paginated key listing for unbounded stores. Mirrors a generic + /// cursor-based listing API. + /// + /// Pass `none` for `cursor` on the first call and the cursor + /// returned in `next-cursor` on subsequent calls. `limit` is capped + /// at 1024 per page; 0 means "use the server default." + kv-list-keys-page: func(prefix: string, cursor: option, limit: u32) -> result; + /// Delete all keys matching a prefix. /// /// Returns the count of deleted keys. diff --git a/host/net@1.0.0.wit b/host/net@1.0.0.wit index f79a055..437edf3 100644 --- a/host/net@1.0.0.wit +++ b/host/net@1.0.0.wit @@ -40,10 +40,10 @@ interface host { /// Bind and activate the pre-provisioned Unix socket listener. /// - /// Security-gated: requires net-bind capability. The listener handle - /// argument is ignored (single pre-bound listener per capsule). - /// Returns a listener handle. - net-bind-unix: func(listener-handle: u64) -> result; + /// Security-gated: requires net-bind capability. The kernel binds a + /// single Unix listener per capsule at load time and returns its + /// handle on activation. + net-bind-unix: func() -> result; /// Blocking accept: wait for the next client connection. /// @@ -52,11 +52,12 @@ interface host { /// the capsule is unloaded. Returns a stream handle ID. net-accept: func(listener-handle: u64) -> result; - /// Non-blocking accept with a 10ms timeout. + /// Polling accept with a caller-controlled timeout. /// - /// Returns a stream handle ID if a connection was accepted, - /// or none if no pending connections or the capsule is unloading. - net-poll-accept: func(listener-handle: u64) -> result, string>; + /// Blocks up to `timeout-ms` (capped at 60,000 by the host) waiting + /// for the next client connection. Returns a stream handle ID if a + /// connection arrived in that window, or `none` if it didn't. + net-poll-accept: func(listener-handle: u64, timeout-ms: u64) -> result, string>; /// Read the next length-prefixed frame from a stream. /// @@ -229,9 +230,8 @@ interface host { /// Receive up to `max-bytes` from the socket. Blocks up to the /// socket's read timeout (defaults to non-blocking ~50 ms poll). - /// Returns an empty data list with sentinel peer (`""`/`0`) if no - /// datagram was available. - udp-recv-from: func(socket: u64, max-bytes: u32) -> result; + /// Returns `none` if no datagram was available in the window. + udp-recv-from: func(socket: u64, max-bytes: u32) -> result, string>; /// Set the read timeout for `udp-recv-from`. `none` clears it /// (recv returns immediately when no data is queued). @@ -242,4 +242,17 @@ interface host { /// Close a UDP socket handle. Idempotent. udp-close: func(socket: u64) -> result<_, string>; + + /// Resolve a hostname to a list of `"ip"` or `"ip:port"` strings + /// (the latter when the input was `"host:port"`). Mirrors + /// `std::net::ToSocketAddrs::to_socket_addrs`. + /// + /// The SSRF airlock applies to each resolved IP — private, + /// loopback, link-local, multicast, and unspecified ranges are + /// stripped from the returned list. An empty list means resolution + /// succeeded but every result was airlock-rejected. + /// + /// Security-gated: requires `net_connect` or `net_udp` capability. + /// Hostnames must match an entry in either allowlist. + net-lookup-host: func(host: string) -> result, string>; } diff --git a/host/process@1.0.0.wit b/host/process@1.0.0.wit index 6e8bac5..6021c78 100644 --- a/host/process@1.0.0.wit +++ b/host/process@1.0.0.wit @@ -47,8 +47,29 @@ interface host { stdout: string, /// Captured standard error. stderr: string, - /// Process exit code (-1 if killed or unknown). - exit-code: s32, + /// Exit code if the process exited normally. `none` if the + /// process was killed (no exit code available) or the platform + /// did not surface one. + exit-code: option, + } + + /// Signal a background process can receive (Unix semantics; the + /// kernel maps to the closest equivalent on Windows). + /// + /// `kill` (SIGKILL) is a separate function because it is non- + /// graceful and drains stdout/stderr buffers; `signal` is fire-and- + /// forget for graceful or user-defined signals. + enum process-signal { + /// SIGTERM — graceful shutdown request. + term, + /// SIGHUP — reload configuration. + hup, + /// SIGUSR1 — user-defined. + usr1, + /// SIGUSR2 — user-defined. + usr2, + /// SIGINT — interrupt (Ctrl-C equivalent). + int, } /// Result of spawning a background process. @@ -104,15 +125,38 @@ interface host { /// Sends SIGKILL to the process group (Unix) or kill (Windows). kill: func(process-id: u64) -> result; - /// Signal a background process by symbolic name. + /// Send a signal to a background process. /// - /// Accepted names (Unix semantics): `"term"` (SIGTERM, default - /// graceful shutdown), `"hup"` (SIGHUP, reload), `"usr1"` (SIGUSR1), - /// `"usr2"` (SIGUSR2), `"int"` (SIGINT). On Windows the kernel maps - /// to the closest equivalent; unsupported signals return an error. + /// See `process-signal` for the accepted set. `kill` (SIGKILL) + /// remains a separate function for non-graceful termination with + /// log drainage. + signal: func(process-id: u64, sig: process-signal) -> result<_, string>; + + /// Write to the stdin of a running background process. Mirrors + /// `std::process::ChildStdin::write_all`. + /// + /// Useful for REPL-style children (`python -i`, `psql`, MCP stdio + /// subprocesses). Returns the number of bytes actually written. + /// Per-call payload capped at 1 MB so callers loop with buffered + /// writes for large inputs. + /// Security-gated: the process must have been spawned with + /// `stdin: option>` set or implicitly granted by the + /// `host_process` capability. + process-write-stdin: func(process-id: u64, data: list) -> result; + + /// Close the stdin pipe of a running background process. The child + /// observes EOF on subsequent reads. Mirrors dropping + /// `std::process::ChildStdin`. + /// Security-gated: requires `host_process` capability. + process-close-stdin: func(process-id: u64) -> result<_, string>; + + /// Wait for a background process to exit. Mirrors + /// `std::process::Child::{wait, try_wait}`. /// - /// `kill` (SIGKILL) remains a separate function because it is - /// non-graceful and returns drained stdout/stderr buffers; `signal` - /// is fire-and-forget. - signal: func(process-id: u64, signal-name: string) -> result<_, string>; + /// `timeout-ms` of `none` waits indefinitely; pass a bounded value + /// to drive request-response patterns that mustn't hang on a + /// runaway child. Returns the exit code if the process terminated + /// within the window (matching `process-result.exit-code` shape), + /// or `none` if the timeout elapsed first. + process-wait: func(process-id: u64, timeout-ms: option) -> result, string>; } diff --git a/host/sys@1.0.0.wit b/host/sys@1.0.0.wit index bbfe2c2..67f231d 100644 --- a/host/sys@1.0.0.wit +++ b/host/sys@1.0.0.wit @@ -48,9 +48,11 @@ interface host { /// Read a configuration value from the capsule's manifest `[config]`. /// - /// Returns the raw config value string, or empty string if not set. - /// String values are returned without JSON-encoding (no extra quotes). - get-config: func(key: string) -> result; + /// Returns the raw config value, or `none` if the key is not set in + /// the manifest. String values are returned without JSON-encoding + /// (no extra quotes); the empty string is a valid value distinct + /// from `none`. + get-config: func(key: string) -> result, string>; /// Get the caller context for the current invocation. /// From 22b26d14dffc5530ec89b8de3ce6d63e95f50335 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 21 May 2026 23:28:04 +0400 Subject: [PATCH 08/14] feat(net): add TCP keepalive + linger socket options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You're right that capsules use sockets — both Unix and TCP. Deferring keepalive and linger to 1.x was wrong: long-lived connections to message brokers, databases, IPC bridges (cli↔daemon, hook-bridge) need keepalive to detect silently-dead peers without holding the stream open indefinitely, and linger control matters for fast-shutdown and reconnect cycles. Added four setsockopt-shaped functions: - net-set-keepalive(handle, keepalive-secs: option) — enables TCP keepalive with the given probe interval; none disables. Errors on Unix-domain handles (keepalive is TCP-only). - net-keepalive(handle) — returns the current setting. - net-set-linger(handle, linger-ms: option) — none = default graceful FIN; some(0) = immediate RST drop unsent; some(t) = wait up to t ms for buffer drain. Mirrors std::net::TcpStream::set_linger. - net-linger(handle) — returns the current value. Not adding: SO_REUSEADDR (no TCP listener yet, Unix listener does unlink+rebind instead), SO_BROADCAST / multicast (deferred with UDP specifics until a use case lands), SO_RCVBUF / SO_SNDBUF (rare-need performance tuning, easy 1.x add). net fn count: 28 → 32. --- host/net@1.0.0.wit | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/host/net@1.0.0.wit b/host/net@1.0.0.wit index 437edf3..2ae1206 100644 --- a/host/net@1.0.0.wit +++ b/host/net@1.0.0.wit @@ -192,6 +192,32 @@ interface host { /// Mirrors `std::net::TcpStream::ttl`. net-ttl: func(stream-handle: u64) -> result; + /// Enable TCP keepalive on `stream`. `none` disables; `some(secs)` + /// turns it on with that interval between probes. Required for + /// long-lived TCP connections (message brokers, DBs, IPC bridges) + /// to detect silently-dead peers — without it, a half-dead + /// connection holds the stream open until the next explicit write. + /// + /// Mirrors `socket2::SockRef::set_tcp_keepalive`. Returns an error + /// for Unix-domain stream handles (keepalive is TCP-only). + net-set-keepalive: func(stream-handle: u64, keepalive-secs: option) -> result<_, string>; + + /// Return the current keepalive setting, or `none` if disabled. + net-keepalive: func(stream-handle: u64) -> result, string>; + + /// Set `SO_LINGER` on `stream`. `none` clears it (default graceful + /// FIN close); `some(0)` causes an immediate RST with unsent data + /// dropped on close; `some(t)` waits up to `t` ms for unsent data + /// to drain before closing. Mirrors `std::net::TcpStream::set_linger`. + /// + /// Useful for capsules tearing down a connection without waiting + /// for the kernel-side TCP buffer to drain (testing, fast shutdown, + /// resource-constrained reconnect cycles). + net-set-linger: func(stream-handle: u64, linger-ms: option) -> result<_, string>; + + /// Return the current `SO_LINGER` value, or `none` if unset. + net-linger: func(stream-handle: u64) -> result, string>; + // ----------------------------------------------------------------- // UDP — datagram sockets // ----------------------------------------------------------------- From 9ab4226a6dddd738754014d91f3ecb20c98a1bd3 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 22 May 2026 00:05:52 +0400 Subject: [PATCH 09/14] build: vendor wasi-io@0.2.0; add validate-wit.sh with deps/ staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upcoming WIT freeze-prep refactor adds cross-package use clauses (notably `use wasi:io/poll@0.2.0.{pollable}` for the pollable foundation on net + ipc subscription handles). wasm-tools 1.x resolves WIT dependencies from a sibling deps/ directory, so we vendor wasi-io@0.2.0 under deps/wasi-io/ — poll, streams, error, world. Locked at the official 0.2.0 release tag. New scripts/validate-wit.sh stages each host/*.wit alongside the vendored deps/ and sibling host/ files into a tempdir before parsing, so cross-package use clauses resolve. wasm-tools' directory parse treats a dir as a single package and can't handle our flat host/ tree where each file is its own package; staging per-file works around it. interfaces/*.wit files are not validated here — they cross-reference each other and wasm-tools 1.x's single-pass deps loader chokes on the alphabetical ordering (tries to load astrid:context before its astrid:types dep). Downstream SDK builds (cargo-component / wkg) do proper topological resolution and validate that side. CI workflow updated to call the script. --- .github/workflows/lint.yml | 24 +--- deps/wasi-io/error.wit | 34 +++++ deps/wasi-io/poll.wit | 41 ++++++ deps/wasi-io/streams.wit | 262 +++++++++++++++++++++++++++++++++++++ deps/wasi-io/world.wit | 6 + scripts/validate-wit.sh | 89 +++++++++++++ 6 files changed, 439 insertions(+), 17 deletions(-) create mode 100644 deps/wasi-io/error.wit create mode 100644 deps/wasi-io/poll.wit create mode 100644 deps/wasi-io/streams.wit create mode 100644 deps/wasi-io/world.wit create mode 100755 scripts/validate-wit.sh diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5877a4f..c7b4d33 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -37,20 +37,10 @@ jobs: run: cargo install --locked wasm-tools - name: Parse every host/*.wit file - # Only host/ — each host package is self-contained (no cross-package - # `use`), so single-file mode works. interfaces/ files cross-reference - # each other (e.g. `use astrid:types/types.{...}`) and would require a - # deps/ tree to resolve; they predate this CI and are validated by - # downstream SDK builds. - run: | - fail=0 - for f in host/*.wit; do - if wasm-tools component wit "$f" >/dev/null 2>&1; then - echo "OK: $f" - else - echo "FAIL: $f" - wasm-tools component wit "$f" 2>&1 | head -20 - fail=1 - fi - done - exit $fail + # Stages each host/*.wit alongside vendored deps/ (wasi-io) + + # sibling host/ files so cross-package `use` clauses resolve. + # interfaces/*.wit files are not validated here — they cross- + # reference each other and wasm-tools 1.x can't topo-sort deps/ + # in a single pass; downstream SDK builds (cargo-component / wkg) + # validate them with proper resolution. + run: scripts/validate-wit.sh diff --git a/deps/wasi-io/error.wit b/deps/wasi-io/error.wit new file mode 100644 index 0000000..22e5b64 --- /dev/null +++ b/deps/wasi-io/error.wit @@ -0,0 +1,34 @@ +package wasi:io@0.2.0; + + +interface error { + /// A resource which represents some error information. + /// + /// The only method provided by this resource is `to-debug-string`, + /// which provides some human-readable information about the error. + /// + /// In the `wasi:io` package, this resource is returned through the + /// `wasi:io/streams/stream-error` type. + /// + /// To provide more specific error information, other interfaces may + /// provide functions to further "downcast" this error into more specific + /// error information. For example, `error`s returned in streams derived + /// from filesystem types to be described using the filesystem's own + /// error-code type, using the function + /// `wasi:filesystem/types/filesystem-error-code`, which takes a parameter + /// `borrow` and returns + /// `option`. + /// + /// The set of functions which can "downcast" an `error` into a more + /// concrete type is open. + resource error { + /// Returns a string that is suitable to assist humans in debugging + /// this error. + /// + /// WARNING: The returned string should not be consumed mechanically! + /// It may change across platforms, hosts, or other implementation + /// details. Parsing this string is a major platform-compatibility + /// hazard. + to-debug-string: func() -> string; + } +} diff --git a/deps/wasi-io/poll.wit b/deps/wasi-io/poll.wit new file mode 100644 index 0000000..ddc67f8 --- /dev/null +++ b/deps/wasi-io/poll.wit @@ -0,0 +1,41 @@ +package wasi:io@0.2.0; + +/// A poll API intended to let users wait for I/O events on multiple handles +/// at once. +interface poll { + /// `pollable` represents a single I/O event which may be ready, or not. + resource pollable { + + /// Return the readiness of a pollable. This function never blocks. + /// + /// Returns `true` when the pollable is ready, and `false` otherwise. + ready: func() -> bool; + + /// `block` returns immediately if the pollable is ready, and otherwise + /// blocks until ready. + /// + /// This function is equivalent to calling `poll.poll` on a list + /// containing only this pollable. + block: func(); + } + + /// Poll for completion on a set of pollables. + /// + /// This function takes a list of pollables, which identify I/O sources of + /// interest, and waits until one or more of the events is ready for I/O. + /// + /// The result `list` contains one or more indices of handles in the + /// argument list that is ready for I/O. + /// + /// If the list contains more elements than can be indexed with a `u32` + /// value, this function traps. + /// + /// A timeout can be implemented by adding a pollable from the + /// wasi-clocks API to the list. + /// + /// This function does not return a `result`; polling in itself does not + /// do any I/O so it doesn't fail. If any of the I/O sources identified by + /// the pollables has an error, it is indicated by marking the source as + /// being reaedy for I/O. + poll: func(in: list>) -> list; +} diff --git a/deps/wasi-io/streams.wit b/deps/wasi-io/streams.wit new file mode 100644 index 0000000..6d2f871 --- /dev/null +++ b/deps/wasi-io/streams.wit @@ -0,0 +1,262 @@ +package wasi:io@0.2.0; + +/// WASI I/O is an I/O abstraction API which is currently focused on providing +/// stream types. +/// +/// In the future, the component model is expected to add built-in stream types; +/// when it does, they are expected to subsume this API. +interface streams { + use error.{error}; + use poll.{pollable}; + + /// An error for input-stream and output-stream operations. + variant stream-error { + /// The last operation (a write or flush) failed before completion. + /// + /// More information is available in the `error` payload. + last-operation-failed(error), + /// The stream is closed: no more input will be accepted by the + /// stream. A closed output-stream will return this error on all + /// future operations. + closed + } + + /// An input bytestream. + /// + /// `input-stream`s are *non-blocking* to the extent practical on underlying + /// platforms. I/O operations always return promptly; if fewer bytes are + /// promptly available than requested, they return the number of bytes promptly + /// available, which could even be zero. To wait for data to be available, + /// use the `subscribe` function to obtain a `pollable` which can be polled + /// for using `wasi:io/poll`. + resource input-stream { + /// Perform a non-blocking read from the stream. + /// + /// When the source of a `read` is binary data, the bytes from the source + /// are returned verbatim. When the source of a `read` is known to the + /// implementation to be text, bytes containing the UTF-8 encoding of the + /// text are returned. + /// + /// This function returns a list of bytes containing the read data, + /// when successful. The returned list will contain up to `len` bytes; + /// it may return fewer than requested, but not more. The list is + /// empty when no bytes are available for reading at this time. The + /// pollable given by `subscribe` will be ready when more bytes are + /// available. + /// + /// This function fails with a `stream-error` when the operation + /// encounters an error, giving `last-operation-failed`, or when the + /// stream is closed, giving `closed`. + /// + /// When the caller gives a `len` of 0, it represents a request to + /// read 0 bytes. If the stream is still open, this call should + /// succeed and return an empty list, or otherwise fail with `closed`. + /// + /// The `len` parameter is a `u64`, which could represent a list of u8 which + /// is not possible to allocate in wasm32, or not desirable to allocate as + /// as a return value by the callee. The callee may return a list of bytes + /// less than `len` in size while more bytes are available for reading. + read: func( + /// The maximum number of bytes to read + len: u64 + ) -> result, stream-error>; + + /// Read bytes from a stream, after blocking until at least one byte can + /// be read. Except for blocking, behavior is identical to `read`. + blocking-read: func( + /// The maximum number of bytes to read + len: u64 + ) -> result, stream-error>; + + /// Skip bytes from a stream. Returns number of bytes skipped. + /// + /// Behaves identical to `read`, except instead of returning a list + /// of bytes, returns the number of bytes consumed from the stream. + skip: func( + /// The maximum number of bytes to skip. + len: u64, + ) -> result; + + /// Skip bytes from a stream, after blocking until at least one byte + /// can be skipped. Except for blocking behavior, identical to `skip`. + blocking-skip: func( + /// The maximum number of bytes to skip. + len: u64, + ) -> result; + + /// Create a `pollable` which will resolve once either the specified stream + /// has bytes available to read or the other end of the stream has been + /// closed. + /// The created `pollable` is a child resource of the `input-stream`. + /// Implementations may trap if the `input-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + subscribe: func() -> pollable; + } + + + /// An output bytestream. + /// + /// `output-stream`s are *non-blocking* to the extent practical on + /// underlying platforms. Except where specified otherwise, I/O operations also + /// always return promptly, after the number of bytes that can be written + /// promptly, which could even be zero. To wait for the stream to be ready to + /// accept data, the `subscribe` function to obtain a `pollable` which can be + /// polled for using `wasi:io/poll`. + resource output-stream { + /// Check readiness for writing. This function never blocks. + /// + /// Returns the number of bytes permitted for the next call to `write`, + /// or an error. Calling `write` with more bytes than this function has + /// permitted will trap. + /// + /// When this function returns 0 bytes, the `subscribe` pollable will + /// become ready when this function will report at least 1 byte, or an + /// error. + check-write: func() -> result; + + /// Perform a write. This function never blocks. + /// + /// When the destination of a `write` is binary data, the bytes from + /// `contents` are written verbatim. When the destination of a `write` is + /// known to the implementation to be text, the bytes of `contents` are + /// transcoded from UTF-8 into the encoding of the destination and then + /// written. + /// + /// Precondition: check-write gave permit of Ok(n) and contents has a + /// length of less than or equal to n. Otherwise, this function will trap. + /// + /// returns Err(closed) without writing if the stream has closed since + /// the last call to check-write provided a permit. + write: func( + contents: list + ) -> result<_, stream-error>; + + /// Perform a write of up to 4096 bytes, and then flush the stream. Block + /// until all of these operations are complete, or an error occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write`, and `flush`, and is implemented with the + /// following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while !contents.is_empty() { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, contents.len()); + /// let (chunk, rest) = contents.split_at(len); + /// this.write(chunk ); // eliding error handling + /// contents = rest; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + blocking-write-and-flush: func( + contents: list + ) -> result<_, stream-error>; + + /// Request to flush buffered output. This function never blocks. + /// + /// This tells the output-stream that the caller intends any buffered + /// output to be flushed. the output which is expected to be flushed + /// is all that has been passed to `write` prior to this call. + /// + /// Upon calling this function, the `output-stream` will not accept any + /// writes (`check-write` will return `ok(0)`) until the flush has + /// completed. The `subscribe` pollable will become ready when the + /// flush has completed and the stream can accept more writes. + flush: func() -> result<_, stream-error>; + + /// Request to flush buffered output, and block until flush completes + /// and stream is ready for writing again. + blocking-flush: func() -> result<_, stream-error>; + + /// Create a `pollable` which will resolve once the output-stream + /// is ready for more writing, or an error has occured. When this + /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an + /// error. + /// + /// If the stream is closed, this pollable is always ready immediately. + /// + /// The created `pollable` is a child resource of the `output-stream`. + /// Implementations may trap if the `output-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + subscribe: func() -> pollable; + + /// Write zeroes to a stream. + /// + /// This should be used precisely like `write` with the exact same + /// preconditions (must use check-write first), but instead of + /// passing a list of bytes, you simply pass the number of zero-bytes + /// that should be written. + write-zeroes: func( + /// The number of zero-bytes to write + len: u64 + ) -> result<_, stream-error>; + + /// Perform a write of up to 4096 zeroes, and then flush the stream. + /// Block until all of these operations are complete, or an error + /// occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with + /// the following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while num_zeroes != 0 { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, num_zeroes); + /// this.write-zeroes(len); // eliding error handling + /// num_zeroes -= len; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + blocking-write-zeroes-and-flush: func( + /// The number of zero-bytes to write + len: u64 + ) -> result<_, stream-error>; + + /// Read from one stream and write to another. + /// + /// The behavior of splice is equivelant to: + /// 1. calling `check-write` on the `output-stream` + /// 2. calling `read` on the `input-stream` with the smaller of the + /// `check-write` permitted length and the `len` provided to `splice` + /// 3. calling `write` on the `output-stream` with that read data. + /// + /// Any error reported by the call to `check-write`, `read`, or + /// `write` ends the splice and reports that error. + /// + /// This function returns the number of bytes transferred; it may be less + /// than `len`. + splice: func( + /// The stream to read from + src: borrow, + /// The number of bytes to splice + len: u64, + ) -> result; + + /// Read from one stream and write to another, with blocking. + /// + /// This is similar to `splice`, except that it blocks until the + /// `output-stream` is ready for writing, and the `input-stream` + /// is ready for reading, before performing the `splice`. + blocking-splice: func( + /// The stream to read from + src: borrow, + /// The number of bytes to splice + len: u64, + ) -> result; + } +} diff --git a/deps/wasi-io/world.wit b/deps/wasi-io/world.wit new file mode 100644 index 0000000..5f0b43f --- /dev/null +++ b/deps/wasi-io/world.wit @@ -0,0 +1,6 @@ +package wasi:io@0.2.0; + +world imports { + import streams; + import poll; +} diff --git a/scripts/validate-wit.sh b/scripts/validate-wit.sh new file mode 100755 index 0000000..789748c --- /dev/null +++ b/scripts/validate-wit.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# validate-wit.sh — parse every host/*.wit and interfaces/*.wit by +# staging each file alongside the repo's vendored deps/ tree so cross- +# package `use` clauses (e.g. `use wasi:io/poll@0.2.0.{pollable};`) +# resolve. +# +# wasm-tools resolves WIT dependencies from a sibling `deps/` directory. +# Each WIT file declares its own package, so we stage one tempdir per +# file: `/main.wit` + `/deps/...`. The shared deps live in +# `/deps/` and are vendored at fixed versions. +# +# Usage: scripts/validate-wit.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEPS_ROOT="$REPO_ROOT/deps" + +fail=0 +shopt -s nullglob + +# For each main file we want to parse, stage: +# main.wit <- the file under test +# deps/wasi-io/*.wit <- vendored WASI +# deps/astrid-/*.wit <- every OTHER astrid WIT file in the repo, +# so cross-package `use` clauses resolve. +# +# Each WIT file declares its own package, so deps/ ends up with one +# subdirectory per other package in the workspace. + +stage_other_astrid_deps() { + local main_file="$1" + local staging="$2" + local sibling_dir + # host/ files only see other host/ files; interfaces/ files only see + # other interfaces/. The two are distinct universes — host/ is the + # kernel ABI (capsules import), interfaces/ is capsule-to-capsule + # IPC contracts (the SDK generates typed events from these). Some + # package names overlap by accident; keeping the dep sets separate + # avoids validation conflicts. + case "$main_file" in + "$REPO_ROOT"/host/*) sibling_dir="$REPO_ROOT/host" ;; + "$REPO_ROOT"/interfaces/*) sibling_dir="$REPO_ROOT/interfaces" ;; + *) return ;; + esac + local f base pkg + for f in "$sibling_dir"/*.wit; do + [[ "$f" == "$main_file" ]] && continue + base="$(basename "$f" .wit)" + pkg="${base%@*}" + mkdir -p "$staging/deps/astrid-$pkg" + cp "$f" "$staging/deps/astrid-$pkg/$base.wit" + done +} + +# Only validate host/. interfaces/*.wit files have cross-package use +# clauses that wasm-tools 1.x can't resolve via deps/ in a single pass +# (it walks deps/ alphabetically and fails when a file references a +# package not yet processed). Those files are validated by downstream +# SDK builds via cargo-component / wkg which do proper topological +# resolution. +for f in "$REPO_ROOT"/host/*.wit; do + rel="${f#$REPO_ROOT/}" + staging="$(mktemp -d)" + trap 'rm -rf "$staging"' EXIT + cp "$f" "$staging/main.wit" + mkdir -p "$staging/deps" + if [[ -d "$DEPS_ROOT" ]]; then + # Each vendored dependency lives in its own subdir under deps/. + for d in "$DEPS_ROOT"/*/; do + [[ -d "$d" ]] || continue + # Strip trailing slash so cp -R copies the directory itself, + # not just its contents. + cp -R "${d%/}" "$staging/deps/" + done + fi + stage_other_astrid_deps "$f" "$staging" + if wasm-tools component wit "$staging" >/dev/null 2>&1; then + echo "OK: $rel" + else + echo "FAIL: $rel" + wasm-tools component wit "$staging" 2>&1 | head -20 | sed 's/^/ /' + fail=1 + fi + rm -rf "$staging" + trap - EXIT +done +exit $fail From bf00f0c657d99b33b08ffa6377bf47e812d3ea8e Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 22 May 2026 00:07:03 +0400 Subject: [PATCH 10/14] refactor(bus)!: move capsule-to-capsule contracts to astrid-bus:* namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-freeze namespace split. host/*.wit (kernel ABI — direct wasmtime CM linker calls) keeps astrid:*. interfaces/*.wit (capsule-to-capsule schemas for events on the IPC bus) moves to astrid-bus:*. The two were colliding on astrid:approval@1.0.0 and astrid:elicit@1.0.0 where the same name covered two distinct concerns (kernel-mediated approval gate vs capsule↔capsule approval-event schemas) — a capsule importing both would have hit a name conflict in its world. The rename resolves the collisions and codifies the layering for every future package. All 17 interfaces/*.wit files renamed: astrid:agent → astrid-bus:agent astrid:approval → astrid-bus:approval (was colliding with host/approval) astrid:client → astrid-bus:client astrid:context → astrid-bus:context astrid:elicit → astrid-bus:elicit (was colliding with host/elicit) astrid:hook → astrid-bus:hook astrid:llm → astrid-bus:llm astrid:onboarding → astrid-bus:onboarding astrid:prompt → astrid-bus:prompt astrid:registry → astrid-bus:registry astrid:session → astrid-bus:session astrid:spark → astrid-bus:spark astrid:system → astrid-bus:system astrid:tool → astrid-bus:tool astrid:types → astrid-bus:types astrid:user → astrid-bus:user astrid:users → astrid-bus:users Cross-references inside interfaces/ (e.g. `use astrid:types/types` in llm.wit, session.wit, prompt.wit, tool.wit, context.wit; `use astrid:registry/registry` in onboarding.wit; `use astrid:onboarding/ onboarding` in elicit.wit) updated to astrid-bus:* in the same pass. README rewritten to document the two-namespace model: astrid:* for kernel-mediated syscalls, astrid-bus:* for IPC-bus event schemas. Capsule.toml imports/exports now use both prefixes. --- README.md | 38 +++++++++++++++++++++++--------------- interfaces/agent.wit | 2 +- interfaces/approval.wit | 2 +- interfaces/client.wit | 2 +- interfaces/context.wit | 4 ++-- interfaces/elicit.wit | 4 ++-- interfaces/hook.wit | 2 +- interfaces/llm.wit | 6 +++--- interfaces/onboarding.wit | 2 +- interfaces/prompt.wit | 4 ++-- interfaces/registry.wit | 2 +- interfaces/session.wit | 4 ++-- interfaces/spark.wit | 2 +- interfaces/system.wit | 2 +- interfaces/tool.wit | 4 ++-- interfaces/types.wit | 2 +- interfaces/user.wit | 2 +- interfaces/users.wit | 2 +- 18 files changed, 47 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index a3c4b99..1e64640 100644 --- a/README.md +++ b/README.md @@ -77,37 +77,45 @@ CI enforces this via `scripts/lint-wit-immutability.sh` — any PR that modifies See [RFC: Host ABI](https://github.com/unicity-astrid/rfcs/pull/22) for the full design (per-domain packages, multi-version kernel registration, frozen-file rule) and [issue #750](https://github.com/unicity-astrid/astrid/issues/750) for the motivating bug. -## Capsule interfaces (`interfaces/`) +## Capsule interfaces (`interfaces/`) — the `astrid-bus:*` namespace -The capsule-to-capsule contracts. Capsules declare which they import/export in `Capsule.toml`; the kernel validates at boot that every required import has a matching export. +The capsule-to-capsule contracts. Distinct namespace (`astrid-bus:*`) from the host ABI (`astrid:*`) — the medium is different: host fns are direct wasmtime CM linker calls, bus interfaces are schemas for events that flow over the IPC bus between capsules. Capsules declare which they import/export in `Capsule.toml`; the kernel validates at boot that every required import has a matching export. | File | Package | Description | |------|---------|-------------| -| `interfaces/llm.wit` | `astrid:llm@1.0.0` | LLM generation requests and streaming responses | -| `interfaces/session.wit` | `astrid:session@1.0.0` | Conversation session storage and retrieval | -| `interfaces/spark.wit` | `astrid:spark@1.0.0` | Agent identity and system prompt construction | -| `interfaces/context.wit` | `astrid:context@1.0.0` | Context window compaction and management | -| `interfaces/prompt.wit` | `astrid:prompt@1.0.0` | Prompt assembly from components | -| `interfaces/tool.wit` | `astrid:tool@1.0.0` | Tool dispatch and execution results | -| `interfaces/hook.wit` | `astrid:hook@1.0.0` | Lifecycle hook fan-out and response collection | -| `interfaces/registry.wit` | `astrid:registry@1.0.0` | Model registry operations | -| `interfaces/types.wit` | `astrid:types@1.0.0` | Shared types used across interfaces | -| `interfaces/users.wit` | `astrid:users@1.0.0` | Within-principal user identity store — platform-to-AstridUserId mapping | +| `interfaces/llm.wit` | `astrid-bus:llm@1.0.0` | LLM generation requests and streaming responses | +| `interfaces/session.wit` | `astrid-bus:session@1.0.0` | Conversation session storage and retrieval | +| `interfaces/spark.wit` | `astrid-bus:spark@1.0.0` | Agent identity and system prompt construction | +| `interfaces/context.wit` | `astrid-bus:context@1.0.0` | Context window compaction and management | +| `interfaces/prompt.wit` | `astrid-bus:prompt@1.0.0` | Prompt assembly from components | +| `interfaces/tool.wit` | `astrid-bus:tool@1.0.0` | Tool dispatch and execution results | +| `interfaces/hook.wit` | `astrid-bus:hook@1.0.0` | Lifecycle hook fan-out and response collection | +| `interfaces/registry.wit` | `astrid-bus:registry@1.0.0` | Model registry operations | +| `interfaces/types.wit` | `astrid-bus:types@1.0.0` | Shared types used across bus interfaces | +| `interfaces/users.wit` | `astrid-bus:users@1.0.0` | Within-principal user identity store — platform-to-AstridUserId mapping | + +(Plus `agent`, `approval`, `client`, `elicit`, `onboarding`, `system`, `user` — see `interfaces/` for the full set.) ## How capsules use these -Capsules declare which interfaces they import and export in their `Capsule.toml`: +Capsules declare which host packages and bus interfaces they import/export in their `Capsule.toml`. The two namespaces stay distinct: ```toml +# Host ABI — kernel-mediated syscalls. [imports.astrid] +fs = "1.0.0" +ipc = "1.0.0" + +# Capsule-to-capsule event schemas, on the IPC bus. +[imports.astrid-bus] llm = "^1.0" session = { version = "^1.0", optional = true } -[exports.astrid] +[exports.astrid-bus] session = "1.0.0" ``` -The kernel validates at boot that every required import has a matching export from another loaded capsule. The WIT files define the message schemas carried over the IPC bus. +The kernel validates at boot that every required `astrid-bus` import has a matching `astrid-bus` export from another loaded capsule. The WIT files in `interfaces/` define the message schemas carried over the IPC bus. ## How SDKs use these diff --git a/interfaces/agent.wit b/interfaces/agent.wit index 28e0bc9..78cb7f7 100644 --- a/interfaces/agent.wit +++ b/interfaces/agent.wit @@ -1,4 +1,4 @@ -package astrid:agent@1.0.0; +package astrid-bus:agent@1.0.0; /// Agent interface — runtime envelopes the agent loop publishes for uplinks. /// diff --git a/interfaces/approval.wit b/interfaces/approval.wit index 7d8e578..ef18288 100644 --- a/interfaces/approval.wit +++ b/interfaces/approval.wit @@ -1,4 +1,4 @@ -package astrid:approval@1.0.0; +package astrid-bus:approval@1.0.0; /// Approval interface — human-in-the-loop authorization envelopes. /// diff --git a/interfaces/client.wit b/interfaces/client.wit index 5a2db1a..846e218 100644 --- a/interfaces/client.wit +++ b/interfaces/client.wit @@ -1,4 +1,4 @@ -package astrid:client@1.0.0; +package astrid-bus:client@1.0.0; /// Client interface — connection lifecycle envelopes for uplinks. /// diff --git a/interfaces/context.wit b/interfaces/context.wit index 6720f03..b06e76a 100644 --- a/interfaces/context.wit +++ b/interfaces/context.wit @@ -1,4 +1,4 @@ -package astrid:context@1.0.0; +package astrid-bus:context@1.0.0; /// Context interface — conversation compaction and token management. /// @@ -6,7 +6,7 @@ package astrid:context@1.0.0; /// requests and publishes compaction responses and hooks for plugin /// capsules to influence compaction behaviour. interface context { - use astrid:types/types.{message}; + use astrid-bus:types/types.{message}; /// Request to compact messages within a token budget. record compact-request { diff --git a/interfaces/elicit.wit b/interfaces/elicit.wit index fa0f3f1..3ce6cfa 100644 --- a/interfaces/elicit.wit +++ b/interfaces/elicit.wit @@ -1,4 +1,4 @@ -package astrid:elicit@1.0.0; +package astrid-bus:elicit@1.0.0; /// Elicit interface — interactive user input during a capsule lifecycle hook. /// @@ -8,7 +8,7 @@ package astrid:elicit@1.0.0; /// `elicit` syscall (which is the in-capsule API) — these envelopes carry /// the same flow over the IPC bus for uplink consumption. interface elicit { - use astrid:onboarding/onboarding.{field}; + use astrid-bus:onboarding/onboarding.{field}; /// A lifecycle hook is requesting user input. record request { diff --git a/interfaces/hook.wit b/interfaces/hook.wit index 780e39f..a92fadf 100644 --- a/interfaces/hook.wit +++ b/interfaces/hook.wit @@ -1,4 +1,4 @@ -package astrid:hook@1.0.0; +package astrid-bus:hook@1.0.0; /// Hook interface — maps kernel lifecycle events to semantic hooks. /// diff --git a/interfaces/llm.wit b/interfaces/llm.wit index 2b4242b..d48117d 100644 --- a/interfaces/llm.wit +++ b/interfaces/llm.wit @@ -1,12 +1,12 @@ -package astrid:llm@1.0.0; +package astrid-bus:llm@1.0.0; /// LLM interface — streaming text generation with tool use support. /// /// A capsule exporting this interface handles `llm.v1.request.generate.*` /// events and publishes `llm.v1.stream.*` streaming responses. interface llm { - use astrid:types/types.{message, tool-definition, usage}; - use astrid:registry/registry.{provider-entry}; + use astrid-bus:types/types.{message, tool-definition, usage}; + use astrid-bus:registry/registry.{provider-entry}; /// Request asking an LLM-provider capsule to describe itself. /// Topic: `llm.v1.request.describe`. The registry capsule fans this diff --git a/interfaces/onboarding.wit b/interfaces/onboarding.wit index b0eaa3c..5021330 100644 --- a/interfaces/onboarding.wit +++ b/interfaces/onboarding.wit @@ -1,4 +1,4 @@ -package astrid:onboarding@1.0.0; +package astrid-bus:onboarding@1.0.0; /// Onboarding interface — env-var collection at install / first-run. /// diff --git a/interfaces/prompt.wit b/interfaces/prompt.wit index 0e954e3..0d45382 100644 --- a/interfaces/prompt.wit +++ b/interfaces/prompt.wit @@ -1,4 +1,4 @@ -package astrid:prompt@1.0.0; +package astrid-bus:prompt@1.0.0; /// Prompt interface — assembles LLM prompts with plugin hooks. /// @@ -6,7 +6,7 @@ package astrid:prompt@1.0.0; /// and publishes assembled prompts, with before/after hooks for context /// injection by plugin capsules. interface prompt { - use astrid:types/types.{message, tool-definition}; + use astrid-bus:types/types.{message, tool-definition}; /// Request to assemble a prompt for LLM generation. record assemble-request { diff --git a/interfaces/registry.wit b/interfaces/registry.wit index 2283848..cc21ad9 100644 --- a/interfaces/registry.wit +++ b/interfaces/registry.wit @@ -1,4 +1,4 @@ -package astrid:registry@1.0.0; +package astrid-bus:registry@1.0.0; /// Registry interface — LLM provider discovery and model management. /// diff --git a/interfaces/session.wit b/interfaces/session.wit index 59f7bf9..493e8ed 100644 --- a/interfaces/session.wit +++ b/interfaces/session.wit @@ -1,4 +1,4 @@ -package astrid:session@1.0.0; +package astrid-bus:session@1.0.0; /// Session protocol — persistent conversation history management. /// @@ -6,7 +6,7 @@ package astrid:session@1.0.0; /// events and publishes `session.v1.response.*` replies scoped by /// correlation ID. interface session { - use astrid:types/types.{message}; + use astrid-bus:types/types.{message}; /// Request to fetch conversation history. record get-messages-request { diff --git a/interfaces/spark.wit b/interfaces/spark.wit index 05c0803..ee26e0e 100644 --- a/interfaces/spark.wit +++ b/interfaces/spark.wit @@ -1,4 +1,4 @@ -package astrid:spark@1.0.0; +package astrid-bus:spark@1.0.0; /// Spark interface — agent persona and system prompt construction. /// diff --git a/interfaces/system.wit b/interfaces/system.wit index 699450c..8de64b3 100644 --- a/interfaces/system.wit +++ b/interfaces/system.wit @@ -1,4 +1,4 @@ -package astrid:system@1.0.0; +package astrid-bus:system@1.0.0; /// System interface — kernel-emitted lifecycle, health, and CLI envelopes. /// diff --git a/interfaces/tool.wit b/interfaces/tool.wit index 55f1af4..eea9d64 100644 --- a/interfaces/tool.wit +++ b/interfaces/tool.wit @@ -1,4 +1,4 @@ -package astrid:tool@1.0.0; +package astrid-bus:tool@1.0.0; /// Tool interface — dispatches tool calls to capsule handlers. /// @@ -6,7 +6,7 @@ package astrid:tool@1.0.0; /// routes to the appropriate tool capsule via `tool.v1.execute.`, /// and collects results on `tool.v1.execute.*.result`. interface tool { - use astrid:types/types.{tool-call, tool-call-result, tool-definition}; + use astrid-bus:types/types.{tool-call, tool-call-result, tool-definition}; /// Request to cancel in-flight tool executions. record cancel-request { diff --git a/interfaces/types.wit b/interfaces/types.wit index 7d1bf69..3e306ed 100644 --- a/interfaces/types.wit +++ b/interfaces/types.wit @@ -1,4 +1,4 @@ -package astrid:types@1.0.0; +package astrid-bus:types@1.0.0; /// Shared types used across Astrid interfaces. /// diff --git a/interfaces/user.wit b/interfaces/user.wit index d688ca5..1367d81 100644 --- a/interfaces/user.wit +++ b/interfaces/user.wit @@ -1,4 +1,4 @@ -package astrid:user@1.0.0; +package astrid-bus:user@1.0.0; /// User interface — input envelopes from a human via an uplink. /// diff --git a/interfaces/users.wit b/interfaces/users.wit index 4514329..ff69080 100644 --- a/interfaces/users.wit +++ b/interfaces/users.wit @@ -1,4 +1,4 @@ -package astrid:users@1.0.0; +package astrid-bus:users@1.0.0; /// Users interface — within-principal user identity store. /// From 1341eb25597f75be474639dabefa430cf2231673 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 22 May 2026 00:07:40 +0400 Subject: [PATCH 11/14] =?UTF-8?q?feat(host)!:=20Bucket=20A=20pass=20on=20f?= =?UTF-8?q?s=20+=20net=20=E2=80=94=20error=20variants,=20resource=20types,?= =?UTF-8?q?=20pollable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first of two freeze-prep commits adopting the audit recommendations for the host ABI. Replaces stringly-typed errors, flat u64 handles, and per-call timeout-based readiness with typed alternatives that the Component Model ecosystem expects. astrid:fs@1.0.0: - New `variant error-code { not-found, access, capability-denied, boundary-escape, invalid-path, would-block, is-directory, not-directory, not-empty, too-large, quota, cross-vfs, already-exists, closed, unknown(string) }` replaces every `result<_, string>` return. - New `resource file-handle` with methods read-at / write-at / sync-data / sync-all / stat / set-len. Replaces the flat-handle fs-read-at / fs-write-at / fs-flush / fs-close functions. Folds in Bucket C primitives (sync-all, set-len, fstat-on-handle) as resource methods so capsules get them automatically. Per-capsule cap: 16 open handles. Drop is automatic via the CM runtime — no manual close needed. - New `enum file-type { type-unknown, regular, directory, symlink, block-device, character-device, fifo, socket }` replaces the is-dir / is-file / is-symlink boolean trio in file-stat. Matches wasi-filesystem/types.descriptor-type. - New `record datetime { seconds: s64, nanoseconds: u32 }` for file timestamps; matches wasi-clocks/wall-clock.datetime. Fixes the year-2554 overflow and supports pre-1970 timestamps. file-stat now carries modified / created / accessed as option. - Doc-as-contract additions: paths/error strings are guaranteed VFS-scheme only (no host real-paths leak); kernel re-resolves and re-validates every path on every call (so fs-canonicalize is for display/equality only, NOT a TOCTOU-safe security check); error strings never contain UUIDs/IPs/capability-names. astrid:net@1.0.0: - New `variant error-code` per the network domain (would-block, closed, capability-denied, airlock-rejected, connection-refused, connection-reset, timeout, address-in-use, address-not-available, name-unresolvable, invalid-handle, not-tcp, quota, unknown(string)). - Three new resources replacing flat u64 handles: - `resource unix-listener` (accept / poll-accept / subscribe- readiness) from bind-unix. - `resource tcp-stream` with all the read/write/peek/shutdown/ accessor methods and TCP options as methods. Used for both accepted Unix streams and outbound TCP. - `resource udp-socket` for UDP send-to / recv-from + options. - New `subscribe-readiness` / `subscribe-readable` methods on each resource return `wasi:io/poll@0.2.0.pollable` so capsules can multiplex via `wasi:io/poll.poll`. This is the Bucket E pollable foundation: bridge capsules (cli, hook-bridge, future telegram/ discord) can compose 'wait on Unix socket OR IPC bus' natively without embedding an async runtime in the wasm binary purely for multiplexing. - shutdown-how arm names switched to receive / send / both to match wasi:sockets idiom. - TTL accessors renamed to hop-limit / set-hop-limit to match wasi:sockets (TTL is v4 naming; hop-limit is the standardized v4+v6 name). - `net-poll-accept` gains a caller-controlled timeout-ms arg as a resource method instead of the previous hardcoded 10 ms. - `udp-recv-from` returns `option` instead of the ('', 0)-sentinel pattern. 10 host/ files remain for the next Bucket A commit: ipc, kv, sys, process, http, approval, elicit, identity, uplink, guest. Same pattern. --- host/fs@1.0.0.wit | 384 +++++++++++++++++++++--------------- host/net@1.0.0.wit | 481 ++++++++++++++++++++++----------------------- 2 files changed, 458 insertions(+), 407 deletions(-) diff --git a/host/fs@1.0.0.wit b/host/fs@1.0.0.wit index 248fc09..d590902 100644 --- a/host/fs@1.0.0.wit +++ b/host/fs@1.0.0.wit @@ -1,9 +1,16 @@ /// Filesystem operations within the capsule's workspace boundary. /// -/// All paths are resolved relative to the workspace root. The `home://` -/// URI scheme accesses the principal's home directory. Paths under `/tmp/` -/// access per-principal temporary storage. Attempts to escape any boundary -/// return an error. Symlink traversal is canonicalized to prevent bypass. +/// All paths are resolved relative to a VFS scheme (`workspace://`, +/// `home://`, `tmp://`). The kernel re-resolves and re-validates every +/// path on every call — `fs-canonicalize` is for display and equality +/// only, NOT a one-time security check that subsequent calls can rely +/// on. Symlink traversal is canonicalized to prevent bypass; results +/// returned from `fs-canonicalize` and `fs-read-link` are always +/// VFS-scheme paths (e.g. `workspace://foo`), never host real-paths. +/// +/// Error strings (the `unknown(string)` arm of `error-code`) never +/// contain host real-paths, IP addresses, UUIDs, or capability names — +/// no host-side information leaks through error messages. /// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. @@ -11,7 +18,110 @@ package astrid:fs@1.0.0; interface host { - /// Mode for `fs-open`. + // ----------------------------------------------------------------- + // Error type + // ----------------------------------------------------------------- + + /// Typed error returned from every fallible fs operation. Specific + /// variants for the common cases; `unknown(string)` carries the raw + /// host detail for everything else. Capsules pattern-match on the + /// specific arms and log/propagate the unknown arm. + variant error-code { + /// File or directory not found. + not-found, + /// Host filesystem denied permission (distinct from + /// `capability-denied` which is Astrid's gate). + access, + /// Capsule lacks the required capability for this operation + /// (e.g. `fs_write` to a `home://` path). + capability-denied, + /// Path resolved outside the VFS scope — sandbox violation. + /// Audit-logged at high severity. + boundary-escape, + /// Path string failed validation (NUL byte, control chars, not + /// UTF-8 NFC, exceeded max length, malformed VFS scheme). + invalid-path, + /// Operation would block on a non-blocking handle. + would-block, + /// Target is a directory where a file was expected. + is-directory, + /// Target is a regular file where a directory was expected. + not-directory, + /// Directory is not empty (for non-recursive removes). + not-empty, + /// Payload exceeded the per-call cap (`read-file` / `write-file` + /// hard 10 MB; `read-at` / `write-at` hard 1 MB) or cumulative + /// per-capsule write quota. + too-large, + /// Server-side resource quota exhausted (open handles, total + /// file count under a VFS scheme). + quota, + /// Cross-VFS-scheme rename / link attempted (e.g. workspace:// + /// to home://). Refused to preserve atomic-rename contracts. + cross-vfs, + /// File or directory already exists where exclusive creation + /// was requested. + already-exists, + /// File handle has been closed (Drop'd) and is no longer valid. + closed, + /// Unspecific I/O error from the host. Detail is best-effort and + /// not part of the contract — capsules log it but do not branch + /// on its content. + unknown(string), + } + + // ----------------------------------------------------------------- + // Value types + // ----------------------------------------------------------------- + + /// File kind. Matches `wasi:filesystem/types.descriptor-type`. + enum file-type { + /// Type could not be determined (rare; non-POSIX hosts). + type-unknown, + /// Regular file. + regular, + /// Directory. + directory, + /// Symbolic link (only seen via `fs-stat-symlink`; following + /// stat resolves the link's target type). + symlink, + /// Block device. + block-device, + /// Character device. + character-device, + /// Named pipe (FIFO). + fifo, + /// Unix-domain socket. + socket, + } + + /// Timestamp matching `wasi:clocks/wall-clock.datetime`. Signed + /// seconds allow pre-1970 timestamps (archive restores, etc.). + record datetime { + seconds: s64, + nanoseconds: u32, + } + + /// File metadata returned by `fs-stat`, `fs-stat-symlink`, and + /// `file-handle.stat`. + record file-stat { + /// File size in bytes. + size: u64, + /// Kind of file (regular / directory / symlink / device / etc.). + kind: file-type, + /// POSIX mode bits (file type + permissions). Zero on platforms + /// without a mode concept; best-effort cross-platform. + mode: u32, + /// Last modification time, if available. + modified: option, + /// Creation (birth) time, if available. + created: option, + /// Last access time, if available. + accessed: option, + } + + /// Open mode for `fs-open`. Matches the common subset of + /// `std::fs::OpenOptions`. enum open-mode { /// Open existing file for reading only. Fails if absent. read, @@ -19,173 +129,133 @@ interface host { write, /// Open or create for writing; appends to existing content. append, - /// Open existing file for both reading and writing. Fails if absent. + /// Open existing file for both reading and writing. Fails if + /// absent. read-write, } - /// File metadata returned by `fs-stat` and `fs-stat-symlink`. - record file-stat { - /// File size in bytes. - size: u64, - /// Whether this entry is a directory. - is-dir: bool, - /// Whether this entry is a regular file. - is-file: bool, - /// Whether this entry is a symbolic link. `is-symlink` and either - /// `is-dir` or `is-file` may both be true when the link is followed - /// by `fs-stat`; `fs-stat-symlink` (lstat) reports the link itself. - is-symlink: bool, - /// POSIX mode bits (file type + permissions). Zero on platforms - /// without a mode concept. - mode: u32, - /// Last modification time, nanoseconds since UNIX epoch. `none` - /// if the host filesystem doesn't track it. - mtime-ns: option, - /// Creation time (birth time), nanoseconds since UNIX epoch. - ctime-ns: option, - /// Last access time, nanoseconds since UNIX epoch. - atime-ns: option, - } + // ----------------------------------------------------------------- + // File handle resource + // ----------------------------------------------------------------- - /// Check whether a file or directory exists at the given path. + /// An open file handle. Returned from `fs-open`. The host releases + /// the underlying file descriptor automatically when the resource + /// is dropped (the guest's component-model runtime invokes the + /// destructor) — capsules don't need to close handles explicitly. /// - /// Returns true if the path exists, false otherwise. - /// Security-gated: requires file-read capability. - fs-exists: func(path: string) -> result; + /// Per-capsule cap: 16 open file handles. + resource file-handle { + /// Read up to `max-bytes` from the file at byte `offset`. + /// Mirrors POSIX `pread(2)`. Returns the bytes read; an empty + /// list signals EOF at the requested offset. Per-call payload + /// capped at 1 MB. + read-at: func(offset: u64, max-bytes: u32) -> result, error-code>; - /// Create a directory (and parents) at the given path. - /// - /// Security-gated: requires file-write capability. - fs-mkdir: func(path: string) -> result<_, string>; + /// Write `data` to the file at byte `offset`. Mirrors POSIX + /// `pwrite(2)`. Returns the number of bytes actually written. + /// Per-call payload capped at 1 MB; cumulative writes counted + /// against the per-capsule write quota. + write-at: func(offset: u64, data: list) -> result; - /// List entries in a directory. - /// - /// Returns a list of entry name strings. - /// Security-gated: requires file-read capability. - fs-readdir: func(path: string) -> result, string>; + /// Flush buffered data to disk (`fdatasync(2)` — data only, not + /// metadata). Mirrors `std::fs::File::sync_data`. + sync-data: func() -> result<_, error-code>; - /// Get file metadata (size, type, mtime). Follows symlinks. - /// - /// Security-gated: requires file-read capability. - fs-stat: func(path: string) -> result; + /// Flush both data and metadata to disk (`fsync(2)`). Mirrors + /// `std::fs::File::sync_all`. Required for durability-sensitive + /// workloads (databases, WALs); slower than `sync-data`. + sync-all: func() -> result<_, error-code>; - /// Like `fs-stat` but does NOT follow symlinks — reports the symlink - /// itself when `path` is a link. Mirrors `std::fs::symlink_metadata`. - /// - /// Security-gated: requires file-read capability. - fs-stat-symlink: func(path: string) -> result; + /// Get file metadata on the open handle (`fstat(2)`). Race-free + /// counterpart to `fs-stat(path)`; safe to call after `fs-open` + /// without re-resolving the path. + stat: func() -> result; - /// Remove a file at the given path. - /// - /// Security-gated: requires file-write capability. - fs-unlink: func(path: string) -> result<_, string>; + /// Truncate or extend the file to `size` bytes (`ftruncate(2)`). + /// Mirrors `std::fs::File::set_len`. Extending past end fills + /// with zeros. + set-len: func(size: u64) -> result<_, error-code>; + } - /// Read a file's contents as bytes. - /// - /// Files larger than 10 MB are rejected. Paths are resolved through - /// the VFS layer with sandbox boundary enforcement. - /// Security-gated: requires file-read capability. - read-file: func(path: string) -> result, string>; + // ----------------------------------------------------------------- + // Path-based operations + // ----------------------------------------------------------------- - /// Write content to a file (truncating if it exists, creating if not). - /// - /// Security-gated: requires file-write capability. - write-file: func(path: string, content: list) -> result<_, string>; + /// Open a file and return a handle. Mirrors `std::fs::OpenOptions`. + /// Required capability depends on `mode` (read modes need + /// `fs_read`; write/append/read-write need `fs_write`). + fs-open: func(path: string, mode: open-mode) -> result; - /// Append content to a file, creating it if it does not exist. - /// - /// Logs, journals, and other accumulating writes use this to avoid the - /// read-modify-write hazard of `read-file` + `write-file`. Subject to - /// the same 10 MB upper bound on the appended chunk as `write-file`; - /// callers writing large logs split into multiple appends. - /// Security-gated: requires file-write capability. - fs-append: func(path: string, content: list) -> result<_, string>; - - /// Copy a file from `src` to `dst`. Both paths resolve through the - /// VFS, so cross-scheme copies are subject to the same capability - /// checks as a `read-file` of `src` plus a `write-file` of `dst`. - /// Overwrites `dst` if it exists. Directory copies are not supported; - /// `src` must name a regular file. - /// Security-gated: requires file-read + file-write capabilities. - fs-copy: func(src: string, dst: string) -> result<_, string>; - - /// Rename (move) a file or directory from `src` to `dst`. Both paths - /// must resolve to the same VFS scheme — cross-scheme renames are - /// rejected because they would require a copy + delete and break the - /// atomic-rename contract callers expect. - /// Security-gated: requires file-write capability on both paths. - fs-rename: func(src: string, dst: string) -> result<_, string>; - - /// Remove a directory and all its contents recursively. - /// - /// Refuses to traverse symlinks (no following) to prevent escapes - /// outside the VFS sandbox. Returns the count of removed entries - /// (files + subdirectories). - /// Security-gated: requires file-write capability. - fs-remove-dir-all: func(path: string) -> result; - - /// Resolve a path to its canonical absolute form, following symlinks. - /// Mirrors `std::fs::canonicalize`. - /// - /// The returned path is guaranteed to resolve within the same VFS - /// scheme as the input. Resolutions that would escape the input's - /// VFS scope return an error rather than leak host-filesystem - /// information. - /// Security-gated: requires file-read capability. - fs-canonicalize: func(path: string) -> result; + /// Check whether a path exists. Returns true / false / error + /// (rather than collapsing access-denied into "doesn't exist"). + fs-exists: func(path: string) -> result; - /// Read the target of a symbolic link without following it. - /// Mirrors `std::fs::read_link`. - /// - /// The returned target is guaranteed to resolve within the same VFS - /// scheme as the input. Links pointing outside the scope return an - /// error. - /// Security-gated: requires file-read capability. - fs-read-link: func(path: string) -> result; + /// Create a directory and all missing parents. Mirrors + /// `std::fs::create_dir_all`. Idempotent — succeeds if the + /// directory already exists. + fs-mkdir: func(path: string) -> result<_, error-code>; - // ----------------------------------------------------------------- - // File handles — for streamed and positional I/O on large files - // ----------------------------------------------------------------- - // - // The whole-file `read-file` / `write-file` / `fs-append` calls have - // a 10 MB upper bound; handle-based I/O is the escape valve for - // capsules processing large blobs (logs, models, databases, parquet, - // image/video media). Max 16 open file handles per capsule. + /// List entries in a directory. Returns entry names (not full + /// paths). Per-call cap: 4096 entries; larger directories must use + /// `fs-readdir-page` (not in 1.0; tracked for 1.x). + fs-readdir: func(path: string) -> result, error-code>; - /// Open a file and return a handle. Mirrors `std::fs::OpenOptions`. - /// - /// `mode` controls read/write intent (see `open-mode`). The handle - /// must be closed with `fs-close` when no longer needed; the host - /// reaps handles on capsule unload. - /// Security-gated: requires file-read and/or file-write capability - /// matching the open mode. - fs-open: func(path: string, mode: open-mode) -> result; - - /// Read up to `max-bytes` from the file at byte offset `offset`. - /// Mirrors POSIX `pread(2)`. Returns the bytes read; an empty list - /// signals EOF at the requested offset. - /// - /// Per-call payload capped at 1 MB so the WASM linear memory stays - /// healthy on large files; callers loop with advancing offsets. - /// Security-gated: the handle must have been opened with read - /// permission. - fs-read-at: func(handle: u64, offset: u64, max-bytes: u32) -> result, string>; - - /// Write `data` to the file at byte offset `offset`. Mirrors POSIX - /// `pwrite(2)`. Returns the number of bytes actually written. + /// Get file metadata. Follows symlinks (`stat(2)`). + fs-stat: func(path: string) -> result; + + /// Get file metadata without following symlinks (`lstat(2)`). + /// Mirrors `std::fs::symlink_metadata`. Use when you specifically + /// want to inspect a symlink rather than its target. + fs-stat-symlink: func(path: string) -> result; + + /// Remove a regular file. Mirrors `std::fs::remove_file`. Returns + /// `is-directory` if the path names a directory. + fs-unlink: func(path: string) -> result<_, error-code>; + + /// Read a whole file as bytes. Convenience wrapper around + /// `fs-open` + `file-handle.read-at`. Files larger than 10 MB are + /// rejected with `too-large`; use `fs-open` + handle reads for + /// streaming over large files. + read-file: func(path: string) -> result, error-code>; + + /// Write content to a file (truncate-or-create). Mirrors + /// `std::fs::write`. Capped at 10 MB per call; cumulative against + /// the per-capsule write quota. + write-file: func(path: string, content: list) -> result<_, error-code>; + + /// Append content to a file, creating it if absent. Avoids the + /// read-modify-write hazard of `read-file` + `write-file` for + /// log-style writes. 10 MB per call; cumulative against the + /// per-capsule write quota. + fs-append: func(path: string, content: list) -> result<_, error-code>; + + /// Copy a file from `src` to `dst`. Mirrors `std::fs::copy`. + /// Overwrites `dst`. Directory copies are not supported; `src` + /// must name a regular file. + fs-copy: func(src: string, dst: string) -> result<_, error-code>; + + /// Rename (move) within the same VFS scheme. Mirrors + /// `std::fs::rename`. Cross-scheme renames return `cross-vfs`. + fs-rename: func(src: string, dst: string) -> result<_, error-code>; + + /// Remove a directory and all its contents recursively. Refuses + /// to traverse symlinks to prevent sandbox escapes. Returns the + /// count of removed entries (files + subdirectories). + fs-remove-dir-all: func(path: string) -> result; + + /// Resolve a path to its canonical form, following symlinks. + /// Returns a VFS-scheme path (`workspace://`, `home://`, `tmp://`), + /// NEVER a host real-path. Resolutions escaping the input's VFS + /// scope return `boundary-escape`. /// - /// Per-call payload capped at 1 MB. - /// Security-gated: the handle must have been opened with write - /// permission. - fs-write-at: func(handle: u64, offset: u64, data: list) -> result; - - /// Flush buffered writes for `handle` to disk. Mirrors - /// `std::fs::File::sync_data`. - /// Security-gated: the handle must have been opened with write - /// permission. - fs-flush: func(handle: u64) -> result<_, string>; - - /// Close a file handle. Idempotent — closing an already-closed - /// handle is a no-op. - fs-close: func(handle: u64) -> result<_, string>; + /// Note: `fs-canonicalize` is for display and path-equality only. + /// The result is NOT a security check that subsequent calls can + /// trust — the kernel re-resolves every path on every call, so + /// `fs-canonicalize` then `fs-open` is NOT TOCTOU-safe. + fs-canonicalize: func(path: string) -> result; + + /// Read the target of a symbolic link without following it. + /// Mirrors `std::fs::read_link`. Returns a VFS-scheme path; links + /// pointing outside the input's scope return `boundary-escape`. + fs-read-link: func(path: string) -> result; } diff --git a/host/net@1.0.0.wit b/host/net@1.0.0.wit index 2ae1206..31494d6 100644 --- a/host/net@1.0.0.wit +++ b/host/net@1.0.0.wit @@ -1,12 +1,14 @@ -/// Networking — Unix domain sockets and outbound TCP. +/// Networking — Unix domain sockets, outbound TCP, UDP, and DNS resolution. /// /// The kernel pre-binds a single `UnixListener` per capsule. Capsules /// accept client connections via the provided listener, then read/write /// length-prefixed frames. Session token handshake authentication is -/// enforced on each accepted connection. Outbound TCP connect is gated -/// by `net_connect` allowlists and DNS airlock checks. +/// enforced on each accepted connection. Outbound TCP and UDP are gated +/// by `net_connect` / `net_udp` allowlists and the SSRF airlock blocks +/// private/loopback/link-local/multicast/unspecified IPs on resolved +/// peer addresses. /// -/// Max 8 concurrent streams per capsule. +/// Max 8 concurrent TCP streams + 4 UDP sockets per capsule. /// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. @@ -14,7 +16,57 @@ package astrid:net@1.0.0; interface host { - /// Status of a network read operation. + use wasi:io/poll@0.2.0.{pollable}; + + // ----------------------------------------------------------------- + // Error type + // ----------------------------------------------------------------- + + /// Typed error returned from every fallible net operation. + variant error-code { + /// Operation would block (non-blocking reads with no data, + /// non-blocking accept with no pending). + would-block, + /// Stream / socket has been closed by the peer or locally. + closed, + /// Capsule lacks the required capability. + capability-denied, + /// Peer address resolved into the SSRF-blocked range + /// (private/loopback/link-local/multicast/unspecified). + airlock-rejected, + /// Connect attempt refused by peer. + connection-refused, + /// Established connection was reset. + connection-reset, + /// Connect / read / write exceeded the call's timeout. + timeout, + /// Local address already in use (rare for outbound; possible + /// for UDP binds). + address-in-use, + /// Local address can't be bound (parsing error, unsupported + /// family). + address-not-available, + /// DNS resolution returned no results, or all results were + /// airlock-rejected. + name-unresolvable, + /// Stream / socket handle has been dropped and is no longer + /// valid. + invalid-handle, + /// Tried to call a TCP-only option on a Unix-domain stream + /// (e.g. `set-nodelay`, `keepalive`, `set-hop-limit`). + not-tcp, + /// Server-side resource quota exhausted (open streams, open + /// UDP sockets). + quota, + /// Unspecific I/O error from the host. Detail is best-effort. + unknown(string), + } + + // ----------------------------------------------------------------- + // Value types + // ----------------------------------------------------------------- + + /// Status of a length-prefixed framed read. variant net-read-status { /// Data frame received. data(list), @@ -24,261 +76,190 @@ interface host { pending, } - /// Direction argument for `net-shutdown` — mirrors `std::net::Shutdown`. + /// Direction argument for `tcp-stream.shutdown`. Mirrors + /// `wasi:sockets` `shutdown-type` (and `std::net::Shutdown`). enum shutdown-how { - /// Half-close the read side. Subsequent reads return EOF; writes - /// continue. - read, - /// Half-close the write side. The peer sees EOF on its read side; - /// reads continue. - write, - /// Close both directions. Equivalent to `net-close-stream` except - /// the handle entry is retained so getters (`peer-addr`, etc.) - /// still work until the explicit close call. + /// Half-close the read side. Subsequent reads return EOF. + receive, + /// Half-close the write side. Peer sees EOF on its read side. + send, + /// Close both directions. both, } - /// Bind and activate the pre-provisioned Unix socket listener. - /// - /// Security-gated: requires net-bind capability. The kernel binds a - /// single Unix listener per capsule at load time and returns its - /// handle on activation. - net-bind-unix: func() -> result; - - /// Blocking accept: wait for the next client connection. - /// - /// Performs peer credential verification (UID match on Unix) and - /// session token handshake. Blocks until a connection arrives or - /// the capsule is unloaded. Returns a stream handle ID. - net-accept: func(listener-handle: u64) -> result; - - /// Polling accept with a caller-controlled timeout. - /// - /// Blocks up to `timeout-ms` (capped at 60,000 by the host) waiting - /// for the next client connection. Returns a stream handle ID if a - /// connection arrived in that window, or `none` if it didn't. - net-poll-accept: func(listener-handle: u64, timeout-ms: u64) -> result, string>; - - /// Read the next length-prefixed frame from a stream. - /// - /// Returns Data with payload, Closed if peer disconnected, or - /// Pending if no data available. Max frame size: 10 MB. - net-read: func(stream-handle: u64) -> result; - - /// Write a length-prefixed frame to a stream. - /// - /// Non-fatal on failure (dead stream is cleaned up on next read). - net-write: func(stream-handle: u64, data: list) -> result<_, string>; - - /// Close a stream handle, releasing host-side resources. - /// - /// Idempotent: closing an already-closed handle is a no-op. - /// Publishes a `client.v1.disconnect` IPC event. - net-close-stream: func(stream-handle: u64) -> result<_, string>; - - /// Open an outbound TCP connection to `host:port`. - /// - /// Returns a stream handle compatible with the existing - /// `net-read` / `net-write` / `net-close-stream` functions - /// — the same handle type returned by `net-accept`. - /// - /// Security gates (all checked before any TCP syscall): - /// - The capsule's `net_connect` capability allowlist must - /// contain a pattern matching `host:port`. Patterns are - /// `"host:port"` exact matches or `"host:*"` (any port for - /// the named host). Missing or empty allowlist denies all - /// outbound TCP (fail-closed). - /// - DNS resolution runs after the capability check. The - /// resolved IP is rejected if it falls into a private, - /// loopback, link-local, multicast, or unspecified range, - /// matching the airlock that gates `http-request` / - /// `http-stream-start`. - /// - The capsule's per-instance active-stream cap (default - /// 8, shared with `net-accept`) is enforced. - /// - Connect attempts time out (default 10s) rather than - /// holding the WASM guest in a host fn indefinitely. - net-connect-tcp: func(host: string, port: u16) -> result; - - /// Read up to `max-bytes` from `stream` without length-prefix framing. - /// - /// Mirrors `std::net::TcpStream::read` (and `::read`). - /// Returns the bytes that were available — may be shorter than - /// `max-bytes`. Empty list means no data is ready under the current - /// read timeout (default: non-blocking, ~50 ms internal poll). - /// - /// Use this for byte-stream protocols (WebSocket, MQTT, postgres, - /// HTTP/1.1, TLS, …). The existing `net-read` keeps the length-prefix - /// framing required by the uplink-proxy use case. - net-read-bytes: func(stream-handle: u64, max-bytes: u32) -> result, string>; - - /// Write `data` to `stream` without length-prefix framing. - /// - /// Mirrors `::write`. Returns the number of - /// bytes actually written — may be less than `data.len()` when the - /// kernel-side socket buffer is full. Callers loop until `data` is - /// drained (the SDK `write_all` wrapper handles this). - net-write-bytes: func(stream-handle: u64, data: list) -> result; - - /// Read up to `max-bytes` from `stream` without consuming them. - /// - /// Mirrors `std::net::TcpStream::peek`. Subsequent `net-read-bytes` - /// returns the same bytes again. Useful for protocol detection on - /// the first frame of a connection. - net-peek: func(stream-handle: u64, max-bytes: u32) -> result, string>; - - /// Shut down the read side, write side, or both halves of `stream`. - /// - /// Mirrors `std::net::TcpStream::shutdown`. After `shutdown-how::write` - /// the peer sees EOF on its read side; further sends on the local - /// side return an error. `shutdown-how::both` closes both directions - /// but leaves the handle entry intact so accessors (e.g. - /// `peer-addr`) still work until `net-close-stream` is called. - net-shutdown: func(stream-handle: u64, how: shutdown-how) -> result<_, string>; - - /// Return the remote peer address as `"ip:port"`. - /// - /// Mirrors `std::net::TcpStream::peer_addr`. Returns an error for - /// Unix-domain stream handles. - net-peer-addr: func(stream-handle: u64) -> result; - - /// Return the local socket address as `"ip:port"`. - /// - /// Mirrors `std::net::TcpStream::local_addr`. Returns an error for - /// Unix-domain stream handles. - net-local-addr: func(stream-handle: u64) -> result; - - /// Enable or disable the `TCP_NODELAY` option (Nagle's algorithm off - /// when `true`). - /// - /// Mirrors `std::net::TcpStream::set_nodelay`. Returns an error for - /// Unix-domain stream handles. - net-set-nodelay: func(stream-handle: u64, nodelay: bool) -> result<_, string>; - - /// Return the current `TCP_NODELAY` setting. - /// - /// Mirrors `std::net::TcpStream::nodelay`. - net-nodelay: func(stream-handle: u64) -> result; - - /// Set the read timeout. `none` clears the timeout (reads still - /// honour the host's internal cancellation token; this controls - /// only the user-visible blocking duration of each `net-read-bytes` - /// call). - /// - /// Mirrors `std::net::TcpStream::set_read_timeout`. Subsequent - /// `net-read-bytes` and `net-peek` calls honour the new value. - net-set-read-timeout: func(stream-handle: u64, timeout-ms: option) -> result<_, string>; - - /// Return the current read timeout in milliseconds, or `none` if - /// unset. - net-read-timeout: func(stream-handle: u64) -> result, string>; - - /// Set the write timeout. `none` clears it. - /// - /// Mirrors `std::net::TcpStream::set_write_timeout`. - net-set-write-timeout: func(stream-handle: u64, timeout-ms: option) -> result<_, string>; - - /// Return the current write timeout in milliseconds, or `none` if - /// unset. - net-write-timeout: func(stream-handle: u64) -> result, string>; - - /// Set the IP `TTL` field on outgoing packets. - /// - /// Mirrors `std::net::TcpStream::set_ttl`. Returns an error for - /// Unix-domain stream handles. - net-set-ttl: func(stream-handle: u64, ttl: u32) -> result<_, string>; - - /// Return the current IP TTL. - /// - /// Mirrors `std::net::TcpStream::ttl`. - net-ttl: func(stream-handle: u64) -> result; - - /// Enable TCP keepalive on `stream`. `none` disables; `some(secs)` - /// turns it on with that interval between probes. Required for - /// long-lived TCP connections (message brokers, DBs, IPC bridges) - /// to detect silently-dead peers — without it, a half-dead - /// connection holds the stream open until the next explicit write. - /// - /// Mirrors `socket2::SockRef::set_tcp_keepalive`. Returns an error - /// for Unix-domain stream handles (keepalive is TCP-only). - net-set-keepalive: func(stream-handle: u64, keepalive-secs: option) -> result<_, string>; - - /// Return the current keepalive setting, or `none` if disabled. - net-keepalive: func(stream-handle: u64) -> result, string>; - - /// Set `SO_LINGER` on `stream`. `none` clears it (default graceful - /// FIN close); `some(0)` causes an immediate RST with unsent data - /// dropped on close; `some(t)` waits up to `t` ms for unsent data - /// to drain before closing. Mirrors `std::net::TcpStream::set_linger`. - /// - /// Useful for capsules tearing down a connection without waiting - /// for the kernel-side TCP buffer to drain (testing, fast shutdown, - /// resource-constrained reconnect cycles). - net-set-linger: func(stream-handle: u64, linger-ms: option) -> result<_, string>; - - /// Return the current `SO_LINGER` value, or `none` if unset. - net-linger: func(stream-handle: u64) -> result, string>; - - // ----------------------------------------------------------------- - // UDP — datagram sockets - // ----------------------------------------------------------------- - // - // Mirrors `std::net::UdpSocket`. Gated by a separate `net_udp` - // capability allowlist (distinct from `net_connect` for TCP) so - // capsule authors opt into datagram networking explicitly. The - // SSRF airlock applies on send-to / recv-from: peer addresses - // resolving to private/loopback/link-local/multicast ranges are - // rejected. Max 4 concurrent UDP sockets per capsule. - - /// Datagram returned by `udp-recv-from`. + /// Datagram returned by `udp-socket.recv-from`. record udp-datagram { /// Payload bytes received. data: list, - /// Peer host (numeric `"ip"` form, IPv4 or IPv6). + /// Peer host (numeric IP, IPv4 or IPv6). peer-host: string, /// Peer UDP port. peer-port: u16, } - /// Bind a UDP socket to `host:port`. Pass `"0.0.0.0"` / `"::"` for - /// any interface and `0` for an ephemeral port. Returns a socket - /// handle. - /// - /// Security-gated: requires `net_udp` capability with a matching - /// `bind:host:port` pattern. - udp-bind: func(host: string, port: u16) -> result; - - /// Send `data` to `peer-host:peer-port`. Returns the byte count sent - /// (UDP semantics: short writes are unusual but possible). - /// - /// Security-gated: peer address goes through the SSRF airlock and - /// must match a `send:host:port` pattern in `net_udp`. - udp-send-to: func(socket: u64, data: list, peer-host: string, peer-port: u16) -> result; - - /// Receive up to `max-bytes` from the socket. Blocks up to the - /// socket's read timeout (defaults to non-blocking ~50 ms poll). - /// Returns `none` if no datagram was available in the window. - udp-recv-from: func(socket: u64, max-bytes: u32) -> result, string>; - - /// Set the read timeout for `udp-recv-from`. `none` clears it - /// (recv returns immediately when no data is queued). - udp-set-read-timeout: func(socket: u64, timeout-ms: option) -> result<_, string>; - - /// Return the local socket address as `"ip:port"`. - udp-local-addr: func(socket: u64) -> result; - - /// Close a UDP socket handle. Idempotent. - udp-close: func(socket: u64) -> result<_, string>; - - /// Resolve a hostname to a list of `"ip"` or `"ip:port"` strings - /// (the latter when the input was `"host:port"`). Mirrors - /// `std::net::ToSocketAddrs::to_socket_addrs`. - /// - /// The SSRF airlock applies to each resolved IP — private, - /// loopback, link-local, multicast, and unspecified ranges are - /// stripped from the returned list. An empty list means resolution - /// succeeded but every result was airlock-rejected. - /// - /// Security-gated: requires `net_connect` or `net_udp` capability. - /// Hostnames must match an entry in either allowlist. - net-lookup-host: func(host: string) -> result, string>; + // ----------------------------------------------------------------- + // TCP listener (Unix-domain only in 1.0; TCP listener deferred) + // ----------------------------------------------------------------- + + /// The capsule's pre-bound Unix domain listener. One per capsule; + /// the kernel binds it at load time and the capsule activates it + /// with `bind-unix`. TCP listeners (`bind-tcp`) are deferred — the + /// `inbound webhook receiver` use case can wait for 1.x. + resource unix-listener { + /// Blocking accept. Performs peer credential verification + /// (UID match on Unix) and session token handshake. + accept: func() -> result; + + /// Polling accept with caller-controlled timeout. Returns + /// `none` if no connection arrived within `timeout-ms` + /// (capped at 60_000 by the host). + poll-accept: func(timeout-ms: u64) -> result, error-code>; + + /// Pollable that fires when a connection is ready to be + /// accepted. Compose with other pollables via + /// `wasi:io/poll.poll` for multiplexed I/O. + subscribe-readiness: func() -> pollable; + } + + // ----------------------------------------------------------------- + // TCP stream — Unix-domain and outbound-TCP streams share this type + // ----------------------------------------------------------------- + + /// A bidirectional stream. Used for both accepted Unix-domain + /// connections (`unix-listener.accept`) and outbound TCP + /// (`connect-tcp`). TCP-only options return `not-tcp` on Unix + /// streams. + resource tcp-stream { + // ---- Length-prefixed framed I/O (uplink-proxy use case) ---- + + /// Read the next length-prefixed frame. Returns Data/Closed/ + /// Pending. Max frame size: 10 MB. + read: func() -> result; + + /// Write a length-prefixed frame. Non-fatal on failure (dead + /// stream cleaned up on next read). + write: func(data: list) -> result<_, error-code>; + + // ---- Byte-stream I/O (general protocols) ---- + + /// Read up to `max-bytes` from the stream without length- + /// prefix framing. Mirrors `std::net::TcpStream::read`. Empty + /// list = no data ready within the current read timeout. + read-bytes: func(max-bytes: u32) -> result, error-code>; + + /// Write bytes without length-prefix framing. Mirrors + /// `::write`. Returns bytes actually + /// written (may be < `data.len()` under buffer pressure). + write-bytes: func(data: list) -> result; + + /// Peek without consuming. Mirrors `std::net::TcpStream::peek`. + peek: func(max-bytes: u32) -> result, error-code>; + + // ---- Lifecycle ---- + + /// Shut down read / write / both. Mirrors + /// `std::net::TcpStream::shutdown`. After `shutdown(both)` + /// accessors (`peer-addr` etc.) still work until the stream + /// is dropped. + shutdown: func(how: shutdown-how) -> result<_, error-code>; + + // ---- Address accessors ---- + + /// Remote peer address as `"ip:port"`. Returns `not-tcp` for + /// Unix-domain streams. + peer-addr: func() -> result; + + /// Local socket address as `"ip:port"`. Returns `not-tcp` for + /// Unix-domain streams. + local-addr: func() -> result; + + // ---- TCP socket options (return `not-tcp` for Unix) ---- + + /// Enable / disable `TCP_NODELAY` (Nagle off when true). + set-nodelay: func(nodelay: bool) -> result<_, error-code>; + nodelay: func() -> result; + + /// Read timeout. `none` = no timeout. + set-read-timeout: func(timeout-ms: option) -> result<_, error-code>; + read-timeout: func() -> result, error-code>; + + /// Write timeout. `none` = no timeout. + set-write-timeout: func(timeout-ms: option) -> result<_, error-code>; + write-timeout: func() -> result, error-code>; + + /// IPv6 hop limit / IPv4 TTL (`IP_TTL`/`IPV6_UNICAST_HOPS`). + /// Mirrors WASI `set-hop-limit`. Returns `not-tcp` for Unix + /// streams. + set-hop-limit: func(hops: u32) -> result<_, error-code>; + hop-limit: func() -> result; + + /// TCP keepalive. `none` disables; `some(secs)` enables with + /// the given probe interval. Required for long-lived + /// connections to detect silently-dead peers. + set-keepalive: func(keepalive-secs: option) -> result<_, error-code>; + keepalive: func() -> result, error-code>; + + /// `SO_LINGER`. `none` = default graceful close; `some(0)` = + /// immediate RST drop unsent; `some(t)` = drain up to t ms. + set-linger: func(linger-ms: option) -> result<_, error-code>; + linger: func() -> result, error-code>; + + // ---- Readiness ---- + + /// Pollable that fires when the stream is ready to read. + /// Compose with other pollables for multiplexed I/O. + subscribe-readable: func() -> pollable; + } + + // ----------------------------------------------------------------- + // UDP socket resource + // ----------------------------------------------------------------- + + /// A UDP datagram socket. Bind once; send/recv to/from arbitrary + /// peers (SSRF airlock applies on each peer). Connected-UDP mode + /// (lock to one peer for performance) is deferred to 1.x. + resource udp-socket { + /// Send to a peer. Returns bytes sent. + send-to: func(data: list, peer-host: string, peer-port: u16) -> result; + + /// Receive up to `max-bytes`. `none` if no datagram arrived + /// within the read timeout. + recv-from: func(max-bytes: u32) -> result, error-code>; + + /// Set the read timeout for `recv-from`. + set-read-timeout: func(timeout-ms: option) -> result<_, error-code>; + + /// Local bound address as `"ip:port"`. + local-addr: func() -> result; + + /// Pollable that fires when a datagram is ready to receive. + subscribe-readable: func() -> pollable; + } + + // ----------------------------------------------------------------- + // Factory functions at interface scope + // ----------------------------------------------------------------- + + /// Bind and activate the pre-provisioned Unix listener. The kernel + /// binds it at load time per capsule. + bind-unix: func() -> result; + + /// Open an outbound TCP connection to `host:port`. Goes through + /// the SSRF airlock (resolves DNS, rejects private/loopback/etc.). + /// Requires `net_connect` allowlist match. + connect-tcp: func(host: string, port: u16) -> result; + + /// Bind a UDP socket. `"0.0.0.0"` / `"::"` accepts on any + /// interface (NB: capsule becomes reachable from any peer on the + /// network — restrict via `net_udp` capability if outbound-only). + /// Port `0` selects an ephemeral port. + udp-bind: func(host: string, port: u16) -> result; + + /// Resolve a hostname to a list of `"ip:port"` (or `"ip"` if no + /// port in input) strings. SSRF airlock applies — private, + /// loopback, link-local, multicast, unspecified ranges are + /// stripped silently. Empty list = all results filtered. + /// Requires `net_connect` or `net_udp` capability matching the + /// hostname. + lookup-host: func(host: string) -> result, error-code>; } From cedf14c9cb02dd82682d8d30ee4e412a7bb87e41 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 22 May 2026 00:13:44 +0400 Subject: [PATCH 12/14] feat(host)!: Bucket A pass on remaining 9 host packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the freeze-prep refactor across all host/* WIT files. Applies the same patterns established in fs/net: per-package error-code variants replacing result<_, string>, resource types where there's a long-lived handle, and doc-as-contract additions for audit policy / charset rules / capability gates. astrid:sys@1.0.0: - error-code variant: capability-denied, config-key-reserved, too-large, registry-unavailable, cancelled, unknown(string). - get-config returns result, error-code> distinguishing 'not set' from empty-string values. - random-bytes length: u32 → u64 matching wasi:random. - Per-fn audit policy documented inline. astrid:kv@1.0.0: - error-code: invalid-key, too-large, quota, cas-mismatch, unknown. - Charset rule documented (UTF-8 NFC, no NUL/control, max 256 bytes for keys; 1 MiB max for values). - Per-fn audit policy documented. astrid:approval@1.0.0: - error-code: invalid-input, timeout, store-unavailable, unknown. - approval-decision enum surfaces the specific resolution (denied/approved/approved-session/approved-always/allowance) instead of the old approved: bool — capsule UI can communicate WHY for transparency. astrid:elicit@1.0.0: - error-code: not-in-lifecycle, timeout, cancelled, invalid-input, store-unavailable, unknown. - elicit-type as typed enum (text/secret/select/array) — was string. - elicit-response as variant — value(string) / values(list) / secret-stored — instead of stringly-typed JSON. astrid:identity@1.0.0: - error-code: capability-denied, invalid-input, user-not-found, link-not-found, already-linked, store-unavailable, unknown. - Split the god-record identity-ok-response into per-operation responses (identity-resolve-response, identity-create-user-response, platform-link list from identity-list-links). Replaces JSON-in-WIT with typed records. astrid:uplink@1.0.0: - error-code: capability-denied, invalid-input, invalid-profile, unknown-uplink, no-session, quota, unknown. - uplink-profile as typed enum (chat/interactive/notify/bridge) — was string. astrid:http@1.0.0: - error-code: capability-denied, invalid-request, dns-error, airlock-rejected, tls-error, timeout, connection-error, body-too-large, closed, quota, protocol(string), unknown. - http-method as typed variant matching wasi:http (get/head/post/put/ delete/connect/options/trace/patch/other(string)) — was string. - http-stream resource replaces the flat-handle stream-start/read/close trio: stream is constructed by http-stream-start; status / headers / read-chunk / close are methods. Drop is automatic. astrid:process@1.0.0: - error-code: capability-denied, invalid-input, boundary-escape, quota, too-large, closed, cancelled, wait-timeout, unknown. - exit-info record split exit-code: option + signal: option so SIGKILL-by-oom is distinguishable from normal-exit-with-code-1. process-result / read-logs-result / kill-result all carry exit-info. - process-handle resource replaces the flat-handle process-id passed around: read-logs / write-stdin / close-stdin / signal / kill / wait are methods on the resource. Drop is automatic. astrid:ipc@1.0.0 (the most consequential): - error-code: capability-denied, invalid-input, closed, rate-limited, backpressure, quota, timeout, unknown. - principal-attribution variant on ipc-message: verified(string) / claimed(string) / system. The Bucket A3 trust marker — downstream consumers MUST check this variant on sensitive actions; claimed principals are uplink-asserted and NOT kernel-verified. publish-as produces 'claimed'; publish produces 'verified'. Closes the ipc-publish-as ambient-spoofing vector flagged in the security audit. The kernel audit log records BOTH the uplink's true principal AND the claimed principal for publish-as. - subscription resource with poll / recv / subscribe-readiness methods. subscribe-readiness returns wasi:io/poll.pollable so bridge capsules can multiplex bus + sockets via wasi:io/poll.poll without embedding tokio purely for that. - get-interceptor-bindings (renamed from get-interceptor-handles) explicitly scoped to the caller's own bindings — does NOT enumerate other capsules' interceptors. Closes the capability-inference leak. - Topic charset rule documented ( per segment, max 8 segments, max 256 bytes). Total host fn / method count is similar to before; what changed is the shape of the contract (typed errors, typed resources, typed enums) rather than the count. --- host/approval@1.0.0.wit | 58 +++++++++-- host/elicit@1.0.0.wit | 67 +++++++++--- host/http@1.0.0.wit | 119 +++++++++++++++------- host/identity@1.0.0.wit | 94 +++++++++-------- host/ipc@1.0.0.wit | 174 ++++++++++++++++++------------- host/kv@1.0.0.wit | 85 +++++++++------- host/process@1.0.0.wit | 221 +++++++++++++++++++++------------------- host/sys@1.0.0.wit | 69 +++++++++---- host/uplink@1.0.0.wit | 48 +++++++-- 9 files changed, 594 insertions(+), 341 deletions(-) diff --git a/host/approval@1.0.0.wit b/host/approval@1.0.0.wit index 7899c01..f236ec1 100644 --- a/host/approval@1.0.0.wit +++ b/host/approval@1.0.0.wit @@ -4,34 +4,70 @@ /// patterns), then publishes an ApprovalRequired IPC event and blocks /// until the frontend user responds or the request times out (60s). /// +/// Note: a capsule-to-capsule `astrid-bus:approval@1.0.0` package +/// exists in `interfaces/` for approval-event schemas on the IPC bus. +/// This host interface and that bus contract are distinct concerns; +/// the namespace split keeps them from colliding. +/// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. package astrid:approval@1.0.0; interface host { + /// Typed error returned from approval operations. + variant error-code { + /// Action or resource string failed sanitization (control + /// chars, exceeded max length, NUL bytes). + invalid-input, + /// User did not respond within 60s. + timeout, + /// AllowanceStore is temporarily unavailable. + store-unavailable, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + /// Approval request from a capsule to the host. /// - /// The capsule declares the action and resource. The kernel classifies - /// risk and manages approval policy — the capsule sees only approved/denied. + /// The capsule declares the action and resource. The kernel + /// classifies risk and manages approval policy — the capsule sees + /// only approved/denied. record approval-request { - /// The action being requested (e.g. "git push"). + /// The action being requested (e.g. "git push"). Sanitized: + /// control chars stripped, max 256 chars. action: string, /// Full resource description (e.g. "git push origin main"). + /// Sanitized: max 1024 chars. target-resource: string, } - /// Approval response from the host. + /// Decision returned by the user (or by the AllowanceStore for a + /// pre-approved pattern). + enum approval-decision { + /// Denied — capsule must not proceed. + denied, + /// Approved once. + approved, + /// Approved for the current session. + approved-session, + /// Approved permanently (stored in the AllowanceStore). + approved-always, + /// Auto-approved via an existing allowance pattern. + allowance, + } + + /// Approval response from the host. Carries the specific decision + /// (not just approved/denied) so capsule UI can communicate WHY + /// (e.g. "stored as always-approve") for transparency. record approval-response { - /// Whether the action was approved. - approved: bool, + /// The specific decision class. + decision: approval-decision, } /// Request human approval for a sensitive action. /// - /// Decision values: "approve" (once), "approve_session", "approve_always", - /// "deny", "allowance" (auto-granted via existing allowance). - /// Action strings are sanitized (control chars stripped, max 256 chars). - /// Resource strings are sanitized (max 1024 chars). - request-approval: func(request: approval-request) -> result; + /// Audit: every approval call recorded — request, response, and + /// resolution path (AllowanceStore hit vs user prompt). + request-approval: func(request: approval-request) -> result; } diff --git a/host/elicit@1.0.0.wit b/host/elicit@1.0.0.wit index c4182eb..cd5525a 100644 --- a/host/elicit@1.0.0.wit +++ b/host/elicit@1.0.0.wit @@ -1,8 +1,13 @@ /// Interactive user input collection during lifecycle hooks. /// -/// Only callable during `#[install]` or `#[upgrade]` lifecycle phases. -/// Blocks the WASM thread until the frontend (TUI/CLI) collects user -/// input and publishes a response, or the request times out (120s). +/// Only callable during `astrid-install` or `astrid-upgrade` lifecycle +/// phases. Blocks the WASM thread until the frontend (TUI/CLI) collects +/// user input and publishes a response, or the request times out (120s). +/// +/// Note: a capsule-to-capsule `astrid-bus:elicit@1.0.0` package exists +/// in `interfaces/` for elicit-event schemas on the IPC bus. This host +/// interface and that bus contract are distinct concerns; the namespace +/// split keeps them from colliding. /// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. @@ -10,10 +15,38 @@ package astrid:elicit@1.0.0; interface host { + /// Typed error returned from elicit operations. + variant error-code { + /// Called outside an install/upgrade lifecycle phase. + not-in-lifecycle, + /// User did not respond within 120s. + timeout, + /// User cancelled (closed prompt, sent CTRL-C, etc.). + cancelled, + /// Input failed validation (e.g. select value not in options). + invalid-input, + /// SecretStore (keychain + KV fallback) is unavailable. + store-unavailable, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// Type of input being elicited. + enum elicit-type { + /// Plain text input. + text, + /// Secret (stored in SecretStore, never returned in plaintext). + secret, + /// Single selection from `options`. + %select, + /// Multiple selections from `options` (returned as JSON array). + array, + } + /// Request for user input during capsule lifecycle. record elicit-request { - /// Input type: "text", "secret", "select", or "array". - elicit-type: string, + /// Input type. + kind: elicit-type, /// Key for storing the collected value. key: string, /// Human-readable prompt description. @@ -24,16 +57,22 @@ interface host { default-value: option, } - /// Prompt the user for input (text, secret, select, or array). - /// - /// Returns a JSON response depending on the request type: - /// text/select: `{"value": "..."}`, - /// secret: `{"ok": true}` (value stored in SecretStore), - /// array: `{"values": [...]}`. - elicit: func(request: elicit-request) -> result; + /// Response from an elicit call. + variant elicit-response { + /// Single text/select value. + value(string), + /// Multiple values (from `array` type). + values(list), + /// Secret stored in SecretStore; value not returned. + secret-stored, + } + + /// Prompt the user for input. + /// Audit: every elicit call recorded. + elicit: func(request: elicit-request) -> result; /// Check whether a secret key has been stored for this capsule. - /// /// Uses the SecretStore abstraction (OS keychain with KV fallback). - has-secret: func(key: string) -> result; + /// Audit: not recorded (read-only). + has-secret: func(key: string) -> result; } diff --git a/host/http@1.0.0.wit b/host/http@1.0.0.wit index 632e5bb..a04a8ea 100644 --- a/host/http@1.0.0.wit +++ b/host/http@1.0.0.wit @@ -1,9 +1,9 @@ /// HTTP client operations with SSRF protection. /// /// DNS resolution blocks connections to private, loopback, link-local, -/// and multicast IP addresses. IPv4-mapped and IPv4-compatible IPv6 -/// addresses are also checked. The security gate enforces per-capsule -/// allow-lists and rate limits. +/// multicast, and unspecified IP addresses. IPv4-mapped and IPv4- +/// compatible IPv6 addresses are also checked. The security gate +/// enforces per-capsule allow-lists and rate limits. /// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. @@ -11,6 +11,51 @@ package astrid:http@1.0.0; interface host { + /// Typed error returned from every fallible http operation. + variant error-code { + /// Capsule lacks the `net` capability. + capability-denied, + /// Hostname failed validation (NUL bytes, control chars, etc.) + /// or URL doesn't parse. + invalid-request, + /// DNS could not resolve the hostname. + dns-error, + /// All resolved IPs were SSRF-blocked (private/loopback/etc). + airlock-rejected, + /// TLS handshake / certificate problem. + tls-error, + /// Request or per-chunk read exceeded its timeout. + timeout, + /// Connection refused or reset by peer. + connection-error, + /// Response body exceeded the 10 MB cap on buffered requests + /// (use `http-stream-start` for larger). + body-too-large, + /// Stream handle has been closed. + closed, + /// Server-side resource quota exhausted (4 concurrent streams). + quota, + /// Unspecific HTTP-protocol-level error from the host. + protocol(string), + /// Unspecific I/O error from the host; detail is best-effort. + unknown(string), + } + + /// HTTP request method. Matches `wasi:http/types.method`. `other` + /// carries non-standard methods (PROPFIND, etc.) by name. + variant http-method { + get, + head, + post, + put, + %delete, + connect, + options, + trace, + patch, + other(string), + } + /// A key-value pair used for typed header lists. record key-value-pair { key: string, @@ -21,57 +66,57 @@ interface host { record http-request-data { /// Target URL. url: string, - /// HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS). - method: string, - /// Request headers as key-value pairs. + /// HTTP method. + method: http-method, + /// Request headers as key-value pairs. Duplicates allowed + /// (e.g. multiple `Cookie` lines). headers: list, /// Optional request body as raw bytes. JSON/text callers /// encode their string payload to UTF-8 bytes; binary uploads - /// (image, protobuf, multipart) pass their bytes directly - /// without lossy string conversion. + /// (image, protobuf, multipart) pass their bytes directly. body: option>, } - /// HTTP response returned by the host. + /// HTTP response returned from `http-request`. record http-response-data { /// HTTP status code. status: u16, /// Response headers. headers: list, - /// Response body as raw bytes (may contain non-UTF-8 binary data). + /// Response body as raw bytes. body: list, } - /// HTTP streaming response metadata returned on stream start. - record http-stream-start-response { - /// Opaque handle for subsequent read/close calls. - handle: u64, - /// HTTP status code. - status: u16, - /// Response headers. - headers: list, + /// HTTP streaming response handle. The kernel buffers chunks + /// server-side; the capsule reads them via `read-chunk` until + /// EOF or `close`. Per-chunk timeout: 120s. + /// + /// Per-capsule cap: 4 concurrent HTTP streams. Drop is automatic + /// — capsules don't need to call close explicitly. + resource http-stream { + /// HTTP status code returned at stream start. + status: func() -> u16; + + /// Response headers from the initial response. + headers: func() -> list; + + /// Read the next chunk. Returns an empty list at EOF; + /// callers loop until the list is empty. + read-chunk: func() -> result, error-code>; + + /// Close the stream explicitly. Idempotent. Equivalent to + /// dropping the resource. + close: func() -> result<_, error-code>; } /// Perform a buffered HTTP request (full response in memory). - /// /// 30s timeout, max response body 10 MB. - /// Security-gated: requires http capability. - http-request: func(request: http-request-data) -> result; + /// Audit: recorded (URL host + method + status; body bytes not + /// logged). + http-request: func(request: http-request-data) -> result; - /// Start a streaming HTTP request (headers returned, body streamed). - /// - /// Max 4 concurrent HTTP streams per capsule. - /// Security-gated: requires http capability. - http-stream-start: func(request: http-request-data) -> result; - - /// Read the next chunk from a streaming HTTP response. - /// - /// Returns the chunk bytes, or an empty list on EOF. - /// 120s per-chunk timeout. - http-stream-read: func(stream-handle: u64) -> result, string>; - - /// Close a streaming HTTP response handle. - /// - /// Idempotent: closing an already-closed handle is a no-op. - http-stream-close: func(stream-handle: u64) -> result<_, string>; + /// Start a streaming HTTP request (headers returned on the + /// resource; body streamed via `read-chunk`). + /// Audit: recorded (start + close). + http-stream-start: func(request: http-request-data) -> result; } diff --git a/host/identity@1.0.0.wit b/host/identity@1.0.0.wit index 9713116..6e78344 100644 --- a/host/identity@1.0.0.wit +++ b/host/identity@1.0.0.wit @@ -2,7 +2,7 @@ /// /// Maps external platform identities (Discord user, GitHub user, etc.) /// to internal Astrid user IDs. All operations are security-gated per -/// operation type (resolve, link, unlink, create-user, list-links). +/// operation type and audit-logged. /// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. @@ -10,6 +10,25 @@ package astrid:identity@1.0.0; interface host { + /// Typed error returned from identity operations. + variant error-code { + /// Capsule lacks the `identity` capability. + capability-denied, + /// Platform / user-id / display-name / method failed + /// validation (NUL bytes, control chars, max length). + invalid-input, + /// User ID does not exist. + user-not-found, + /// No link exists for the given platform user (resolve / unlink). + link-not-found, + /// Link already exists (link operation collision). + already-linked, + /// Identity store is temporarily unavailable. + store-unavailable, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + /// Request to resolve a platform identity to an Astrid user. record identity-resolve-request { /// External platform (e.g. "discord", "github"). @@ -18,25 +37,19 @@ interface host { platform-user-id: string, } - /// Result of an identity resolve operation. + /// Successful resolution; carries the user info. Returns + /// `link-not-found` error instead of a flag-based "found: bool". record identity-resolve-response { - /// Whether a matching user was found. - found: bool, - /// The Astrid user ID, if found. - user-id: option, + /// The Astrid user ID. + user-id: string, /// The user's display name, if available. display-name: option, - /// Error message, if the operation failed. - error: option, } /// Request to link a platform identity to an Astrid user. record identity-link-request { - /// External platform (e.g. "discord", "github"). platform: string, - /// User ID on the external platform. platform-user-id: string, - /// Internal Astrid user ID to link to. astrid-user-id: string, /// Authentication method (e.g. "passkey", "token"). method: string, @@ -44,9 +57,7 @@ interface host { /// Request to unlink a platform identity. record identity-unlink-request { - /// External platform. platform: string, - /// User ID on the external platform. platform-user-id: string, } @@ -56,44 +67,39 @@ interface host { display-name: option, } - /// Request to list platform links for an Astrid user. - record identity-list-links-request { - /// Internal Astrid user ID. - astrid-user-id: string, + /// Response from `identity-create-user`. + record identity-create-user-response { + /// The newly created Astrid user ID. + user-id: string, } - /// Result of an identity mutation (link, unlink, create-user, list-links). - record identity-ok-response { - /// Whether the operation succeeded. - ok: bool, - /// Error message, if the operation failed. - error: option, - /// Created user ID (set by create-user on success). - user-id: option, - /// Whether a link was removed (set by unlink on success). - removed: option, - /// JSON-encoded list of platform links (set by list-links on success). - links-json: option, + /// One platform link in a user's link list. + record platform-link { + platform: string, + platform-user-id: string, + /// When the link was created (ISO 8601). + linked-at: string, + /// Authentication method used at link time. + method: string, } - /// Resolve a platform identity to an Astrid user. - identity-resolve: func(request: identity-resolve-request) -> result; + /// Resolve a platform identity to an Astrid user. Returns + /// `link-not-found` if the platform user is not linked to anyone. + identity-resolve: func(request: identity-resolve-request) -> result; - /// Link a platform identity to an Astrid user. - identity-link: func(request: identity-link-request) -> result; + /// Link a platform identity to an Astrid user. Returns + /// `already-linked` if a link for this (platform, platform-user-id) + /// already exists. + identity-link: func(request: identity-link-request) -> result<_, error-code>; - /// Unlink a platform identity. - identity-unlink: func(request: identity-unlink-request) -> result; + /// Unlink a platform identity. Returns `link-not-found` if no link + /// exists. + identity-unlink: func(request: identity-unlink-request) -> result<_, error-code>; /// Create a new Astrid user. - /// - /// Returns an ok-response; the created user ID is available in the - /// response JSON when accessed via the SDK. - identity-create-user: func(request: identity-create-user-request) -> result; + identity-create-user: func(request: identity-create-user-request) -> result; - /// List all platform links for an Astrid user. - /// - /// Returns an ok-response; the links are available in the - /// response JSON when accessed via the SDK. - identity-list-links: func(request: identity-list-links-request) -> result; + /// List all platform links for an Astrid user. Returns the + /// (possibly empty) link list directly — no JSON blob. + identity-list-links: func(astrid-user-id: string) -> result, error-code>; } diff --git a/host/ipc@1.0.0.wit b/host/ipc@1.0.0.wit index 195b255..b9467eb 100644 --- a/host/ipc@1.0.0.wit +++ b/host/ipc@1.0.0.wit @@ -1,12 +1,17 @@ /// Inter-Process Communication (IPC) event bus. /// /// Capsules communicate via a publish/subscribe event bus. Topics are -/// dot-delimited strings (max 8 segments, max 256 bytes). Publishing -/// and subscribing are independently ACL-gated via `ipc_publish` and -/// `ipc_subscribe` patterns in `Capsule.toml [capabilities]`. +/// dot-delimited strings ([a-z0-9._-]+ per segment, max 8 segments, +/// max 256 bytes). Publishing and subscribing are independently +/// ACL-gated via `ipc_publish` and `ipc_subscribe` patterns in +/// `Capsule.toml [capabilities]`. /// -/// Provenance is tracked via `ipc-message.source-id` (the capsule's -/// session UUID), not topic namespacing. +/// Provenance is tracked via `ipc-message.source-id` (the publishing +/// capsule's session UUID) and `ipc-message.principal` (variant +/// carrying both the principal AND the trust marker — verified vs +/// claimed). Downstream consumers MUST check the principal-attribution +/// variant for sensitive actions: claimed principals are uplink- +/// asserted and not kernel-verified. /// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. @@ -14,9 +19,55 @@ package astrid:ipc@1.0.0; interface host { - /// IPC message envelope returned by `ipc-poll` and `ipc-recv`. + use wasi:io/poll@0.2.0.{pollable}; + + /// Typed error returned from every fallible ipc operation. + variant error-code { + /// Capsule lacks `ipc_publish` / `ipc_subscribe` matching the + /// topic, or lacks `uplink` for `publish-as`. + capability-denied, + /// Topic / payload / principal failed validation. + invalid-input, + /// Subscription handle has been dropped. + closed, + /// Publish was rate-limited (per-capsule limit). + rate-limited, + /// Bus queue overflow (buffer full beyond drop threshold). + backpressure, + /// Max subscriptions per capsule (128) reached. + quota, + /// Recv timed out before a message arrived. + timeout, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// Provenance + trust attribution for an IPC message's principal. + /// + /// Capsules MUST check this variant on sensitive actions. A + /// `claimed` principal is uplink-asserted (via `publish-as`) and + /// the kernel has NOT verified the asserting uplink actually + /// authenticated the named principal. Treat `claimed` as caller- + /// input, not authenticated context. + variant principal-attribution { + /// Principal kernel-verified from the publishing capsule's + /// invocation context. Trusted: capability checks may use + /// this directly. + verified(string), + /// Principal claimed by an uplink capsule via `publish-as`. + /// Uplink-asserted, NOT kernel-verified. Downstream capability + /// checks on sensitive actions MUST require `verified` or + /// perform additional authentication. + claimed(string), + /// System / kernel-originated event with no attributable + /// principal (e.g. lifecycle events from the kernel itself). + system, + } + + /// IPC message envelope returned by `subscription.poll` / + /// `subscription.recv`. record ipc-envelope { - /// List of IPC messages received since last poll/recv. + /// List of messages received since last poll/recv. messages: list, /// Number of messages dropped due to buffer overflow. dropped: u64, @@ -32,82 +83,69 @@ interface host { payload: string, /// UUID of the capsule that sent this message. source-id: string, - /// Principal attributed to the publisher of this message. - /// - /// For messages published via `ipc-publish`, this is the publishing - /// capsule's invocation principal (whoever the host attributed the - /// call to). For messages published via `ipc-publish-as`, this is - /// the principal the uplink claimed on behalf of an external caller. - /// - /// `none` for system / kernel-originated events that have no - /// attributable principal, and for legacy messages that predate - /// this field. Subscribers processing multi-message recv batches - /// should read this per-message rather than relying on the - /// invocation context (which only reflects the first message's + /// Principal attribution — see `principal-attribution`. + /// Subscribers processing multi-message recv batches MUST + /// read this per-message rather than rely on invocation + /// context (which only reflects the first message's /// publisher). - principal: option, + principal: principal-attribution, } /// Pre-registered interceptor handle mapping. - record interceptor-handle { + record interceptor-binding { /// Subscription handle ID. handle-id: u64, /// Interceptor action name. action: string, - /// IPC topic pattern this handle is subscribed to. + /// IPC topic pattern this binding is subscribed to. topic: string, } - /// Publish a message to a topic. - /// - /// Payload must be valid JSON. Rate-limited per capsule. The principal - /// is automatically propagated from the caller context. - ipc-publish: func(topic: string, payload: string) -> result<_, string>; + /// An active subscription to an IPC topic pattern. Drop is + /// automatic — capsules don't call unsubscribe explicitly. + /// Per-capsule cap: 128 subscriptions. + resource subscription { + /// Non-blocking poll for messages. Returns whatever's queued + /// since the last poll/recv. + poll: func() -> result; - /// Publish a message on behalf of a specific principal. - /// - /// Like `ipc-publish` but stamps the outgoing envelope with the - /// supplied principal instead of the capsule's own — used by - /// uplinks (CLI proxy, Telegram bridge, etc.) to relay messages - /// from external clients while preserving the calling identity - /// for downstream capability checks. Gated on `uplink = true` in - /// the manifest's `[capabilities]` block. The trust model is - /// "trust the uplink": the kernel does not verify that the uplink - /// actually authenticated the claimed principal. Real cross- - /// principal auth lives in issue #658. - ipc-publish-as: func( - topic: string, - payload: string, - principal: string, - ) -> result<_, string>; + /// Block until a message arrives, or `timeout-ms` elapses. + /// Max timeout: 60_000 ms (capped by host). Returns the + /// envelope on message arrival or `timeout` error on + /// timeout. + recv: func(timeout-ms: u64) -> result; - /// Subscribe to a topic pattern and receive a handle. - /// - /// Supports exact matches and trailing-suffix wildcards (`foo.bar.*`). - /// Mid-segment wildcards (`a.*.b`) are rejected. Returns a handle ID - /// for use with `ipc-poll`, `ipc-recv`, and `ipc-unsubscribe`. - /// Max 128 subscriptions per capsule. - ipc-subscribe: func(topic-pattern: string) -> result; + /// Pollable that fires when messages are queued. Compose with + /// other pollables (net, future http-stream, etc.) via + /// `wasi:io/poll.poll` for multiplexed I/O in bridge and + /// fan-out capsules. + subscribe-readiness: func() -> pollable; + } - /// Unsubscribe from a topic by handle ID. - /// - /// Runtime-owned interceptor handles cannot be unsubscribed. - ipc-unsubscribe: func(handle-id: u64) -> result<_, string>; + /// Publish a UTF-8 JSON payload to a topic. Principal is + /// automatically attributed from the caller's invocation context + /// as `verified`. + /// Audit: recorded. + publish: func(topic: string, payload: string) -> result<_, error-code>; - /// Non-blocking poll for messages on a subscription. - /// - /// Returns the messages received since last poll, plus drop/lag counts. - ipc-poll: func(handle-id: u64) -> result; + /// Publish on behalf of a specific principal (uplink-claimed, + /// NOT kernel-verified — see `principal-attribution::claimed`). + /// Reserved for uplinks (`uplink = true` in + /// `[capabilities]`); other callers get `capability-denied`. + /// Subscribers see the principal as `claimed(...)`, not + /// `verified(...)`. + /// Audit: recorded with BOTH the uplink's true principal AND + /// the claimed principal. + publish-as: func(topic: string, payload: string, principal: string) -> result<_, error-code>; - /// Blocking receive on a subscription with timeout. - /// - /// Blocks the WASM thread until a message arrives, the timeout expires - /// (capped at 60s), or the capsule is unloaded. - ipc-recv: func(handle-id: u64, timeout-ms: u64) -> result; + /// Subscribe to a topic pattern. Supports exact matches and + /// trailing-suffix wildcards (`foo.bar.*`). Mid-segment wildcards + /// are rejected. + subscribe: func(topic-pattern: string) -> result; - /// Get pre-registered interceptor handle mappings for run-loop capsules. - /// - /// Returns a list of interceptor handle objects describing which IPC - /// subscription handles correspond to interceptor actions. - get-interceptor-handles: func() -> result, string>; + /// Get pre-registered interceptor handles for run-loop capsules. + /// Returns ONLY the calling capsule's own interceptors — does + /// NOT enumerate other capsules' bindings (capability-inference + /// scope discipline). + get-interceptor-bindings: func() -> result, error-code>; } diff --git a/host/kv@1.0.0.wit b/host/kv@1.0.0.wit index 93b6fb3..4072155 100644 --- a/host/kv@1.0.0.wit +++ b/host/kv@1.0.0.wit @@ -5,22 +5,32 @@ /// scoping ensures different users' data is isolated even when the same /// capsule serves multiple principals. /// +/// Keys are UTF-8 NFC, no NUL or control characters, max 256 bytes. +/// Values are arbitrary bytes (max 1 MiB per value). Server-side +/// cumulative quota per (principal, capsule) namespace is bounded; +/// `quota` is returned when the quota is exhausted. +/// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. package astrid:kv@1.0.0; interface host { - /// Read a value by key. - /// - /// Returns the stored bytes, or none if the key does not exist. - kv-get: func(key: string) -> result>, string>; - - /// Write a value by key. - kv-set: func(key: string, value: list) -> result<_, string>; - - /// Delete a key. - kv-delete: func(key: string) -> result<_, string>; + /// Typed error returned from every fallible kv operation. + variant error-code { + /// Key string failed validation (NUL byte, control chars, not + /// UTF-8 NFC, exceeded max length). + invalid-key, + /// Value exceeded the 1 MiB per-value cap. + too-large, + /// Per-(principal, capsule) cumulative quota exhausted. + quota, + /// `kv-cas` saw a value other than `expected` — caller retries + /// with the new current value. + cas-mismatch, + /// Unspecific host error; detail is best-effort. + unknown(string), + } /// A page of keys returned by `kv-list-keys-page`. record key-page { @@ -31,45 +41,52 @@ interface host { next-cursor: option, } + /// Read a value by key. + /// + /// Returns the stored bytes, or `none` if the key does not exist. + /// Audit: not recorded per-call (high-volume read; sampled at + /// kernel level). + kv-get: func(key: string) -> result>, error-code>; + + /// Write a value by key. Atomic per-key replacement. + /// Audit: recorded. + kv-set: func(key: string, value: list) -> result<_, error-code>; + + /// Delete a key. Idempotent — deleting a missing key is a no-op. + /// Audit: recorded. + kv-delete: func(key: string) -> result<_, error-code>; + /// List all keys matching a prefix. Convenience for small stores /// where the caller is sure the result fits. /// /// Capped server-side at 1024 keys per call — larger result sets - /// return an error directing the caller to `kv-list-keys-page`. - kv-list-keys: func(prefix: string) -> result, string>; + /// return `too-large` directing the caller to `kv-list-keys-page`. + kv-list-keys: func(prefix: string) -> result, error-code>; - /// Paginated key listing for unbounded stores. Mirrors a generic - /// cursor-based listing API. + /// Paginated key listing for unbounded stores. /// /// Pass `none` for `cursor` on the first call and the cursor - /// returned in `next-cursor` on subsequent calls. `limit` is capped - /// at 1024 per page; 0 means "use the server default." - kv-list-keys-page: func(prefix: string, cursor: option, limit: u32) -> result; + /// returned in `next-cursor` on subsequent calls. `limit` is + /// capped at 1024 per page; 0 means "use the server default." + kv-list-keys-page: func(prefix: string, cursor: option, limit: u32) -> result; /// Delete all keys matching a prefix. /// /// Returns the count of deleted keys. - kv-clear-prefix: func(prefix: string) -> result; + /// Audit: recorded. + kv-clear-prefix: func(prefix: string) -> result; /// Atomically compare-and-swap a key. /// /// If the key's current value equals `expected`, replace it with - /// `new` and return `true`. Otherwise leave the store unchanged and - /// return `false`. `expected` of `none` means "swap only if the key - /// does not currently exist" (create-if-absent). Use the read-then- - /// cas pattern for unconditional updates that need concurrency - /// safety: - /// - /// ```text - /// loop { - /// current = kv-get(key)?; - /// next = mutate(current); - /// if kv-cas(key, current, next)? { break; } - /// } - /// ``` + /// `new` and return `true`. Otherwise leave the store unchanged + /// and return `false` (caller retries with the new current value). + /// `expected` of `none` means "swap only if the key does not + /// currently exist" (create-if-absent). /// /// Required for any concurrent coordination on shared state — the - /// kernel does not guarantee single-threaded execution across - /// invocations of the same capsule. - kv-cas: func(key: string, expected: option>, new: list) -> result; + /// kernel runs capsule invocations across the multi-threaded tokio + /// worker pool, so RMW patterns on shared keys race without this. + /// Audit: recorded. + kv-cas: func(key: string, expected: option>, new: list) -> result; } diff --git a/host/process@1.0.0.wit b/host/process@1.0.0.wit index 6021c78..2276a1f 100644 --- a/host/process@1.0.0.wit +++ b/host/process@1.0.0.wit @@ -1,9 +1,9 @@ /// Host-side process spawning with OS-level sandboxing. /// /// Commands are wrapped in platform-specific sandbox tools -/// (`sandbox-exec` on macOS, `bwrap` on Linux) scoped to the -/// workspace directory. All processes are tracked for cancellation. -/// Security-gated: requires host-process capability. +/// (`sandbox-exec` on macOS, `bwrap` on Linux) scoped to the workspace +/// directory. All processes are tracked for cancellation. +/// Security-gated: requires `host_process` capability. /// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. @@ -11,11 +11,51 @@ package astrid:process@1.0.0; interface host { + /// Typed error returned from process operations. + variant error-code { + /// Capsule lacks the `host_process` capability. + capability-denied, + /// Command / args / cwd / env failed validation (NUL bytes, + /// control chars, max length). + invalid-input, + /// `cwd` resolved outside the workspace. + boundary-escape, + /// Per-capsule background-process cap exhausted (max 8). + quota, + /// Stdin payload exceeded the per-call 1 MB cap or the + /// cumulative-per-process write quota. + too-large, + /// Handle has been closed (process exited and reaped). + closed, + /// Spawn cancelled (capsule unloading). + cancelled, + /// Wait timed out before the process exited. + wait-timeout, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// Signal a background process can receive (Unix semantics; the + /// kernel maps to the closest equivalent on Windows). + /// + /// `kill` (SIGKILL) is a separate method because it is non- + /// graceful and drains stdout/stderr buffers into the kill result. + enum process-signal { + /// SIGTERM — graceful shutdown request. + term, + /// SIGHUP — reload configuration. + hup, + /// SIGUSR1 — user-defined. + usr1, + /// SIGUSR2 — user-defined. + usr2, + /// SIGINT — interrupt (Ctrl-C equivalent). + int, + } + /// Environment variable to pass to a spawned process. record env-var { - /// Variable name. key: string, - /// Variable value. value: string, } @@ -25,57 +65,38 @@ interface host { cmd: string, /// Command arguments. args: list, - /// Optional stdin bytes piped to the spawned process. The full - /// buffer is delivered to the child's stdin and then stdin is - /// closed; the child sees EOF after consuming. + /// Optional stdin bytes piped to the spawned process. For + /// long-lived stdin streaming use `process-handle.write-stdin`. + /// Capped at 4 MiB per spawn (cumulative). stdin: option>, - /// Environment variables to set in the child. Replaces (not - /// merges with) the host's default sandbox environment, except - /// for a small allowlist the kernel always passes through - /// (e.g. `PATH` segments needed by sandbox-exec / bwrap). + /// Environment variables. Replaces the host's default sandbox + /// environment except for a small kernel-passthrough allowlist. env: list, - /// Working directory for the child, relative to the workspace. - /// Must resolve inside the workspace sandbox; absolute paths - /// and `..` escapes are rejected. `none` keeps the workspace - /// root. + /// Working directory relative to the workspace. Must resolve + /// inside the sandbox; absolute paths and `..` escapes are + /// rejected with `boundary-escape`. cwd: option, } + /// Exit information for a process that has terminated. + record exit-info { + /// Normal exit code if exited normally; `none` if killed by + /// signal or platform didn't surface one. + exit-code: option, + /// Signal that killed the process (Unix), if any. Distinguishes + /// SIGKILL (oom-killer, parent kill) from SIGTERM (graceful + /// shutdown) from normal exit. + signal: option, + } + /// Result of a synchronous process execution. record process-result { /// Captured standard output. stdout: string, /// Captured standard error. stderr: string, - /// Exit code if the process exited normally. `none` if the - /// process was killed (no exit code available) or the platform - /// did not surface one. - exit-code: option, - } - - /// Signal a background process can receive (Unix semantics; the - /// kernel maps to the closest equivalent on Windows). - /// - /// `kill` (SIGKILL) is a separate function because it is non- - /// graceful and drains stdout/stderr buffers; `signal` is fire-and- - /// forget for graceful or user-defined signals. - enum process-signal { - /// SIGTERM — graceful shutdown request. - term, - /// SIGHUP — reload configuration. - hup, - /// SIGUSR1 — user-defined. - usr1, - /// SIGUSR2 — user-defined. - usr2, - /// SIGINT — interrupt (Ctrl-C equivalent). - int, - } - - /// Result of spawning a background process. - record spawn-background-result { - /// Opaque process handle for subsequent log/kill calls. - id: u64, + /// How the process terminated. + exit: exit-info, } /// Logs and status from a background process. @@ -86,77 +107,67 @@ interface host { stderr: string, /// Whether the process is still running. running: bool, - /// Exit code if the process has terminated. - exit-code: option, + /// Exit info if the process has terminated. + exit: option, } /// Result of killing a background process. - record kill-process-result { + record kill-result { /// Whether the process was successfully killed. killed: bool, - /// Exit code if available. - exit-code: option, + /// Exit info if available. + exit: option, /// Final buffered stdout. stdout: string, /// Final buffered stderr. stderr: string, } - /// Spawn a synchronous (blocking) process. - /// - /// Blocks the WASM thread until the process exits or is cancelled. - /// Cancelled processes return exit_code -1. - spawn: func(request: spawn-request) -> result; - - /// Spawn a background (non-blocking) process. + /// A running or recently-terminated background process. Drop is + /// automatic — capsules don't need to explicitly close; the + /// host reaps the process on resource drop. /// - /// Returns a process handle for subsequent log/kill calls. - /// Max 8 concurrent background processes per capsule. - /// stdout/stderr are buffered (1 MB per stream, ring buffer). - spawn-background: func(request: spawn-request) -> result; - - /// Read buffered logs from a background process. - /// - /// Drains the buffers (subsequent reads return only new data). - read-logs: func(process-id: u64) -> result; - - /// Kill a background process. - /// - /// Sends SIGKILL to the process group (Unix) or kill (Windows). - kill: func(process-id: u64) -> result; - - /// Send a signal to a background process. - /// - /// See `process-signal` for the accepted set. `kill` (SIGKILL) - /// remains a separate function for non-graceful termination with - /// log drainage. - signal: func(process-id: u64, sig: process-signal) -> result<_, string>; + /// Per-capsule cap: 8 concurrent background processes. + resource process-handle { + /// Read buffered logs since the last call. Drains the buffers + /// (subsequent reads return only new data). Buffers are 1 MiB + /// ring per stream. + read-logs: func() -> result; + + /// Write to the process's stdin. Useful for REPL-style + /// children (`python -i`, `psql`, MCP stdio subprocesses). + /// Returns bytes actually written; capped at 1 MB per call. + write-stdin: func(data: list) -> result; + + /// Close the stdin pipe (child observes EOF on read). + close-stdin: func() -> result<_, error-code>; + + /// Send a signal. Fire-and-forget; for graceful shutdown use + /// `term`. Use `kill` (SIGKILL) for non-graceful with log + /// drainage. + signal: func(sig: process-signal) -> result<_, error-code>; + + /// Send SIGKILL and drain stdout/stderr buffers. Returns the + /// final state including exit-info if available. + kill: func() -> result; + + /// Wait for the process to exit. `timeout-ms: none` waits + /// indefinitely; bounded values drive request-response + /// patterns that mustn't hang on a runaway child. + /// Returns exit-info on exit, `wait-timeout` error if the + /// timeout elapsed first. + wait: func(timeout-ms: option) -> result; + } - /// Write to the stdin of a running background process. Mirrors - /// `std::process::ChildStdin::write_all`. - /// - /// Useful for REPL-style children (`python -i`, `psql`, MCP stdio - /// subprocesses). Returns the number of bytes actually written. - /// Per-call payload capped at 1 MB so callers loop with buffered - /// writes for large inputs. - /// Security-gated: the process must have been spawned with - /// `stdin: option>` set or implicitly granted by the - /// `host_process` capability. - process-write-stdin: func(process-id: u64, data: list) -> result; - - /// Close the stdin pipe of a running background process. The child - /// observes EOF on subsequent reads. Mirrors dropping - /// `std::process::ChildStdin`. - /// Security-gated: requires `host_process` capability. - process-close-stdin: func(process-id: u64) -> result<_, string>; - - /// Wait for a background process to exit. Mirrors - /// `std::process::Child::{wait, try_wait}`. - /// - /// `timeout-ms` of `none` waits indefinitely; pass a bounded value - /// to drive request-response patterns that mustn't hang on a - /// runaway child. Returns the exit code if the process terminated - /// within the window (matching `process-result.exit-code` shape), - /// or `none` if the timeout elapsed first. - process-wait: func(process-id: u64, timeout-ms: option) -> result, string>; + /// Spawn a synchronous (blocking) process. Blocks the WASM + /// task until the process exits or is cancelled. Cancelled + /// processes return `cancelled` error. + /// Audit: recorded (cmd + args + cwd, not env or stdin bytes). + spawn: func(request: spawn-request) -> result; + + /// Spawn a background (non-blocking) process. Returns a handle + /// for subsequent log/wait/signal/kill calls. + /// stdout/stderr are buffered (1 MiB per stream, ring buffer). + /// Audit: recorded. + spawn-background: func(request: spawn-request) -> result; } diff --git a/host/sys@1.0.0.wit b/host/sys@1.0.0.wit index 67f231d..28ee1ce 100644 --- a/host/sys@1.0.0.wit +++ b/host/sys@1.0.0.wit @@ -2,10 +2,11 @@ /// entropy, sleep, capability introspection. /// /// Astrid does not expose `wasi:random`, `wasi:clocks`, or other generic -/// WASI primitives to capsules. Every host call is gated, principal-scoped, -/// and audited — having a second un-audited surface (WASI) would defeat -/// that. Primitives capsules need (random bytes, monotonic clock, sleep) -/// live here. +/// WASI primitives to capsules (only `wasi:io/poll` + `wasi:io/streams` +/// are retained as Component Model foundation types). Every other host +/// call is gated, principal-scoped, and audited — primitives capsules +/// need (random bytes, monotonic clock, sleep) live here so they go +/// through the same audit/principal layer. /// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. @@ -13,6 +14,24 @@ package astrid:sys@1.0.0; interface host { + /// Typed error returned from every fallible sys operation. + variant error-code { + /// Capsule lacks the required capability. + capability-denied, + /// Config key is reserved and cannot be read (e.g. internal + /// kernel-only entries). + config-key-reserved, + /// Random-bytes / sleep length exceeded the server-side cap. + too-large, + /// Capsule registry is temporarily unavailable for capability + /// checks (fail-closed: returns this rather than allowed:false). + registry-unavailable, + /// Sleep was interrupted because the capsule is unloading. + cancelled, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + /// Log severity level for structured capsule logging. enum log-level { trace, @@ -51,20 +70,22 @@ interface host { /// Returns the raw config value, or `none` if the key is not set in /// the manifest. String values are returned without JSON-encoding /// (no extra quotes); the empty string is a valid value distinct - /// from `none`. - get-config: func(key: string) -> result, string>; + /// from `none`. Audit: not recorded (read-only manifest access). + get-config: func(key: string) -> result, error-code>; /// Get the caller context for the current invocation. /// /// Returns the acting principal, originating capsule UUID, and message /// timestamp. Returns an empty context if no caller is available. - get-caller: func() -> result; + /// Audit: not recorded. + get-caller: func() -> result; /// Emit a structured log message attributed to the calling capsule. /// /// Logs are routed to the current principal's log directory with /// daily rotation. Cross-principal invocations write to the target /// principal's log directory. + /// Audit: every log call recorded as a structured audit entry. log: func(level: log-level, message: string); /// Signal that the capsule's run loop is ready. @@ -75,34 +96,40 @@ interface host { signal-ready: func(); /// Get the current wall-clock time as milliseconds since UNIX epoch. + /// Infallible (matches `Instant::now` style; pre-1970 clocks are a + /// host misconfiguration, not a capsule concern). clock-ms: func() -> u64; /// Get the current monotonic clock reading in nanoseconds. /// - /// Suitable for measuring elapsed time within a process. Does not jump - /// with NTP adjustments. The absolute value is meaningless across - /// processes or capsule reloads — only differences are. + /// Suitable for measuring elapsed time within a process. Does not + /// jump with NTP adjustments. The absolute value is meaningless + /// across processes or capsule reloads — only differences are. + /// Infallible. clock-monotonic-ns: func() -> u64; /// Block the calling guest task for the given duration in nanoseconds. /// /// Capped server-side at 60 seconds per call (callers needing longer /// waits loop on shorter sleeps and check for cancellation between - /// iterations). Returns when the duration has elapsed or the capsule - /// is unloading. - sleep-ns: func(duration-ns: u64) -> result<_, string>; + /// iterations). Returns when the duration has elapsed; returns + /// `cancelled` if the capsule is unloading mid-sleep. + sleep-ns: func(duration-ns: u64) -> result<_, error-code>; /// Fill the caller's requested length with cryptographically secure - /// random bytes from the host's OS-level CSPRNG. + /// random bytes from the host's OS-level CSPRNG. Length matches + /// WASI random-bytes (`u64`). /// - /// `length` is capped at 4096 bytes per call (cryptographic use cases - /// — key material, nonces, identifiers — fit comfortably; bulk entropy - /// callers loop). Larger requests return an error. - random-bytes: func(length: u32) -> result, string>; + /// `length` is capped at 4096 bytes per call (cryptographic use + /// cases fit comfortably; bulk entropy callers loop). Larger + /// requests return `too-large`. + /// Audit: not recorded (read-only, no side effects). + random-bytes: func(length: u64) -> result, error-code>; /// Check whether a capsule has a specific manifest capability. /// - /// Fail-closed: returns `allowed: false` for unknown UUIDs, - /// unknown capabilities, or unavailable registry. - check-capsule-capability: func(request: capability-check-request) -> result; + /// Fail-closed: returns `allowed: false` for unknown UUIDs and + /// unknown capabilities. Returns `registry-unavailable` when the + /// registry itself can't be consulted. + check-capsule-capability: func(request: capability-check-request) -> result; } diff --git a/host/uplink@1.0.0.wit b/host/uplink@1.0.0.wit index b82a924..1e03c83 100644 --- a/host/uplink@1.0.0.wit +++ b/host/uplink@1.0.0.wit @@ -10,16 +10,50 @@ package astrid:uplink@1.0.0; interface host { + /// Typed error returned from uplink operations. + variant error-code { + /// Capsule lacks the `uplink` capability. + capability-denied, + /// Name / platform / profile failed validation (NUL bytes, + /// control chars, max length). + invalid-input, + /// Profile not in the recognized set (`chat`, `interactive`, + /// `notify`, `bridge`). + invalid-profile, + /// Uplink ID not registered or already unregistered. + unknown-uplink, + /// No active session for the target principal — message was + /// intentionally dropped. + no-session, + /// Server-side resource quota exhausted. + quota, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// Profile for a registered uplink. Determines how the kernel + /// routes inbound messages. + enum uplink-profile { + /// Conversational chat (Telegram, Discord DM, Slack DM). + chat, + /// Long-lived interactive session (CLI TTY). + interactive, + /// One-way notification sink. + notify, + /// Bidirectional bridge to another runtime. + bridge, + } + /// Register an uplink endpoint. /// - /// Arguments: name, platform identifier, profile ("chat", "interactive", - /// "notify", "bridge"). Returns the assigned uplink UUID string. - /// Security-gated: requires uplink-register capability. - uplink-register: func(name: string, platform: string, profile: string) -> result; + /// Returns the assigned uplink UUID. + /// Audit: recorded. + uplink-register: func(name: string, platform: string, profile: uplink-profile) -> result; /// Send an inbound message through a registered uplink. /// - /// Returns true if sent, false if the message was intentionally dropped - /// (e.g. no active session for the principal). - uplink-send: func(uplink-id: string, platform-user-id: string, content: string) -> result; + /// Returns `true` if sent, `false` if intentionally dropped + /// (no `no-session` error — the drop is normal flow). + /// Audit: recorded. + uplink-send: func(uplink-id: string, platform-user-id: string, content: string) -> result; } From 4c4ff1ddb805ba654d18d4af67d51cab8014f207 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 22 May 2026 00:17:51 +0400 Subject: [PATCH 13/14] docs(host): IPC publish re-entrancy / fan-out cap + udp-bind exposure warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final pre-freeze doc-as-contract additions surfaced by the security audit pass: ipc::publish — documents the re-entrancy contract (publishing from within an interceptor handler is allowed but counts against a host- side publish-depth budget of 8) and the fan-out cap (a single publish dispatches to at most 256 subscribers; further matches surface as backpressure / lag counters). These were left ambiguous in the prior version — without them in the WIT contract, a capsule author writing recursive interceptors or wide-fan-out could discover the implicit caps only by hitting them in production. net::udp-bind — strengthens the security warning around binding to non-loopback addresses. The previous note read as informational; the new wording makes explicit that 0.0.0.0 / :: is a server posture exposing the capsule to every network interface, and recommends the net_udp capability allowlist pattern for loopback-only binds. Both are doc-only changes — no shape impact on the WIT. --- host/ipc@1.0.0.wit | 12 ++++++++++++ host/net@1.0.0.wit | 13 ++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/host/ipc@1.0.0.wit b/host/ipc@1.0.0.wit index b9467eb..f34aeb6 100644 --- a/host/ipc@1.0.0.wit +++ b/host/ipc@1.0.0.wit @@ -125,6 +125,18 @@ interface host { /// Publish a UTF-8 JSON payload to a topic. Principal is /// automatically attributed from the caller's invocation context /// as `verified`. + /// + /// Re-entrancy: publish does NOT synchronously invoke subscribers. + /// Subscribers receive the message on their next `recv`/`poll`. + /// Publishing from within an interceptor handler is allowed but + /// counts against the per-invocation publish-depth budget (host + /// default: 8) to prevent unbounded reentry. + /// + /// Fan-out: a single publish dispatches to at most 256 matching + /// subscribers; further matches return `backpressure` (the kernel + /// preserves the first 256 deliveries and counts the rest as + /// dropped in subscribers' lag/drop counters). + /// /// Audit: recorded. publish: func(topic: string, payload: string) -> result<_, error-code>; diff --git a/host/net@1.0.0.wit b/host/net@1.0.0.wit index 31494d6..42e22e7 100644 --- a/host/net@1.0.0.wit +++ b/host/net@1.0.0.wit @@ -249,9 +249,16 @@ interface host { /// Requires `net_connect` allowlist match. connect-tcp: func(host: string, port: u16) -> result; - /// Bind a UDP socket. `"0.0.0.0"` / `"::"` accepts on any - /// interface (NB: capsule becomes reachable from any peer on the - /// network — restrict via `net_udp` capability if outbound-only). + /// Bind a UDP socket. + /// + /// Bind addresses other than `127.0.0.1` / `::1` make the capsule + /// reachable from outside the host — `"0.0.0.0"` / `"::"` exposes + /// the socket to every network interface, which is a server + /// posture, not a client posture. Restrict in `Capsule.toml + /// [capabilities] net_udp` to loopback-only patterns + /// (`"bind:127.0.0.1:*"` / `"bind:[::1]:*"`) unless the capsule + /// genuinely needs to serve. + /// /// Port `0` selects an ephemeral port. udp-bind: func(host: string, port: u16) -> result; From 0c1f0c3dcf37119a6bc198b54498e46b9ad9f659 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 22 May 2026 00:28:35 +0400 Subject: [PATCH 14/14] feat(host)!: land the rest of the 1.x candidates before freeze (except symlink creation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You're right that these are all things we agreed should land — pulling them in pre-freeze so the 1.0 surface is as complete as the audits recommended. The only deferred item kept is symlink creation, where the sandbox-escape risk outweighs the demand. astrid:fs@1.0.0: - fs-hard-link(src, link-path) — creates a hard link. Same-VFS-scheme only; cross-scheme returns cross-vfs. Cannot link directories. The kernel validates both endpoints stay inside the VFS scope. - fs-mkdir / fs-mkdir-all split — fs-mkdir is now the strict POSIX mkdir(2) (errors if parent missing or target exists) for lock-dir / exclusive-scratch patterns. fs-mkdir-all is the old behaviour (idempotent create-with-parents). Existing fs-mkdir doc previously said 'and missing parents' — the rename + split makes the semantic honest. Symlink creation deliberately not added: capsules can create symlinks with paths pointing outside the workspace, and even kernel-side canonicalization at follow-time has TOCTOU windows symlink swap can exploit. The read-only fs-read-link / fs-stat- symlink pair handles existing symlinks. astrid:net@1.0.0: - New tcp-listener resource (accept / poll-accept / local-addr / subscribe-readiness) + bind-tcp factory function. Inbound TCP serving — self-hosted webhook receivers, gRPC endpoints, Prometheus scrape ports. Gated by a new net_tcp_bind capability allowlist distinct from net_connect. 0.0.0.0 / :: warning documented analogous to udp-bind. - tcp-stream gains set-reuseaddr / reuseaddr (SO_REUSEADDR) for listener restart cycles. - udp-socket adds connected mode: connect / disconnect / send / recv / peer-addr. SSRF airlock runs once at connect; kernel filters spoofed peer datagrams. Faster syscall path for QUIC, DNS-over-UDP, syslog-over-UDP. Unconnected send-to / recv-from remain. astrid:process@1.0.0: - process-handle gains os-pid() — surfaces the kernel-level PID for capsules correlating with cgroup paths, /proc entries, or children that log their own PID. - process-handle gains wait-with-output(timeout) — atomic wait + final stdout/stderr drain. Closes the read-logs race for short-lived children where output could be lost between wait observing exit and the next read-logs call. - process-handle gains subscribe-exit and subscribe-logs pollables. Multiplexes 'wait on child process' with IPC events, file I/O, etc. via wasi:io/poll.poll. astrid:http@1.0.0: - http-stream gains subscribe-readable pollable. Capsule streaming a large HTTP response can multiplex it with IPC requests / file writes / other I/O via wasi:io/poll. Deferred (intentional, kept on the 1.x candidate list): - Symlink creation (fs-symlink) — sandbox-escape risk too high. - File locking (fs-lock) — kv-cas covers in-runtime concurrency; cross-process file locks aren't useful in a sandboxed runtime. - UDP multicast (set-broadcast / join-multicast-v4/v6) — niche; mDNS / SSDP are the only realistic drivers and no near-term capsule needs them. - HTTP outgoing body streaming — needs a properly-designed outgoing-body resource (mirrors wasi-http); designing as a 1.x package addition once a real upload-streaming capsule appears. --- README.md | 4 +- host/fs@1.0.0.wit | 28 ++++++++++++- host/http@1.0.0.wit | 8 ++++ host/net@1.0.0.wit | 89 ++++++++++++++++++++++++++++++++++++++---- host/process@1.0.0.wit | 24 ++++++++++++ 5 files changed, 142 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1e64640..f674136 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ Each domain is its own package, frozen at a per-file version. A capsule imports | File | Package | Description | |------|---------|-------------| -| `host/fs@1.0.0.wit` | `astrid:fs@1.0.0` | Filesystem operations within the workspace boundary — whole-file IO, file handles with positional read/write, metadata, canonicalize, read-link. | +| `host/fs@1.0.0.wit` | `astrid:fs@1.0.0` | Filesystem operations within the workspace boundary — whole-file IO, file handles with positional read/write, metadata, canonicalize, read-link, hard-link. | | `host/ipc@1.0.0.wit` | `astrid:ipc@1.0.0` | Publish/subscribe IPC event bus. | | `host/uplink@1.0.0.wit` | `astrid:uplink@1.0.0` | Inbound message ingestion from external platforms. | | `host/kv@1.0.0.wit` | `astrid:kv@1.0.0` | Per-capsule, per-principal key-value storage with atomic compare-and-swap and paginated key listing. | -| `host/net@1.0.0.wit` | `astrid:net@1.0.0` | Unix-domain sockets, gated outbound TCP, gated UDP, gated DNS resolution. | +| `host/net@1.0.0.wit` | `astrid:net@1.0.0` | Unix-domain sockets, gated outbound TCP, inbound TCP listener, gated UDP (unconnected + connected mode), gated DNS resolution. | | `host/http@1.0.0.wit` | `astrid:http@1.0.0` | HTTP client with SSRF protection and streaming. | | `host/sys@1.0.0.wit` | `astrid:sys@1.0.0` | Logging, config, time, caller context, entropy, sleep, capability introspection. | | `host/process@1.0.0.wit` | `astrid:process@1.0.0` | OS-sandboxed host process spawn (with stdin/env/cwd), wait, signal, kill, read-logs, stdin streaming. | diff --git a/host/fs@1.0.0.wit b/host/fs@1.0.0.wit index d590902..4b26aa3 100644 --- a/host/fs@1.0.0.wit +++ b/host/fs@1.0.0.wit @@ -190,10 +190,20 @@ interface host { /// (rather than collapsing access-denied into "doesn't exist"). fs-exists: func(path: string) -> result; + /// Create a directory. Mirrors `std::fs::create_dir` / + /// `mkdir(2)`. Strict — fails with `already-exists` if the path + /// exists, and fails with `not-found` if the parent directory + /// does not exist. Use this for lock-dir patterns and exclusive + /// scratch dirs where the race "another writer got here first" + /// matters. For the "ensure this exists" pattern use + /// `fs-mkdir-all`. + fs-mkdir: func(path: string) -> result<_, error-code>; + /// Create a directory and all missing parents. Mirrors /// `std::fs::create_dir_all`. Idempotent — succeeds if the - /// directory already exists. - fs-mkdir: func(path: string) -> result<_, error-code>; + /// directory already exists. For the strict "must not exist" + /// variant use `fs-mkdir`. + fs-mkdir-all: func(path: string) -> result<_, error-code>; /// List entries in a directory. Returns entry names (not full /// paths). Per-call cap: 4096 entries; larger directories must use @@ -258,4 +268,18 @@ interface host { /// Mirrors `std::fs::read_link`. Returns a VFS-scheme path; links /// pointing outside the input's scope return `boundary-escape`. fs-read-link: func(path: string) -> result; + + /// Create a hard link from `link-path` to `src`. Mirrors + /// `std::fs::hard_link`. Both paths must resolve to the same VFS + /// scheme; cross-scheme links return `cross-vfs`. Cannot link + /// directories. The kernel enforces that both endpoints stay + /// inside the VFS scope at link time. + /// + /// (Symlink creation — `fs-symlink` — is deliberately omitted: it + /// allows capsules to encode boundary-escape paths into the + /// workspace, and the read-only `fs-read-link` / `fs-stat-symlink` + /// pair is sufficient for handling existing symlinks. Revisit if + /// a concrete use case emerges with a security model.) + /// Security-gated: requires file-write capability on both paths. + fs-hard-link: func(src: string, link-path: string) -> result<_, error-code>; } diff --git a/host/http@1.0.0.wit b/host/http@1.0.0.wit index a04a8ea..231a14f 100644 --- a/host/http@1.0.0.wit +++ b/host/http@1.0.0.wit @@ -11,6 +11,8 @@ package astrid:http@1.0.0; interface host { + use wasi:io/poll@0.2.0.{pollable}; + /// Typed error returned from every fallible http operation. variant error-code { /// Capsule lacks the `net` capability. @@ -107,6 +109,12 @@ interface host { /// Close the stream explicitly. Idempotent. Equivalent to /// dropping the resource. close: func() -> result<_, error-code>; + + /// Pollable that fires when the next chunk is ready to read + /// (or EOF has arrived). Compose with other pollables for + /// multiplexed I/O — e.g. a capsule streaming an HTTP + /// response while also handling IPC requests. + subscribe-readable: func() -> pollable; } /// Perform a buffered HTTP request (full response in memory). diff --git a/host/net@1.0.0.wit b/host/net@1.0.0.wit index 42e22e7..a12b55a 100644 --- a/host/net@1.0.0.wit +++ b/host/net@1.0.0.wit @@ -98,13 +98,12 @@ interface host { } // ----------------------------------------------------------------- - // TCP listener (Unix-domain only in 1.0; TCP listener deferred) + // Listeners (Unix-domain + TCP) // ----------------------------------------------------------------- /// The capsule's pre-bound Unix domain listener. One per capsule; /// the kernel binds it at load time and the capsule activates it - /// with `bind-unix`. TCP listeners (`bind-tcp`) are deferred — the - /// `inbound webhook receiver` use case can wait for 1.x. + /// with `bind-unix`. resource unix-listener { /// Blocking accept. Performs peer credential verification /// (UID match on Unix) and session token handshake. @@ -121,6 +120,27 @@ interface host { subscribe-readiness: func() -> pollable; } + /// A bound TCP listener accepting inbound connections from the + /// network. For self-hosted webhook receivers, gRPC endpoints, + /// Prometheus scrape ports, etc. Distinct from `unix-listener` + /// which serves only locally-authenticated clients on the + /// kernel's pre-bound Unix socket. + /// + /// Per-capsule cap: 4 TCP listeners. Drop closes the socket. + resource tcp-listener { + /// Blocking accept for an inbound TCP connection. + accept: func() -> result; + + /// Polling accept with caller-controlled timeout. + poll-accept: func(timeout-ms: u64) -> result, error-code>; + + /// Local bind address as `"ip:port"`. + local-addr: func() -> result; + + /// Pollable that fires when a connection is ready to accept. + subscribe-readiness: func() -> pollable; + } + // ----------------------------------------------------------------- // TCP stream — Unix-domain and outbound-TCP streams share this type // ----------------------------------------------------------------- @@ -204,6 +224,14 @@ interface host { set-linger: func(linger-ms: option) -> result<_, error-code>; linger: func() -> result, error-code>; + /// `SO_REUSEADDR`. Useful for accepted connections from a + /// `tcp-listener` so the listener can re-bind to the same + /// port immediately after a restart without waiting for + /// TIME_WAIT to expire. Has no effect on outbound streams. + /// Returns `not-tcp` for Unix-domain streams. + set-reuseaddr: func(reuse: bool) -> result<_, error-code>; + reuseaddr: func() -> result; + // ---- Readiness ---- /// Pollable that fires when the stream is ready to read. @@ -215,10 +243,19 @@ interface host { // UDP socket resource // ----------------------------------------------------------------- - /// A UDP datagram socket. Bind once; send/recv to/from arbitrary - /// peers (SSRF airlock applies on each peer). Connected-UDP mode - /// (lock to one peer for performance) is deferred to 1.x. + /// A UDP datagram socket. Two modes: + /// + /// 1. **Unconnected** (default after `udp-bind`) — use `send-to` / + /// `recv-from` with per-call peer addressing. SSRF airlock + /// applies on every send. + /// 2. **Connected** (after `connect`) — use `send` / `recv` + /// without per-call peer. SSRF airlock applies once at + /// connect time; the kernel filters out datagrams from peers + /// other than the connected one. Faster syscall path for + /// chatty protocols (QUIC, DNS-over-UDP, syslog-over-UDP). resource udp-socket { + // ---- Unconnected-mode I/O ---- + /// Send to a peer. Returns bytes sent. send-to: func(data: list, peer-host: string, peer-port: u16) -> result; @@ -226,7 +263,35 @@ interface host { /// within the read timeout. recv-from: func(max-bytes: u32) -> result, error-code>; - /// Set the read timeout for `recv-from`. + // ---- Connected-mode I/O ---- + + /// Lock this socket to a single peer. SSRF airlock runs on + /// the peer address once. After connect, `send` / `recv` + /// work without per-call peer arguments, and the kernel + /// filters out datagrams from any peer other than this one. + /// Calling `connect` again rebinds to the new peer. + connect: func(peer-host: string, peer-port: u16) -> result<_, error-code>; + + /// Unbind from the connected peer; reverts to unconnected + /// mode (send-to / recv-from with per-call peer). + disconnect: func() -> result<_, error-code>; + + /// Send to the connected peer. Returns bytes sent. Errors + /// with `not-tcp`-equivalent (`unknown` arm — TODO future + /// `not-connected` variant) if the socket is unconnected. + send: func(data: list) -> result; + + /// Receive from the connected peer. `none` if no datagram + /// arrived within the read timeout. + recv: func(max-bytes: u32) -> result>, error-code>; + + /// Currently connected peer as `"ip:port"`. `none` if the + /// socket is unconnected. + peer-addr: func() -> result, error-code>; + + // ---- Common ---- + + /// Set the read timeout for `recv-from` / `recv`. set-read-timeout: func(timeout-ms: option) -> result<_, error-code>; /// Local bound address as `"ip:port"`. @@ -244,6 +309,16 @@ interface host { /// binds it at load time per capsule. bind-unix: func() -> result; + /// Bind a TCP listener for inbound connections. + /// + /// `"0.0.0.0"` / `"::"` exposes the listener to every network + /// interface (server posture) — restrict in `Capsule.toml + /// [capabilities] net_tcp_bind` to loopback-only patterns unless + /// the capsule genuinely needs to serve. Port `0` selects an + /// ephemeral port. Gated by a `net_tcp_bind` capability allowlist + /// distinct from `net_connect`. + bind-tcp: func(host: string, port: u16) -> result; + /// Open an outbound TCP connection to `host:port`. Goes through /// the SSRF airlock (resolves DNS, rejects private/loopback/etc.). /// Requires `net_connect` allowlist match. diff --git a/host/process@1.0.0.wit b/host/process@1.0.0.wit index 2276a1f..c86d448 100644 --- a/host/process@1.0.0.wit +++ b/host/process@1.0.0.wit @@ -11,6 +11,8 @@ package astrid:process@1.0.0; interface host { + use wasi:io/poll@0.2.0.{pollable}; + /// Typed error returned from process operations. variant error-code { /// Capsule lacks the `host_process` capability. @@ -157,6 +159,28 @@ interface host { /// Returns exit-info on exit, `wait-timeout` error if the /// timeout elapsed first. wait: func(timeout-ms: option) -> result; + + /// Wait for the process to exit AND drain remaining stdout / + /// stderr buffers atomically. Mirrors + /// `std::process::Child::wait_with_output`. Closes the + /// read-logs race for short-lived children that may have + /// terminal output not yet drained when `wait` observes exit. + wait-with-output: func(timeout-ms: option) -> result; + + /// The OS-level PID of the process. Useful for capsules that + /// correlate kernel-level events (cgroup IDs, /proc paths) + /// with child processes that log their own PID. Returns + /// `closed` if the process has already been reaped. + os-pid: func() -> result; + + /// Pollable that fires when the process has exited. Compose + /// with other pollables to multiplex "wait on child OR + /// receive IPC event." + subscribe-exit: func() -> pollable; + + /// Pollable that fires when stdout / stderr has buffered + /// data ready to be drained via `read-logs`. + subscribe-logs: func() -> pollable; } /// Spawn a synchronous (blocking) process. Blocks the WASM