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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion astrid-sys/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
wit-staging/
# wit-staging/ is committed: build.rs regenerates it from the
# `unicity-astrid/wit` submodule on workspace builds, but the
# committed copy ships with the published crate so `cargo install`
# works without the submodule on the consumer's machine.

2 changes: 1 addition & 1 deletion astrid-sys/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ build = "build.rs"
include = [
"src/**/*",
"build.rs",
"../contracts/host/**/*.wit",
"wit-staging/**/*.wit",
"Cargo.toml",
"LICENSE-*",
"README.md",
Expand Down
59 changes: 50 additions & 9 deletions astrid-sys/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,20 @@
//!
//! No external WIT packages are vendored — the contract is fully
//! Astrid-owned (`astrid:*` only, no `wasi:*` dependency).
//!
//! Two execution modes:
//!
//! - **Workspace builds**: the `contracts/` submodule is present at
//! `sdk-rust/contracts/host/`. Clean and re-stage `wit-staging/` from
//! the submodule so the committed copy stays in lockstep with the
//! canonical source.
//! - **Published builds** (`cargo install`, `cargo publish` verifier):
//! the submodule isn't part of the `.crate` tarball. Skip staging —
//! the committed `wit-staging/` ships with the crate and is what
//! `wit_bindgen::generate!` consumes.

use std::fs;
use std::path::{Path, PathBuf};
use std::path::PathBuf;

fn main() {
// Tell rustc the `getrandom_backend="custom"` cfg flag is known —
Expand All @@ -26,14 +37,49 @@ fn main() {
println!("cargo::rustc-check-cfg=cfg(getrandom_backend, values(\"custom\"))");

let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let contracts_root = crate_root
let host_src = crate_root
.parent() // sdk-rust/
.expect("astrid-sys must live under the sdk-rust workspace root")
.join("contracts");
.join("contracts")
.join("host");

let staging = crate_root.join("wit-staging");
let deps = staging.join("deps");

// Published-crate path: the `unicity-astrid/wit` submodule isn't
// available on a consumer's machine. The committed `wit-staging/`
// ships with the crate; `src/lib.rs`'s `wit_bindgen::generate!`
// reads it directly. Skip the stage step.
//
// Empty-submodule path: a fresh clone without `git submodule
// update --init` leaves `host_src/` non-existent or empty. Treat
// identically to the published-crate path so we don't wipe the
// committed wit-staging.
let has_wit_files = fs::read_dir(&host_src)
.map(|entries| {
entries.filter_map(Result::ok).any(|e| {
e.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("wit"))
})
})
.unwrap_or(false);
if !has_wit_files {
// Watch the same surface we'd watch in the staging path. Without
// these, Cargo won't rerun build.rs after a developer runs
// `git submodule update --init` against a fresh clone, so the
// committed wit-staging would stay stale relative to the now-
// checked-out submodule.
println!("cargo:rerun-if-changed=wit-staging");
println!("cargo:rerun-if-changed={}", host_src.display());
println!("cargo:rerun-if-changed=build.rs");
println!(
"cargo:rerun-if-changed={}",
crate_root.parent().unwrap().join(".gitmodules").display()
);
return;
}
Comment thread
joshuajbouw marked this conversation as resolved.

if staging.exists() {
fs::remove_dir_all(&staging).expect("clean wit-staging");
}
Expand All @@ -48,7 +94,6 @@ fn main() {
)
.expect("write root.wit");

let host_src = contracts_root.join("host");
for entry in fs::read_dir(&host_src).expect("read contracts/host") {
let entry = entry.unwrap();
let path = entry.path();
Expand All @@ -70,7 +115,7 @@ fn main() {
println!("cargo:rerun-if-changed={}", path.display());
}

rerun_if_dir_changed(&host_src);
println!("cargo:rerun-if-changed={}", host_src.display());
println!("cargo:rerun-if-changed=build.rs");
// CI environments may run `git submodule update` lazily; the
// .gitmodules pointer changing without the working tree yet
Expand All @@ -80,7 +125,3 @@ fn main() {
crate_root.parent().unwrap().join(".gitmodules").display()
);
}

fn rerun_if_dir_changed(dir: &Path) {
println!("cargo:rerun-if-changed={}", dir.display());
}
73 changes: 73 additions & 0 deletions astrid-sys/wit-staging/deps/astrid-approval/approval@1.0.0.wit
Original file line number Diff line number Diff line change
@@ -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<approval-response, error-code>;
}
78 changes: 78 additions & 0 deletions astrid-sys/wit-staging/deps/astrid-elicit/elicit@1.0.0.wit
Original file line number Diff line number Diff line change
@@ -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<list<string>>,
/// Default value.
default-value: option<string>,
}

/// Response from an elicit call.
variant elicit-response {
/// Single text/select value.
value(string),
/// Multiple values (from `array` type).
values(list<string>),
/// 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<elicit-response, error-code>;

/// 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<bool, error-code>;
}
Loading
Loading