clawft_kernel/wasm_runner/types.rs
1//! WASM runner types: configuration, errors, tool specs, signing, and sandbox.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::time::Duration;
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::governance::EffectVector;
11
12/// Serde support for [u8; 64] as hex strings (used for Ed25519 signatures).
13pub(crate) mod sig_serde {
14 use serde::{self, Deserialize, Deserializer, Serializer};
15
16 pub fn serialize<S>(hash: &[u8; 64], serializer: S) -> Result<S::Ok, S::Error>
17 where
18 S: Serializer,
19 {
20 let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
21 serializer.serialize_str(&hex)
22 }
23
24 pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 64], D::Error>
25 where
26 D: Deserializer<'de>,
27 {
28 let s = String::deserialize(deserializer)?;
29 let bytes: Vec<u8> = (0..s.len())
30 .step_by(2)
31 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(serde::de::Error::custom))
32 .collect::<Result<Vec<u8>, _>>()?;
33 let mut arr = [0u8; 64];
34 if bytes.len() != 64 {
35 return Err(serde::de::Error::custom(format!(
36 "expected 64 bytes, got {}",
37 bytes.len()
38 )));
39 }
40 arr.copy_from_slice(&bytes);
41 Ok(arr)
42 }
43}
44
45// ---------------------------------------------------------------------------
46// Sandbox configuration
47// ---------------------------------------------------------------------------
48
49/// Configuration for the WASM sandbox.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct WasmSandboxConfig {
52 /// Maximum fuel units (roughly equivalent to instructions).
53 /// Default: 1,000,000 (~100ms on modern hardware).
54 #[serde(default = "default_max_fuel")]
55 pub max_fuel: u64,
56
57 /// Maximum memory in bytes the WASM module may allocate.
58 /// Default: 16 MiB.
59 #[serde(default = "default_max_memory")]
60 pub max_memory_bytes: usize,
61
62 /// Wall-clock timeout for execution.
63 /// Default: 30 seconds.
64 #[serde(default = "default_max_execution_secs", alias = "maxExecutionTimeSecs")]
65 pub max_execution_time_secs: u64,
66
67 /// Host function calls the WASM module is allowed to make.
68 /// Empty means no host calls permitted.
69 #[serde(default)]
70 pub allowed_host_calls: Vec<String>,
71
72 /// Whether to enable WASI (basic I/O, no filesystem).
73 #[serde(default)]
74 pub wasi_enabled: bool,
75
76 /// Maximum WASM module size in bytes before loading.
77 /// Default: 10 MiB.
78 #[serde(default = "default_max_module_size")]
79 pub max_module_size_bytes: usize,
80}
81
82fn default_max_fuel() -> u64 {
83 1_000_000
84}
85
86fn default_max_memory() -> usize {
87 16 * 1024 * 1024 // 16 MiB
88}
89
90fn default_max_execution_secs() -> u64 {
91 30
92}
93
94fn default_max_module_size() -> usize {
95 10 * 1024 * 1024 // 10 MiB
96}
97
98impl Default for WasmSandboxConfig {
99 fn default() -> Self {
100 Self {
101 max_fuel: default_max_fuel(),
102 max_memory_bytes: default_max_memory(),
103 max_execution_time_secs: default_max_execution_secs(),
104 allowed_host_calls: Vec::new(),
105 wasi_enabled: false,
106 max_module_size_bytes: default_max_module_size(),
107 }
108 }
109}
110
111impl WasmSandboxConfig {
112 /// Get the execution timeout as a Duration.
113 pub fn execution_timeout(&self) -> Duration {
114 Duration::from_secs(self.max_execution_time_secs)
115 }
116}
117
118// ---------------------------------------------------------------------------
119// Per-execution state
120// ---------------------------------------------------------------------------
121
122/// Per-execution state for a WASM tool.
123#[derive(Debug, Clone, Default)]
124pub struct ToolState {
125 /// Name of the tool being executed.
126 pub tool_name: String,
127
128 /// Input data (stdin equivalent).
129 pub stdin: Vec<u8>,
130
131 /// Output data (stdout equivalent).
132 pub stdout: Vec<u8>,
133
134 /// Error output data (stderr equivalent).
135 pub stderr: Vec<u8>,
136
137 /// Environment variables available to the tool.
138 pub env: HashMap<String, String>,
139}
140
141/// Result of a WASM tool execution.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct WasmToolResult {
144 /// Standard output from the tool.
145 pub stdout: String,
146
147 /// Standard error from the tool.
148 pub stderr: String,
149
150 /// Exit code (0 = success).
151 pub exit_code: i32,
152
153 /// Fuel units consumed during execution.
154 pub fuel_consumed: u64,
155
156 /// Peak memory usage in bytes.
157 pub memory_peak: usize,
158
159 /// Actual execution duration.
160 #[serde(with = "duration_millis")]
161 pub execution_time: Duration,
162}
163
164/// Serialization helper for Duration as milliseconds.
165mod duration_millis {
166 use serde::{Deserialize, Deserializer, Serialize, Serializer};
167 use std::time::Duration;
168
169 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
170 d.as_millis().serialize(s)
171 }
172
173 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
174 let ms = u64::deserialize(d)?;
175 Ok(Duration::from_millis(ms))
176 }
177}
178
179/// Validation result for a WASM module.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct WasmValidation {
182 /// Whether the module is valid.
183 pub valid: bool,
184
185 /// Exported function names.
186 pub exports: Vec<String>,
187
188 /// Required import names.
189 pub imports: Vec<String>,
190
191 /// Estimated initial memory requirement.
192 pub estimated_memory: usize,
193
194 /// Warnings about the module (non-fatal issues).
195 pub warnings: Vec<String>,
196}
197
198// ---------------------------------------------------------------------------
199// Errors
200// ---------------------------------------------------------------------------
201
202/// WASM runner errors.
203#[non_exhaustive]
204#[derive(Debug, thiserror::Error)]
205pub enum WasmError {
206 /// The WASM runtime is not available (feature not enabled).
207 #[error("WASM runtime unavailable: compile with --features wasm-sandbox")]
208 RuntimeUnavailable,
209
210 /// The WASM module bytes are invalid.
211 #[error("invalid WASM module: {0}")]
212 InvalidModule(String),
213
214 /// Module compilation failed.
215 #[error("compilation failed: {0}")]
216 CompilationFailed(String),
217
218 /// The tool exhausted its fuel budget.
219 #[error("fuel exhausted after {consumed} units (limit: {limit})")]
220 FuelExhausted { consumed: u64, limit: u64 },
221
222 /// Memory allocation exceeded the configured limit.
223 #[error("memory limit exceeded: {allocated} bytes (limit: {limit} bytes)")]
224 MemoryLimitExceeded { allocated: usize, limit: usize },
225
226 /// Execution exceeded the wall-clock timeout.
227 #[error("execution timeout after {0:?}")]
228 ExecutionTimeout(Duration),
229
230 /// A WASM trap occurred during execution.
231 #[error("WASM trap: {0}")]
232 WasmTrap(String),
233
234 /// A host function call was denied by sandbox policy.
235 #[error("host call denied: {0}")]
236 HostCallDenied(String),
237
238 /// The module exceeds the maximum allowed size.
239 #[error("module too large: {size} bytes (limit: {limit} bytes)")]
240 ModuleTooLarge { size: usize, limit: usize },
241
242 /// Execution denied by governance gate.
243 #[error("governance denied: {0}")]
244 GovernanceDenied(String),
245}
246
247/// Tool execution errors.
248#[non_exhaustive]
249#[derive(Debug, thiserror::Error)]
250pub enum ToolError {
251 #[error("tool not found: {0}")]
252 NotFound(String),
253 #[error("invalid arguments: {0}")]
254 InvalidArgs(String),
255 #[error("execution failed: {0}")]
256 ExecutionFailed(String),
257 #[error("file not found: {0}")]
258 FileNotFound(String),
259 #[error("permission denied: {0}")]
260 PermissionDenied(String),
261 #[error("file too large: {size} bytes (limit: {limit} bytes)")]
262 FileTooLarge { size: u64, limit: u64 },
263 #[error("signature required: {0}")]
264 SignatureRequired(String),
265 #[error("invalid signature: {0}")]
266 InvalidSignature(String),
267 #[error("wasm error: {0}")]
268 Wasm(#[from] WasmError),
269}
270
271// ---------------------------------------------------------------------------
272// Built-in tool catalog types
273// ---------------------------------------------------------------------------
274
275/// Category of a built-in kernel tool.
276#[non_exhaustive]
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub enum ToolCategory {
279 Filesystem,
280 Agent,
281 System,
282 /// ECC cognitive substrate tools (behind `ecc` feature).
283 Ecc,
284 User,
285}
286
287/// Specification of a built-in kernel tool.
288///
289/// Named `BuiltinToolSpec` to distinguish from [`crate::app::ToolSpec`]
290/// which describes application-provided tools.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct BuiltinToolSpec {
293 /// Dotted tool name (e.g. "fs.read_file").
294 pub name: String,
295 /// Category (Filesystem, Agent, System, User).
296 pub category: ToolCategory,
297 /// Human-readable description.
298 pub description: String,
299 /// JSON Schema for parameters.
300 pub parameters: serde_json::Value,
301 /// GovernanceGate action string (e.g. "tool.fs.read").
302 pub gate_action: String,
303 /// Effect vector for governance scoring.
304 pub effect: EffectVector,
305 /// Whether this tool can run natively (without WASM).
306 pub native: bool,
307}
308
309/// A deployed version of a tool.
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct ToolVersion {
312 /// Version number (monotonically increasing per tool).
313 pub version: u32,
314 /// SHA-256 hash of the WASM module bytes.
315 pub module_hash: [u8; 32],
316 /// Ed25519 signature over module_hash (zero if unsigned).
317 #[serde(with = "sig_serde")]
318 pub signature: [u8; 64],
319 /// When this version was deployed.
320 pub deployed_at: DateTime<Utc>,
321 /// Whether this version has been revoked.
322 pub revoked: bool,
323 /// Chain sequence number of the deploy event.
324 pub chain_seq: u64,
325}
326
327/// A tool with its spec and version history.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct DeployedTool {
330 /// Tool specification.
331 pub spec: BuiltinToolSpec,
332 /// Version history (ordered by version number).
333 pub versions: Vec<ToolVersion>,
334 /// Currently active version number.
335 pub active_version: u32,
336}
337
338/// A loaded WASM tool module.
339#[derive(Debug, Clone)]
340pub struct WasmTool {
341 /// Tool name.
342 pub name: String,
343
344 /// Module size in bytes.
345 pub module_size: usize,
346
347 /// SHA-256 hash of module bytes.
348 pub module_hash: [u8; 32],
349
350 /// Tool parameter schema (if exported by the module).
351 pub schema: Option<serde_json::Value>,
352
353 /// Exported function names.
354 pub exports: Vec<String>,
355}
356
357// ---------------------------------------------------------------------------
358// WASI filesystem scope
359// ---------------------------------------------------------------------------
360
361/// WASI filesystem access scope for a tool.
362#[non_exhaustive]
363#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
364pub enum WasiFsScope {
365 /// No filesystem access.
366 #[default]
367 None,
368 /// Read-only access to a directory.
369 ReadOnly(PathBuf),
370 /// Read-write access to a directory.
371 ReadWrite(PathBuf),
372}
373
374// ---------------------------------------------------------------------------
375// CA chain signing
376// ---------------------------------------------------------------------------
377
378/// Tool signing authority -- identifies who signed a tool module.
379#[non_exhaustive]
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub enum ToolSigningAuthority {
382 /// Signed by the kernel's built-in key.
383 Kernel,
384 /// Signed by a developer with a certificate chain.
385 Developer {
386 /// Certificate chain (leaf first, root last).
387 cert_chain: Vec<Certificate>,
388 },
389}
390
391/// A signing certificate in the CA chain.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct Certificate {
394 /// Subject name (e.g. "developer@example.com").
395 pub subject: String,
396 /// Ed25519 public key bytes (32 bytes).
397 pub public_key: [u8; 32],
398 /// Signature over subject + public_key by the issuer.
399 #[serde(with = "sig_serde")]
400 pub signature: [u8; 64],
401 /// Issuer subject name.
402 pub issuer: String,
403}
404
405// ---------------------------------------------------------------------------
406// Tool Signature for ExoChain registration
407// ---------------------------------------------------------------------------
408
409/// A cryptographic signature binding a tool definition to a signer identity.
410///
411/// Used by [`ToolRegistry::register_signed`] to gate tool registration
412/// behind signature verification when `require_signatures` is enabled.
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct ToolSignature {
415 /// Name of the tool being signed.
416 pub tool_name: String,
417 /// SHA-256 hash of the tool definition (spec JSON bytes).
418 pub tool_hash: [u8; 32],
419 /// Identity of the signer (e.g. public key hex or developer id).
420 pub signer_id: String,
421 /// Ed25519 signature bytes over `tool_hash`.
422 pub signature: Vec<u8>,
423 /// Timestamp when the signature was created.
424 pub signed_at: DateTime<Utc>,
425}
426
427impl ToolSignature {
428 /// Create a new tool signature from components.
429 pub fn new(
430 tool_name: impl Into<String>,
431 tool_hash: [u8; 32],
432 signer_id: impl Into<String>,
433 signature: Vec<u8>,
434 ) -> Self {
435 Self {
436 tool_name: tool_name.into(),
437 tool_hash,
438 signer_id: signer_id.into(),
439 signature,
440 signed_at: Utc::now(),
441 }
442 }
443
444 /// Verify this signature against a 32-byte Ed25519 public key.
445 ///
446 /// Returns `true` if the signature is valid for `self.tool_hash`.
447 /// Requires `exochain` feature; without it, always returns `false`.
448 pub fn verify(&self, public_key: &[u8; 32]) -> bool {
449 if self.signature.len() != 64 {
450 return false;
451 }
452 let mut sig_bytes = [0u8; 64];
453 sig_bytes.copy_from_slice(&self.signature);
454 verify_tool_signature(&self.tool_hash, &sig_bytes, public_key)
455 }
456}
457
458/// Verify a tool's Ed25519 signature against a public key.
459///
460/// Requires the `exochain` feature for real Ed25519 verification.
461/// Without the feature, always returns `false`.
462#[cfg(feature = "exochain")]
463pub fn verify_tool_signature(
464 module_hash: &[u8; 32],
465 signature: &[u8; 64],
466 public_key: &[u8; 32],
467) -> bool {
468 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
469 let Ok(vk) = VerifyingKey::from_bytes(public_key) else {
470 return false;
471 };
472 let sig = Signature::from_bytes(signature);
473 vk.verify(module_hash, &sig).is_ok()
474}
475
476/// Stub: always returns `false` when `exochain` feature is disabled.
477#[cfg(not(feature = "exochain"))]
478pub fn verify_tool_signature(
479 _module_hash: &[u8; 32],
480 _signature: &[u8; 64],
481 _public_key: &[u8; 32],
482) -> bool {
483 false
484}
485
486// ---------------------------------------------------------------------------
487// Backend selection
488// ---------------------------------------------------------------------------
489
490/// Backend selection for tool execution.
491#[non_exhaustive]
492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
493pub enum BackendSelection {
494 /// Run natively (no isolation).
495 Native,
496 /// Run in WASM sandbox.
497 Wasm,
498 /// Auto-select based on risk score.
499 Auto,
500}
501
502impl BackendSelection {
503 /// Select backend based on effect vector risk score.
504 ///
505 /// Simple heuristic: risk > 0.3 => WASM sandbox, else native.
506 pub fn from_risk(risk: f64) -> Self {
507 if risk > 0.3 { Self::Wasm } else { Self::Native }
508 }
509}
510
511// ---------------------------------------------------------------------------
512// Multi-layer sandboxing (k3:D12)
513// ---------------------------------------------------------------------------
514
515/// Which sandbox layer denied (or allowed) access.
516///
517/// Three enforcement layers are evaluated in order (k3:D12):
518/// 1. **Governance** -- gate check with tool name + effect vector context
519/// 2. **Environment** -- per-environment allowed-path configuration
520/// 3. **SudoOverride** -- elevated agent capability that bypasses
521/// environment restrictions (logged to chain, requires `sudo` flag)
522///
523/// The first `Deny` short-circuits. `SudoOverride` can only bypass
524/// the **Environment** layer, never the **Governance** layer.
525#[non_exhaustive]
526#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
527pub enum SandboxLayer {
528 /// Governance gate check (always authoritative, cannot be overridden).
529 Governance,
530 /// Environment-scoped path restrictions (e.g. dev=permissive, prod=strict).
531 Environment,
532 /// Elevated override that bypasses environment restrictions.
533 /// Requires `AgentCapabilities::sudo` and is always logged to chain.
534 SudoOverride,
535}
536
537impl std::fmt::Display for SandboxLayer {
538 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
539 match self {
540 SandboxLayer::Governance => write!(f, "governance"),
541 SandboxLayer::Environment => write!(f, "environment"),
542 SandboxLayer::SudoOverride => write!(f, "sudo-override"),
543 }
544 }
545}
546
547/// Result of evaluating the multi-layer sandbox stack.
548#[derive(Debug, Clone, Serialize, Deserialize)]
549pub struct SandboxDecision {
550 /// Whether access is permitted.
551 pub allowed: bool,
552 /// Which layer made the decision.
553 pub decided_by: SandboxLayer,
554 /// Human-readable reason (for logging / chain events).
555 pub reason: String,
556}
557
558impl SandboxDecision {
559 /// Create a permit decision.
560 pub fn permit(layer: SandboxLayer) -> Self {
561 Self {
562 allowed: true,
563 decided_by: layer,
564 reason: "access permitted".into(),
565 }
566 }
567
568 /// Create a deny decision.
569 pub fn deny(layer: SandboxLayer, reason: impl Into<String>) -> Self {
570 Self {
571 allowed: false,
572 decided_by: layer,
573 reason: reason.into(),
574 }
575 }
576}
577
578/// Filesystem sandbox configuration for built-in tools.
579///
580/// Controls which paths a tool is allowed to access. When `allowed_paths`
581/// is non-empty, only files under those directories are permitted.
582/// An empty `allowed_paths` means permissive mode (dev default).
583///
584/// Part of the multi-layer sandboxing stack (k3:D12):
585/// governance gate -> environment config -> sudo override.
586#[derive(Debug, Clone, Default, Serialize, Deserialize)]
587pub struct SandboxConfig {
588 /// Directories the tool is allowed to access.
589 /// Empty = permissive (all paths allowed).
590 pub allowed_paths: Vec<PathBuf>,
591
592 /// Whether sudo override is active for this execution.
593 /// When true and path is denied by environment config, access
594 /// is granted anyway (but logged to chain). Governance denials
595 /// can never be overridden.
596 #[serde(default)]
597 pub sudo_override: bool,
598}
599
600impl SandboxConfig {
601 /// Check whether a path is allowed by this sandbox config.
602 ///
603 /// Returns `true` if `allowed_paths` is empty (permissive mode)
604 /// or the path is under at least one allowed directory.
605 pub fn is_path_allowed(&self, path: &std::path::Path) -> bool {
606 if self.allowed_paths.is_empty() {
607 return true;
608 }
609 // Canonicalize the target path for comparison
610 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
611 self.allowed_paths.iter().any(|allowed| {
612 let allowed_canon = allowed.canonicalize().unwrap_or_else(|_| allowed.clone());
613 canonical.starts_with(&allowed_canon)
614 })
615 }
616
617 /// Multi-layer sandbox check (k3:D12).
618 ///
619 /// Evaluates the environment layer and optional sudo override.
620 /// The governance layer is evaluated separately by the caller
621 /// (via `GovernanceEngine::evaluate`) because it requires the
622 /// full `GovernanceRequest` context.
623 ///
624 /// Evaluation order:
625 /// 1. Environment config (`allowed_paths`) -- if empty, permit.
626 /// 2. If denied and `sudo_override` is true, permit with
627 /// `SandboxLayer::SudoOverride` (caller must log to chain).
628 /// 3. Otherwise deny with `SandboxLayer::Environment`.
629 pub fn check_path_multilayer(&self, path: &std::path::Path) -> SandboxDecision {
630 // Permissive mode (dev default)
631 if self.allowed_paths.is_empty() {
632 return SandboxDecision::permit(SandboxLayer::Environment);
633 }
634
635 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
636 let env_allowed = self.allowed_paths.iter().any(|allowed| {
637 let allowed_canon = allowed.canonicalize().unwrap_or_else(|_| allowed.clone());
638 canonical.starts_with(&allowed_canon)
639 });
640
641 if env_allowed {
642 return SandboxDecision::permit(SandboxLayer::Environment);
643 }
644
645 // Environment denied -- check sudo override
646 if self.sudo_override {
647 return SandboxDecision {
648 allowed: true,
649 decided_by: SandboxLayer::SudoOverride,
650 reason: format!(
651 "sudo override: path {} bypassed environment restriction",
652 path.display()
653 ),
654 };
655 }
656
657 SandboxDecision::deny(
658 SandboxLayer::Environment,
659 format!("path outside sandbox: {}", path.display()),
660 )
661 }
662}