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 {
221 consumed: u64,
222 limit: u64,
223 },
224
225 /// Memory allocation exceeded the configured limit.
226 #[error("memory limit exceeded: {allocated} bytes (limit: {limit} bytes)")]
227 MemoryLimitExceeded {
228 allocated: usize,
229 limit: usize,
230 },
231
232 /// Execution exceeded the wall-clock timeout.
233 #[error("execution timeout after {0:?}")]
234 ExecutionTimeout(Duration),
235
236 /// A WASM trap occurred during execution.
237 #[error("WASM trap: {0}")]
238 WasmTrap(String),
239
240 /// A host function call was denied by sandbox policy.
241 #[error("host call denied: {0}")]
242 HostCallDenied(String),
243
244 /// The module exceeds the maximum allowed size.
245 #[error("module too large: {size} bytes (limit: {limit} bytes)")]
246 ModuleTooLarge {
247 size: usize,
248 limit: usize,
249 },
250}
251
252/// Tool execution errors.
253#[non_exhaustive]
254#[derive(Debug, thiserror::Error)]
255pub enum ToolError {
256 #[error("tool not found: {0}")]
257 NotFound(String),
258 #[error("invalid arguments: {0}")]
259 InvalidArgs(String),
260 #[error("execution failed: {0}")]
261 ExecutionFailed(String),
262 #[error("file not found: {0}")]
263 FileNotFound(String),
264 #[error("permission denied: {0}")]
265 PermissionDenied(String),
266 #[error("file too large: {size} bytes (limit: {limit} bytes)")]
267 FileTooLarge { size: u64, limit: u64 },
268 #[error("signature required: {0}")]
269 SignatureRequired(String),
270 #[error("invalid signature: {0}")]
271 InvalidSignature(String),
272 #[error("wasm error: {0}")]
273 Wasm(#[from] WasmError),
274}
275
276// ---------------------------------------------------------------------------
277// Built-in tool catalog types
278// ---------------------------------------------------------------------------
279
280/// Category of a built-in kernel tool.
281#[non_exhaustive]
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub enum ToolCategory {
284 Filesystem,
285 Agent,
286 System,
287 /// ECC cognitive substrate tools (behind `ecc` feature).
288 Ecc,
289 User,
290}
291
292/// Specification of a built-in kernel tool.
293///
294/// Named `BuiltinToolSpec` to distinguish from [`crate::app::ToolSpec`]
295/// which describes application-provided tools.
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct BuiltinToolSpec {
298 /// Dotted tool name (e.g. "fs.read_file").
299 pub name: String,
300 /// Category (Filesystem, Agent, System, User).
301 pub category: ToolCategory,
302 /// Human-readable description.
303 pub description: String,
304 /// JSON Schema for parameters.
305 pub parameters: serde_json::Value,
306 /// GovernanceGate action string (e.g. "tool.fs.read").
307 pub gate_action: String,
308 /// Effect vector for governance scoring.
309 pub effect: EffectVector,
310 /// Whether this tool can run natively (without WASM).
311 pub native: bool,
312}
313
314/// A deployed version of a tool.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct ToolVersion {
317 /// Version number (monotonically increasing per tool).
318 pub version: u32,
319 /// SHA-256 hash of the WASM module bytes.
320 pub module_hash: [u8; 32],
321 /// Ed25519 signature over module_hash (zero if unsigned).
322 #[serde(with = "sig_serde")]
323 pub signature: [u8; 64],
324 /// When this version was deployed.
325 pub deployed_at: DateTime<Utc>,
326 /// Whether this version has been revoked.
327 pub revoked: bool,
328 /// Chain sequence number of the deploy event.
329 pub chain_seq: u64,
330}
331
332/// A tool with its spec and version history.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct DeployedTool {
335 /// Tool specification.
336 pub spec: BuiltinToolSpec,
337 /// Version history (ordered by version number).
338 pub versions: Vec<ToolVersion>,
339 /// Currently active version number.
340 pub active_version: u32,
341}
342
343/// A loaded WASM tool module.
344#[derive(Debug, Clone)]
345pub struct WasmTool {
346 /// Tool name.
347 pub name: String,
348
349 /// Module size in bytes.
350 pub module_size: usize,
351
352 /// SHA-256 hash of module bytes.
353 pub module_hash: [u8; 32],
354
355 /// Tool parameter schema (if exported by the module).
356 pub schema: Option<serde_json::Value>,
357
358 /// Exported function names.
359 pub exports: Vec<String>,
360}
361
362// ---------------------------------------------------------------------------
363// WASI filesystem scope
364// ---------------------------------------------------------------------------
365
366/// WASI filesystem access scope for a tool.
367#[non_exhaustive]
368#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
369pub enum WasiFsScope {
370 /// No filesystem access.
371 #[default]
372 None,
373 /// Read-only access to a directory.
374 ReadOnly(PathBuf),
375 /// Read-write access to a directory.
376 ReadWrite(PathBuf),
377}
378
379// ---------------------------------------------------------------------------
380// CA chain signing
381// ---------------------------------------------------------------------------
382
383/// Tool signing authority -- identifies who signed a tool module.
384#[non_exhaustive]
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub enum ToolSigningAuthority {
387 /// Signed by the kernel's built-in key.
388 Kernel,
389 /// Signed by a developer with a certificate chain.
390 Developer {
391 /// Certificate chain (leaf first, root last).
392 cert_chain: Vec<Certificate>,
393 },
394}
395
396/// A signing certificate in the CA chain.
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct Certificate {
399 /// Subject name (e.g. "developer@example.com").
400 pub subject: String,
401 /// Ed25519 public key bytes (32 bytes).
402 pub public_key: [u8; 32],
403 /// Signature over subject + public_key by the issuer.
404 #[serde(with = "sig_serde")]
405 pub signature: [u8; 64],
406 /// Issuer subject name.
407 pub issuer: String,
408}
409
410// ---------------------------------------------------------------------------
411// Tool Signature for ExoChain registration
412// ---------------------------------------------------------------------------
413
414/// A cryptographic signature binding a tool definition to a signer identity.
415///
416/// Used by [`ToolRegistry::register_signed`] to gate tool registration
417/// behind signature verification when `require_signatures` is enabled.
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct ToolSignature {
420 /// Name of the tool being signed.
421 pub tool_name: String,
422 /// SHA-256 hash of the tool definition (spec JSON bytes).
423 pub tool_hash: [u8; 32],
424 /// Identity of the signer (e.g. public key hex or developer id).
425 pub signer_id: String,
426 /// Ed25519 signature bytes over `tool_hash`.
427 pub signature: Vec<u8>,
428 /// Timestamp when the signature was created.
429 pub signed_at: DateTime<Utc>,
430}
431
432impl ToolSignature {
433 /// Create a new tool signature from components.
434 pub fn new(
435 tool_name: impl Into<String>,
436 tool_hash: [u8; 32],
437 signer_id: impl Into<String>,
438 signature: Vec<u8>,
439 ) -> Self {
440 Self {
441 tool_name: tool_name.into(),
442 tool_hash,
443 signer_id: signer_id.into(),
444 signature,
445 signed_at: Utc::now(),
446 }
447 }
448
449 /// Verify this signature against a 32-byte Ed25519 public key.
450 ///
451 /// Returns `true` if the signature is valid for `self.tool_hash`.
452 /// Requires `exochain` feature; without it, always returns `false`.
453 pub fn verify(&self, public_key: &[u8; 32]) -> bool {
454 if self.signature.len() != 64 {
455 return false;
456 }
457 let mut sig_bytes = [0u8; 64];
458 sig_bytes.copy_from_slice(&self.signature);
459 verify_tool_signature(&self.tool_hash, &sig_bytes, public_key)
460 }
461}
462
463/// Verify a tool's Ed25519 signature against a public key.
464///
465/// Requires the `exochain` feature for real Ed25519 verification.
466/// Without the feature, always returns `false`.
467#[cfg(feature = "exochain")]
468pub fn verify_tool_signature(
469 module_hash: &[u8; 32],
470 signature: &[u8; 64],
471 public_key: &[u8; 32],
472) -> bool {
473 use ed25519_dalek::{Verifier, VerifyingKey, Signature};
474 let Ok(vk) = VerifyingKey::from_bytes(public_key) else {
475 return false;
476 };
477 let sig = Signature::from_bytes(signature);
478 vk.verify(module_hash, &sig).is_ok()
479}
480
481/// Stub: always returns `false` when `exochain` feature is disabled.
482#[cfg(not(feature = "exochain"))]
483pub fn verify_tool_signature(
484 _module_hash: &[u8; 32],
485 _signature: &[u8; 64],
486 _public_key: &[u8; 32],
487) -> bool {
488 false
489}
490
491// ---------------------------------------------------------------------------
492// Backend selection
493// ---------------------------------------------------------------------------
494
495/// Backend selection for tool execution.
496#[non_exhaustive]
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498pub enum BackendSelection {
499 /// Run natively (no isolation).
500 Native,
501 /// Run in WASM sandbox.
502 Wasm,
503 /// Auto-select based on risk score.
504 Auto,
505}
506
507impl BackendSelection {
508 /// Select backend based on effect vector risk score.
509 ///
510 /// Simple heuristic: risk > 0.3 => WASM sandbox, else native.
511 pub fn from_risk(risk: f64) -> Self {
512 if risk > 0.3 {
513 Self::Wasm
514 } else {
515 Self::Native
516 }
517 }
518}
519
520// ---------------------------------------------------------------------------
521// Multi-layer sandboxing (k3:D12)
522// ---------------------------------------------------------------------------
523
524/// Which sandbox layer denied (or allowed) access.
525///
526/// Three enforcement layers are evaluated in order (k3:D12):
527/// 1. **Governance** -- gate check with tool name + effect vector context
528/// 2. **Environment** -- per-environment allowed-path configuration
529/// 3. **SudoOverride** -- elevated agent capability that bypasses
530/// environment restrictions (logged to chain, requires `sudo` flag)
531///
532/// The first `Deny` short-circuits. `SudoOverride` can only bypass
533/// the **Environment** layer, never the **Governance** layer.
534#[non_exhaustive]
535#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
536pub enum SandboxLayer {
537 /// Governance gate check (always authoritative, cannot be overridden).
538 Governance,
539 /// Environment-scoped path restrictions (e.g. dev=permissive, prod=strict).
540 Environment,
541 /// Elevated override that bypasses environment restrictions.
542 /// Requires `AgentCapabilities::sudo` and is always logged to chain.
543 SudoOverride,
544}
545
546impl std::fmt::Display for SandboxLayer {
547 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548 match self {
549 SandboxLayer::Governance => write!(f, "governance"),
550 SandboxLayer::Environment => write!(f, "environment"),
551 SandboxLayer::SudoOverride => write!(f, "sudo-override"),
552 }
553 }
554}
555
556/// Result of evaluating the multi-layer sandbox stack.
557#[derive(Debug, Clone, Serialize, Deserialize)]
558pub struct SandboxDecision {
559 /// Whether access is permitted.
560 pub allowed: bool,
561 /// Which layer made the decision.
562 pub decided_by: SandboxLayer,
563 /// Human-readable reason (for logging / chain events).
564 pub reason: String,
565}
566
567impl SandboxDecision {
568 /// Create a permit decision.
569 pub fn permit(layer: SandboxLayer) -> Self {
570 Self {
571 allowed: true,
572 decided_by: layer,
573 reason: "access permitted".into(),
574 }
575 }
576
577 /// Create a deny decision.
578 pub fn deny(layer: SandboxLayer, reason: impl Into<String>) -> Self {
579 Self {
580 allowed: false,
581 decided_by: layer,
582 reason: reason.into(),
583 }
584 }
585}
586
587/// Filesystem sandbox configuration for built-in tools.
588///
589/// Controls which paths a tool is allowed to access. When `allowed_paths`
590/// is non-empty, only files under those directories are permitted.
591/// An empty `allowed_paths` means permissive mode (dev default).
592///
593/// Part of the multi-layer sandboxing stack (k3:D12):
594/// governance gate -> environment config -> sudo override.
595#[derive(Debug, Clone, Default, Serialize, Deserialize)]
596pub struct SandboxConfig {
597 /// Directories the tool is allowed to access.
598 /// Empty = permissive (all paths allowed).
599 pub allowed_paths: Vec<PathBuf>,
600
601 /// Whether sudo override is active for this execution.
602 /// When true and path is denied by environment config, access
603 /// is granted anyway (but logged to chain). Governance denials
604 /// can never be overridden.
605 #[serde(default)]
606 pub sudo_override: bool,
607}
608
609impl SandboxConfig {
610 /// Check whether a path is allowed by this sandbox config.
611 ///
612 /// Returns `true` if `allowed_paths` is empty (permissive mode)
613 /// or the path is under at least one allowed directory.
614 pub fn is_path_allowed(&self, path: &std::path::Path) -> bool {
615 if self.allowed_paths.is_empty() {
616 return true;
617 }
618 // Canonicalize the target path for comparison
619 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
620 self.allowed_paths.iter().any(|allowed| {
621 let allowed_canon = allowed.canonicalize().unwrap_or_else(|_| allowed.clone());
622 canonical.starts_with(&allowed_canon)
623 })
624 }
625
626 /// Multi-layer sandbox check (k3:D12).
627 ///
628 /// Evaluates the environment layer and optional sudo override.
629 /// The governance layer is evaluated separately by the caller
630 /// (via `GovernanceEngine::evaluate`) because it requires the
631 /// full `GovernanceRequest` context.
632 ///
633 /// Evaluation order:
634 /// 1. Environment config (`allowed_paths`) -- if empty, permit.
635 /// 2. If denied and `sudo_override` is true, permit with
636 /// `SandboxLayer::SudoOverride` (caller must log to chain).
637 /// 3. Otherwise deny with `SandboxLayer::Environment`.
638 pub fn check_path_multilayer(&self, path: &std::path::Path) -> SandboxDecision {
639 // Permissive mode (dev default)
640 if self.allowed_paths.is_empty() {
641 return SandboxDecision::permit(SandboxLayer::Environment);
642 }
643
644 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
645 let env_allowed = self.allowed_paths.iter().any(|allowed| {
646 let allowed_canon = allowed.canonicalize().unwrap_or_else(|_| allowed.clone());
647 canonical.starts_with(&allowed_canon)
648 });
649
650 if env_allowed {
651 return SandboxDecision::permit(SandboxLayer::Environment);
652 }
653
654 // Environment denied -- check sudo override
655 if self.sudo_override {
656 return SandboxDecision {
657 allowed: true,
658 decided_by: SandboxLayer::SudoOverride,
659 reason: format!(
660 "sudo override: path {} bypassed environment restriction",
661 path.display()
662 ),
663 };
664 }
665
666 SandboxDecision::deny(
667 SandboxLayer::Environment,
668 format!("path outside sandbox: {}", path.display()),
669 )
670 }
671}