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}