Skip to main content

arkhe_forge_platform/hook_host/
mod.rs

1//! Hook host — pre-submit capability-bounded extension point.
2//!
3//! ## Hook contract
4//!
5//! Submit-side execution order:
6//!
7//! ```text
8//! Auth & Quota → Hook (extra_bytes only) → Policy re-validation → Build → Submit
9//! ```
10//!
11//! A hook can mutate **only** the [`ExtraBytesBuilder`] — every other
12//! field of the in-flight submission (`actor`, `verb`, `target`,
13//! `shell_id`, `principal`) is opaque to the hook. Post-hook policy
14//! re-validation re-checks the same predicates as pre-hook — a hook that
15//! edits extra bytes in a way that changes policy outcome is rejected
16//! at the re-validation step (confused-deputy defense).
17//!
18//! ## Two implementations
19//!
20//! - **[`NoopHookHost`]** (always compiled): pass-through that returns
21//!   `Ok(())` without mutating extra bytes. Selected when the runtime
22//!   does not opt in to a sandbox-backed hook.
23//! - **`WasmtimeHookHost`** (feature `tier-2-hook-host-v2`): wasmtime
24//!   preview-2 sandbox with fuel-metered execution, capability-token
25//!   whitelist (`arkhe:hook/{state, emit, fuel}`), and 4-set host-fn
26//!   surface enforcing E14.L2-Allow at runtime. See
27//!   [`wasmtime_host`] (feature-gated) for the concrete sandbox host.
28//!
29//! Both implement the [`HookHost`] trait, so submit-side callers stay
30//! agnostic of the active backend.
31//!
32//! ## Spec anchor
33//!
34//! - **E14 Compute Determinism Closure** — paired E14.L1-Deny
35//!   (build-time AST deny-list) + E14.L2-Allow (runtime host-import
36//!   allow-list, this module).
37//! - **Hook-host 3-tier ingestion** — BLAKE3 digest pin (sigstore +
38//!   cargo-vet attestation tiers route through
39//!   [`wasmtime_host::HookAttestationVerifier`]).
40
41use bytes::Bytes;
42
43#[cfg(feature = "tier-2-hook-host-v2")]
44pub mod capability_linker;
45#[cfg(feature = "tier-2-hook-host-v2")]
46pub mod wasmtime_host;
47
48/// Mutable extra-bytes accumulator threaded through hook invocations.
49/// Hooks may append; they cannot read prior policy-invariant fields.
50/// Wraps the existing `bytes::BytesMut` shape so the L1 builder can
51/// adopt it without re-allocating after the hook returns.
52#[derive(Debug, Default)]
53pub struct ExtraBytesBuilder {
54    inner: bytes::BytesMut,
55}
56
57impl ExtraBytesBuilder {
58    /// New empty builder.
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Append a byte slice to the builder.
64    pub fn append(&mut self, bytes: &[u8]) {
65        self.inner.extend_from_slice(bytes);
66    }
67
68    /// Current accumulated length.
69    pub fn len(&self) -> usize {
70        self.inner.len()
71    }
72
73    /// Empty check.
74    pub fn is_empty(&self) -> bool {
75        self.inner.is_empty()
76    }
77
78    /// Freeze into an immutable [`Bytes`] for downstream submission build.
79    pub fn freeze(self) -> Bytes {
80        self.inner.freeze()
81    }
82}
83
84/// Capability tokens an enabled hook may request from the host. Each
85/// token grants permission to call a single host-side function — non-
86/// whitelisted imports are rejected at module-load (E14.L2-Allow
87/// enforcement).
88///
89/// `#[non_exhaustive]` — additive expansion is non-breaking (TypeCode
90/// forward-compat). `Ord`/`PartialOrd` are required so `CapToken` can
91/// live in a deterministic [`std::collections::BTreeSet`] (the
92/// `HookStoreData::capabilities` container — deterministic iteration
93/// matters for the call-time capability check audit log).
94#[non_exhaustive]
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
96pub enum CapToken {
97    /// `arkhe:hook/state.read` — read the hook-scoped key/value scratchpad.
98    StateRead,
99    /// `arkhe:hook/state.write` — write the hook-scoped scratchpad.
100    StateWrite,
101    /// `arkhe:hook/emit.extra_bytes` — append into [`ExtraBytesBuilder`].
102    EmitExtraBytes,
103    /// `arkhe:hook/fuel.consumed` — query remaining fuel budget.
104    FuelConsumed,
105}
106
107// SealedCapToken safeguard. The hook `CapToken` enum implements both
108// the private `Sealed` marker (only reachable from
109// `arkhe-forge-platform`) and the public
110// `wasm_runtime_common::HookCapTokenSealed` trait. External crates
111// cannot satisfy the `Sealed` bound, so the hook-cap-token universe is
112// closed at the host-defining crate boundary — preserving the
113// E14.L2-Allow rule 3 host-import allow-list integrity at compile-time.
114//
115// Compiled only when at least one wasmtime feature is enabled (matches
116// `wasm_runtime_common`'s feature gate).
117#[cfg(any(feature = "tier-2-hook-host-v2", feature = "tier-2-observer-host-v2"))]
118impl crate::wasm_runtime_common::sealed_impl::Sealed for CapToken {}
119#[cfg(any(feature = "tier-2-hook-host-v2", feature = "tier-2-observer-host-v2"))]
120impl crate::wasm_runtime_common::HookCapTokenSealed for CapToken {}
121
122/// Hook execution context — opaque to hooks themselves; managed by the
123/// host. Carries the capability set + the extra-bytes builder; sandbox-
124/// backed hosts (`WasmtimeHookHost`) thread these into a per-invocation
125/// wasmtime `Store` / `Caller<'_, _>` internally.
126#[derive(Debug)]
127pub struct HookContext<'b> {
128    /// Capability tokens granted by the manifest for this hook
129    /// invocation.
130    pub capabilities: &'b [CapToken],
131    /// Extra-bytes builder threaded through Hook → Policy re-validation
132    /// → L1 build.
133    pub extra: &'b mut ExtraBytesBuilder,
134}
135
136/// Hook execution outcome.
137#[non_exhaustive]
138#[derive(Debug, thiserror::Error)]
139pub enum HookError {
140    /// Hook invocation exceeded the 10 ms / fuel budget. Surfaces only
141    /// from sandbox-backed hosts; the no-op pass-through never returns
142    /// this.
143    #[error("hook budget exhausted")]
144    BudgetExceeded,
145    /// Hook tried to import a host function not in its capability set.
146    /// Rejected at module-load (sandbox-backed host) or as a call-site
147    /// fallback.
148    #[error("capability denied: {0:?}")]
149    CapabilityDenied(CapToken),
150    /// Hook invocation trapped — host catches and quarantines without
151    /// propagating the unwind (analog of L0 A22).
152    #[error("hook trap: {0}")]
153    Trapped(&'static str),
154    /// Post-hook policy re-validation rejected the mutated extra bytes
155    /// (confused-deputy defense — hook attempted to flip policy
156    /// outcome).
157    #[error("post-hook policy re-validation failed")]
158    PolicyReValidationFailed,
159}
160
161/// Pre-submit hook host — the L2 service that runs registered hooks
162/// against an in-flight submission's `extra_bytes` buffer.
163///
164/// # Backends
165///
166/// The runtime selects between two backends:
167/// - [`NoopHookHost`] (always available) — pass-through that returns
168///   `Ok(())` without mutation.
169/// - `WasmtimeHookHost` (feature `tier-2-hook-host-v2`) — wasmtime
170///   preview-2 sandbox with fuel-metered execution + capability-
171///   whitelisted host-fns.
172pub trait HookHost {
173    /// Invoke the hook bound to the given submission. Implementations
174    /// may mutate `ctx.extra`; they may read `ctx.capabilities`. The
175    /// no-op pass-through skips invocation entirely.
176    fn invoke(&self, ctx: &mut HookContext<'_>) -> Result<(), HookError>;
177}
178
179/// Pass-through host — returns `Ok(())` without mutating extra bytes.
180/// The Hook host box in the contract diagram runs this implementation when
181/// the runtime is not configured for sandbox-backed hooks.
182#[derive(Debug, Default, Clone, Copy)]
183pub struct NoopHookHost;
184
185impl NoopHookHost {
186    /// New no-op host.
187    pub fn new() -> Self {
188        Self
189    }
190}
191
192impl HookHost for NoopHookHost {
193    fn invoke(&self, _ctx: &mut HookContext<'_>) -> Result<(), HookError> {
194        Ok(())
195    }
196}
197
198#[cfg(test)]
199#[allow(clippy::expect_used, clippy::unwrap_used)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn extra_bytes_builder_append_and_freeze() {
205        let mut b = ExtraBytesBuilder::new();
206        assert!(b.is_empty());
207        b.append(b"hello ");
208        b.append(b"world");
209        assert_eq!(b.len(), 11);
210        let frozen = b.freeze();
211        assert_eq!(&frozen[..], b"hello world");
212    }
213
214    #[test]
215    fn noop_host_passes_through_without_mutation() {
216        let host = NoopHookHost::new();
217        let mut extra = ExtraBytesBuilder::new();
218        extra.append(b"prefix");
219        let caps = [CapToken::EmitExtraBytes];
220        let mut ctx = HookContext {
221            capabilities: &caps,
222            extra: &mut extra,
223        };
224        assert!(host.invoke(&mut ctx).is_ok());
225        // Pass-through must not have mutated extra bytes.
226        assert_eq!(extra.len(), 6);
227    }
228
229    #[test]
230    fn cap_token_set_round_trips() {
231        // Each currently-shipped `CapToken` variant is reachable + has
232        // a stable `Debug` discriminator. External crates must wildcard
233        // because the enum is `#[non_exhaustive]` (additive expansion
234        // is non-breaking — see TypeCode reservation policy).
235        let tokens = [
236            CapToken::StateRead,
237            CapToken::StateWrite,
238            CapToken::EmitExtraBytes,
239            CapToken::FuelConsumed,
240        ];
241        for t in tokens {
242            // Trivial round-trip — discriminator stays printable; copy
243            // semantics work; equality is reflexive.
244            assert_eq!(t, t);
245            assert!(!format!("{t:?}").is_empty());
246        }
247    }
248
249    #[test]
250    fn hook_error_display_does_not_panic() {
251        let errors = [
252            HookError::BudgetExceeded,
253            HookError::CapabilityDenied(CapToken::StateRead),
254            HookError::Trapped("unexpected"),
255            HookError::PolicyReValidationFailed,
256        ];
257        for e in errors {
258            assert!(!format!("{e}").is_empty());
259        }
260    }
261}