diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..c7b4d33 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,46 @@ +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 host/*.wit file + # 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/README.md b/README.md index dbd915b..f674136 100644 --- a/README.md +++ b/README.md @@ -12,53 +12,122 @@ 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 — 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, 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. | +| `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`. 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 worlds it actually implements: + +```wit +// Interceptor-only capsule: +world router { + include astrid:guest/interceptor@1.0.0; + import astrid:ipc/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. + +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. -## Capsule interfaces (`interfaces/`) +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. -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. +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/`) — the `astrid-bus:*` namespace + +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 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/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/host/approval@1.0.0.wit b/host/approval@1.0.0.wit new file mode 100644 index 0000000..f236ec1 --- /dev/null +++ b/host/approval@1.0.0.wit @@ -0,0 +1,73 @@ +/// 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). +/// +/// 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. + record approval-request { + /// 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, + } + + /// 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 { + /// The specific decision class. + decision: approval-decision, + } + + /// Request human approval for a sensitive action. + /// + /// 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/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..cd5525a --- /dev/null +++ b/host/elicit@1.0.0.wit @@ -0,0 +1,78 @@ +/// Interactive user input collection during lifecycle hooks. +/// +/// 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. + +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. + kind: elicit-type, + /// 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, + } + + /// 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). + /// Audit: not recorded (read-only). + 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..4b26aa3 --- /dev/null +++ b/host/fs@1.0.0.wit @@ -0,0 +1,285 @@ +/// Filesystem operations within the capsule's workspace boundary. +/// +/// 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. + +package astrid:fs@1.0.0; + +interface host { + // ----------------------------------------------------------------- + // 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, + /// 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 handle resource + // ----------------------------------------------------------------- + + /// 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. + /// + /// 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>; + + /// 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; + + /// Flush buffered data to disk (`fdatasync(2)` — data only, not + /// metadata). Mirrors `std::fs::File::sync_data`. + sync-data: func() -> result<_, error-code>; + + /// 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>; + + /// 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; + + /// 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>; + } + + // ----------------------------------------------------------------- + // Path-based operations + // ----------------------------------------------------------------- + + /// 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; + + /// Check whether a path exists. Returns true / false / error + /// (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. 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 + /// `fs-readdir-page` (not in 1.0; tracked for 1.x). + fs-readdir: func(path: string) -> result, error-code>; + + /// 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`. + /// + /// 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; + + /// 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/guest@1.0.0.wit b/host/guest@1.0.0.wit new file mode 100644 index 0000000..b4caafd --- /dev/null +++ b/host/guest@1.0.0.wit @@ -0,0 +1,89 @@ +/// Guest export contract — lifecycle and interceptor entry points the +/// kernel calls into a capsule. +/// +/// 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), +/// `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, + } +} + +/// Interceptor entry point. +/// +/// 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}; + + export astrid-hook-trigger: func(action: string, payload: list) -> capsule-result; +} + +/// 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(); +} + +/// 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(); +} + +/// 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(); +} diff --git a/host/http@1.0.0.wit b/host/http@1.0.0.wit new file mode 100644 index 0000000..231a14f --- /dev/null +++ b/host/http@1.0.0.wit @@ -0,0 +1,130 @@ +/// HTTP client operations with SSRF protection. +/// +/// DNS resolution blocks connections to private, loopback, link-local, +/// 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. + +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. + 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, + value: string, + } + + /// HTTP request sent by a capsule to the host. + record http-request-data { + /// Target URL. + url: string, + /// 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. + body: option>, + } + + /// HTTP response returned from `http-request`. + record http-response-data { + /// HTTP status code. + status: u16, + /// Response headers. + headers: list, + /// Response body as raw bytes. + body: 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>; + + /// 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). + /// 30s timeout, max response body 10 MB. + /// 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 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 new file mode 100644 index 0000000..6e78344 --- /dev/null +++ b/host/identity@1.0.0.wit @@ -0,0 +1,105 @@ +/// 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 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. + +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"). + platform: string, + /// User ID on the external platform. + platform-user-id: string, + } + + /// Successful resolution; carries the user info. Returns + /// `link-not-found` error instead of a flag-based "found: bool". + record identity-resolve-response { + /// The Astrid user ID. + user-id: string, + /// The user's display name, if available. + display-name: option, + } + + /// Request to link a platform identity to an Astrid user. + record identity-link-request { + platform: string, + platform-user-id: string, + astrid-user-id: string, + /// Authentication method (e.g. "passkey", "token"). + method: string, + } + + /// Request to unlink a platform identity. + record identity-unlink-request { + platform: string, + 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, + } + + /// Response from `identity-create-user`. + record identity-create-user-response { + /// The newly created Astrid user ID. + user-id: string, + } + + /// 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. 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. 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. Returns `link-not-found` if no link + /// exists. + identity-unlink: func(request: identity-unlink-request) -> result<_, error-code>; + + /// Create a new Astrid user. + identity-create-user: func(request: identity-create-user-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 new file mode 100644 index 0000000..f34aeb6 --- /dev/null +++ b/host/ipc@1.0.0.wit @@ -0,0 +1,163 @@ +/// Inter-Process Communication (IPC) event bus. +/// +/// Capsules communicate via a publish/subscribe event bus. Topics are +/// 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 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. + +package astrid:ipc@1.0.0; + +interface host { + 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 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 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: principal-attribution, + } + + /// Pre-registered interceptor handle mapping. + record interceptor-binding { + /// Subscription handle ID. + handle-id: u64, + /// Interceptor action name. + action: string, + /// IPC topic pattern this binding is subscribed to. + topic: 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; + + /// 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; + + /// 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; + } + + /// 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>; + + /// 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>; + + /// 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 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 new file mode 100644 index 0000000..4072155 --- /dev/null +++ b/host/kv@1.0.0.wit @@ -0,0 +1,92 @@ +/// 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. +/// +/// 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 { + /// 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 { + /// 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, + } + + /// 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 `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. + /// + /// 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. + /// 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` (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 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/net@1.0.0.wit b/host/net@1.0.0.wit new file mode 100644 index 0000000..a12b55a --- /dev/null +++ b/host/net@1.0.0.wit @@ -0,0 +1,347 @@ +/// 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 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 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. + +package astrid:net@1.0.0; + +interface host { + 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), + /// Stream closed by peer. + closed, + /// No data available (non-blocking). + pending, + } + + /// 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. + receive, + /// Half-close the write side. Peer sees EOF on its read side. + send, + /// Close both directions. + both, + } + + /// Datagram returned by `udp-socket.recv-from`. + record udp-datagram { + /// Payload bytes received. + data: list, + /// Peer host (numeric IP, IPv4 or IPv6). + peer-host: string, + /// Peer UDP port. + peer-port: u16, + } + + // ----------------------------------------------------------------- + // 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`. + 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; + } + + /// 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 + // ----------------------------------------------------------------- + + /// 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>; + + /// `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. + /// Compose with other pollables for multiplexed I/O. + subscribe-readable: func() -> pollable; + } + + // ----------------------------------------------------------------- + // UDP socket resource + // ----------------------------------------------------------------- + + /// 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; + + /// Receive up to `max-bytes`. `none` if no datagram arrived + /// within the read timeout. + recv-from: func(max-bytes: u32) -> result, error-code>; + + // ---- 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"`. + 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; + + /// 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. + connect-tcp: func(host: string, port: u16) -> result; + + /// 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; + + /// 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>; +} diff --git a/host/process@1.0.0.wit b/host/process@1.0.0.wit new file mode 100644 index 0000000..c86d448 --- /dev/null +++ b/host/process@1.0.0.wit @@ -0,0 +1,197 @@ +/// 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 { + use wasi:io/poll@0.2.0.{pollable}; + + /// 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 { + key: string, + 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. For + /// long-lived stdin streaming use `process-handle.write-stdin`. + /// Capped at 4 MiB per spawn (cumulative). + stdin: option>, + /// Environment variables. Replaces the host's default sandbox + /// environment except for a small kernel-passthrough allowlist. + env: list, + /// 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, + /// How the process terminated. + exit: exit-info, + } + + /// 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 info if the process has terminated. + exit: option, + } + + /// Result of killing a background process. + record kill-result { + /// Whether the process was successfully killed. + killed: bool, + /// Exit info if available. + exit: option, + /// Final buffered stdout. + stdout: string, + /// Final buffered stderr. + stderr: string, + } + + /// 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. + /// + /// 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; + + /// 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 + /// 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 new file mode 100644 index 0000000..28ee1ce --- /dev/null +++ b/host/sys@1.0.0.wit @@ -0,0 +1,135 @@ +/// System-level runtime functions: logging, config, time, caller context, +/// entropy, sleep, capability introspection. +/// +/// Astrid does not expose `wasi:random`, `wasi:clocks`, or other generic +/// 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. + +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, + 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, 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`. 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. + /// 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. + /// + /// 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. + /// 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. + /// 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; 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. Length matches + /// WASI random-bytes (`u64`). + /// + /// `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 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 new file mode 100644 index 0000000..1e03c83 --- /dev/null +++ b/host/uplink@1.0.0.wit @@ -0,0 +1,59 @@ +/// 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 { + /// 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. + /// + /// 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 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; +} 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. /// diff --git a/scripts/lint-wit-immutability.sh b/scripts/lint-wit-immutability.sh new file mode 100755 index 0000000..281decc --- /dev/null +++ b/scripts/lint-wit-immutability.sh @@ -0,0 +1,60 @@ +#!/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 -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 + echo "wit immutability lint: ok" + exit 0 +fi + +cat >&2 </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