Skip to main content

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}