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 {
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}