Skip to main content

clawft_kernel/wasm_runner/
mod.rs

1//! WASM tool execution sandbox and built-in tool catalog.
2//!
3//! Provides types and configuration for running tools inside
4//! isolated WASM sandboxes with fuel metering, memory limits,
5//! and host filesystem isolation.
6//!
7//! # K3 Tool Lifecycle
8//!
9//! Tools go through: Build -> Deploy -> Execute -> Version -> Revoke.
10//! This module provides the execution runtime and tool catalog;
11//! the lifecycle management is in [`crate::tree_manager`].
12//!
13//! # Feature Gate
14//!
15//! This module is compiled unconditionally, but the actual
16//! Wasmtime runtime integration requires the `wasm-sandbox`
17//! feature flag. Without it, [`WasmToolRunner::new`] returns
18//! a runner that rejects all tool loads with [`WasmError::RuntimeUnavailable`].
19//!
20//! # Security
21//!
22//! Each tool execution gets its own isolated store:
23//! - No host filesystem access (unless WASI explicitly enabled)
24//! - No network access
25//! - CPU bounded by fuel metering
26//! - Memory bounded by configurable cap
27//! - Wall-clock timeout as safety net
28
29mod types;
30mod runner;
31mod registry;
32mod catalog;
33mod tools_fs;
34mod tools_agent;
35mod tools_sys;
36
37// Re-export everything from sub-modules to preserve the public API.
38pub use types::*;
39pub use runner::{WasmToolRunner, CompiledModuleCache, compute_module_hash};
40pub use registry::{BuiltinTool, ToolRegistry};
41pub use catalog::builtin_tool_catalog;
42pub use tools_fs::*;
43pub use tools_agent::*;
44pub use tools_sys::*;
45
46// ---------------------------------------------------------------------------
47// Tests
48// ---------------------------------------------------------------------------
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use std::sync::Arc;
54    use std::time::Duration;
55    use std::path::PathBuf;
56
57    // --- Config tests (preserved) ---
58
59    #[test]
60    fn default_config() {
61        let config = WasmSandboxConfig::default();
62        assert_eq!(config.max_fuel, 1_000_000);
63        assert_eq!(config.max_memory_bytes, 16 * 1024 * 1024);
64        assert_eq!(config.max_execution_time_secs, 30);
65        assert!(config.allowed_host_calls.is_empty());
66        assert!(!config.wasi_enabled);
67        assert_eq!(config.max_module_size_bytes, 10 * 1024 * 1024);
68    }
69
70    #[test]
71    fn config_serde_roundtrip() {
72        let config = WasmSandboxConfig {
73            max_fuel: 500_000,
74            max_memory_bytes: 8 * 1024 * 1024,
75            max_execution_time_secs: 10,
76            allowed_host_calls: vec!["clock_time_get".into()],
77            wasi_enabled: true,
78            max_module_size_bytes: 5 * 1024 * 1024,
79        };
80        let json = serde_json::to_string(&config).unwrap();
81        let restored: WasmSandboxConfig = serde_json::from_str(&json).unwrap();
82        assert_eq!(restored.max_fuel, 500_000);
83        assert!(restored.wasi_enabled);
84        assert_eq!(restored.allowed_host_calls.len(), 1);
85    }
86
87    #[test]
88    fn execution_timeout_duration() {
89        let config = WasmSandboxConfig {
90            max_execution_time_secs: 15,
91            ..Default::default()
92        };
93        assert_eq!(config.execution_timeout(), Duration::from_secs(15));
94    }
95
96    #[test]
97    fn tool_state_default() {
98        let state = ToolState::default();
99        assert!(state.tool_name.is_empty());
100        assert!(state.stdin.is_empty());
101        assert!(state.stdout.is_empty());
102        assert!(state.stderr.is_empty());
103        assert!(state.env.is_empty());
104    }
105
106    // --- Validation tests (preserved) ---
107
108    #[test]
109    fn validate_wasm_rejects_too_large() {
110        let runner = WasmToolRunner::new(WasmSandboxConfig {
111            max_module_size_bytes: 100,
112            ..Default::default()
113        });
114        let big_bytes = vec![0u8; 200];
115        let result = runner.validate_wasm(&big_bytes);
116        assert!(matches!(result, Err(WasmError::ModuleTooLarge { .. })));
117    }
118
119    #[test]
120    fn validate_wasm_rejects_invalid_magic() {
121        let runner = WasmToolRunner::new(WasmSandboxConfig::default());
122        let bad_bytes = b"not a wasm module at all";
123        let result = runner.validate_wasm(bad_bytes);
124        assert!(matches!(result, Err(WasmError::InvalidModule(_))));
125    }
126
127    #[test]
128    fn validate_wasm_rejects_too_short() {
129        let runner = WasmToolRunner::new(WasmSandboxConfig::default());
130        let short = b"\0asm";
131        let result = runner.validate_wasm(short);
132        assert!(matches!(result, Err(WasmError::InvalidModule(_))));
133    }
134
135    #[test]
136    fn validate_wasm_accepts_valid_header() {
137        let runner = WasmToolRunner::new(WasmSandboxConfig::default());
138        let mut wasm = Vec::new();
139        wasm.extend_from_slice(b"\0asm");
140        wasm.extend_from_slice(&1u32.to_le_bytes());
141
142        let result = runner.validate_wasm(&wasm);
143        assert!(result.is_ok());
144        let validation = result.unwrap();
145        assert!(validation.valid);
146        assert!(validation.warnings.is_empty());
147    }
148
149    #[test]
150    fn validate_wasm_warns_on_wrong_version() {
151        let runner = WasmToolRunner::new(WasmSandboxConfig::default());
152        let mut wasm = Vec::new();
153        wasm.extend_from_slice(b"\0asm");
154        wasm.extend_from_slice(&2u32.to_le_bytes());
155
156        #[cfg(not(feature = "wasm-sandbox"))]
157        {
158            let result = runner.validate_wasm(&wasm).unwrap();
159            assert!(result.valid);
160            assert!(!result.warnings.is_empty());
161            assert!(result.warnings[0].contains("version: 2"));
162        }
163        #[cfg(feature = "wasm-sandbox")]
164        {
165            let result = runner.validate_wasm(&wasm);
166            assert!(result.is_err() || {
167                let v = result.unwrap();
168                !v.warnings.is_empty()
169            });
170        }
171    }
172
173    #[test]
174    fn load_tool_without_feature_rejects() {
175        let runner = WasmToolRunner::new(WasmSandboxConfig::default());
176        let mut wasm = Vec::new();
177        wasm.extend_from_slice(b"\0asm");
178        wasm.extend_from_slice(&1u32.to_le_bytes());
179        wasm.extend_from_slice(&[0u8; 16]);
180
181        #[cfg(not(feature = "wasm-sandbox"))]
182        {
183            let result = runner.load_tool("test-tool", &wasm);
184            assert!(matches!(result, Err(WasmError::RuntimeUnavailable)));
185        }
186    }
187
188    #[test]
189    fn wasm_error_display() {
190        let err = WasmError::RuntimeUnavailable;
191        assert!(err.to_string().contains("wasm-sandbox"));
192
193        let err = WasmError::ModuleTooLarge {
194            size: 20_000_000,
195            limit: 10_000_000,
196        };
197        assert!(err.to_string().contains("20000000"));
198        assert!(err.to_string().contains("10000000"));
199
200        let err = WasmError::FuelExhausted {
201            consumed: 1_000_000,
202            limit: 1_000_000,
203        };
204        assert!(err.to_string().contains("fuel exhausted"));
205
206        let err = WasmError::MemoryLimitExceeded {
207            allocated: 32_000_000,
208            limit: 16_000_000,
209        };
210        assert!(err.to_string().contains("memory limit"));
211    }
212
213    #[test]
214    fn wasm_tool_result_serde_roundtrip() {
215        let result = WasmToolResult {
216            stdout: "output".into(),
217            stderr: String::new(),
218            exit_code: 0,
219            fuel_consumed: 50_000,
220            memory_peak: 1024,
221            execution_time: Duration::from_millis(150),
222        };
223        let json = serde_json::to_string(&result).unwrap();
224        let restored: WasmToolResult = serde_json::from_str(&json).unwrap();
225        assert_eq!(restored.stdout, "output");
226        assert_eq!(restored.exit_code, 0);
227        assert_eq!(restored.fuel_consumed, 50_000);
228        assert_eq!(restored.execution_time, Duration::from_millis(150));
229    }
230
231    #[test]
232    fn wasm_validation_serde_roundtrip() {
233        let validation = WasmValidation {
234            valid: true,
235            exports: vec!["execute".into(), "tool_schema".into()],
236            imports: vec!["wasi_snapshot_preview1::fd_write".into()],
237            estimated_memory: 65536,
238            warnings: vec!["uses wasi".into()],
239        };
240        let json = serde_json::to_string(&validation).unwrap();
241        let restored: WasmValidation = serde_json::from_str(&json).unwrap();
242        assert!(restored.valid);
243        assert_eq!(restored.exports.len(), 2);
244        assert_eq!(restored.imports.len(), 1);
245    }
246
247    // --- Catalog tests ---
248
249    #[test]
250    fn builtin_catalog_has_expected_tools() {
251        let catalog = builtin_tool_catalog();
252        #[cfg(feature = "ecc")]
253        assert_eq!(catalog.len(), 36, "29 base + 7 ecc tools");
254        #[cfg(not(feature = "ecc"))]
255        assert_eq!(catalog.len(), 29);
256    }
257
258    #[test]
259    fn all_tools_have_valid_schema() {
260        let catalog = builtin_tool_catalog();
261        for spec in &catalog {
262            assert!(spec.parameters.is_object(), "{} has non-object schema", spec.name);
263            assert!(
264                spec.parameters.get("type").and_then(|v| v.as_str()) == Some("object"),
265                "{} schema type is not 'object'",
266                spec.name,
267            );
268        }
269    }
270
271    #[test]
272    fn all_tools_have_gate_action() {
273        let catalog = builtin_tool_catalog();
274        for spec in &catalog {
275            assert!(!spec.gate_action.is_empty(), "{} missing gate_action", spec.name);
276            assert!(
277                spec.gate_action.starts_with("tool.") || spec.gate_action.starts_with("ecc."),
278                "{} gate_action should start with 'tool.' or 'ecc.'",
279                spec.name,
280            );
281        }
282    }
283
284    #[test]
285    fn tool_names_are_unique() {
286        let catalog = builtin_tool_catalog();
287        let mut names: Vec<&str> = catalog.iter().map(|s| s.name.as_str()).collect();
288        names.sort();
289        let unique_count = {
290            let mut u = names.clone();
291            u.dedup();
292            u.len()
293        };
294        assert_eq!(names.len(), unique_count, "duplicate tool names found");
295    }
296
297    #[test]
298    fn tool_categories_correct() {
299        let catalog = builtin_tool_catalog();
300        let fs_count = catalog.iter().filter(|s| s.category == ToolCategory::Filesystem).count();
301        let agent_count = catalog.iter().filter(|s| s.category == ToolCategory::Agent).count();
302        let sys_count = catalog.iter().filter(|s| s.category == ToolCategory::System).count();
303        assert_eq!(fs_count, 10);
304        assert_eq!(agent_count, 9);
305        assert_eq!(sys_count, 10);
306        #[cfg(feature = "ecc")]
307        {
308            let ecc_count = catalog.iter().filter(|s| s.category == ToolCategory::Ecc).count();
309            assert_eq!(ecc_count, 7);
310        }
311    }
312
313    // --- FsReadFileTool tests ---
314
315    #[test]
316    fn fs_read_file_reads_content() {
317        let dir = std::env::temp_dir().join("clawft-fs-read-test");
318        let _ = std::fs::create_dir_all(&dir);
319        let file = dir.join("test.txt");
320        std::fs::write(&file, "hello world").unwrap();
321
322        let tool = FsReadFileTool::new();
323        let result = tool
324            .execute(serde_json::json!({"path": file.to_str().unwrap()}))
325            .unwrap();
326        assert_eq!(result["content"], "hello world");
327        assert_eq!(result["size"], 11);
328        assert!(result["modified"].as_str().is_some());
329
330        let _ = std::fs::remove_dir_all(&dir);
331    }
332
333    #[test]
334    fn fs_read_file_with_offset_limit() {
335        let dir = std::env::temp_dir().join("clawft-fs-offset-test");
336        let _ = std::fs::create_dir_all(&dir);
337        let file = dir.join("test.txt");
338        std::fs::write(&file, "0123456789").unwrap();
339
340        let tool = FsReadFileTool::new();
341        let result = tool
342            .execute(serde_json::json!({
343                "path": file.to_str().unwrap(),
344                "offset": 3,
345                "limit": 4,
346            }))
347            .unwrap();
348        assert_eq!(result["content"], "3456");
349
350        let _ = std::fs::remove_dir_all(&dir);
351    }
352
353    #[test]
354    fn fs_read_file_not_found() {
355        let tool = FsReadFileTool::new();
356        let result = tool.execute(serde_json::json!({"path": "/no/such/file/ever"}));
357        assert!(matches!(result, Err(ToolError::FileNotFound(_))));
358    }
359
360    #[test]
361    fn fs_read_file_returns_metadata() {
362        let dir = std::env::temp_dir().join("clawft-fs-meta-test");
363        let _ = std::fs::create_dir_all(&dir);
364        let file = dir.join("meta.txt");
365        std::fs::write(&file, "data").unwrap();
366
367        let tool = FsReadFileTool::new();
368        let result = tool
369            .execute(serde_json::json!({"path": file.to_str().unwrap()}))
370            .unwrap();
371        assert!(result.get("size").is_some());
372        assert!(result.get("modified").is_some());
373
374        let _ = std::fs::remove_dir_all(&dir);
375    }
376
377    // --- AgentSpawnTool tests ---
378
379    #[test]
380    fn agent_spawn_creates_process() {
381        let pt = Arc::new(crate::process::ProcessTable::new(64));
382        let tool = AgentSpawnTool::new(pt.clone());
383        let result = tool
384            .execute(serde_json::json!({"agent_id": "test-spawn"}))
385            .unwrap();
386
387        let pid = result["pid"].as_u64().unwrap();
388        assert!(pt.get(pid).is_some());
389        assert_eq!(result["state"], "running");
390    }
391
392    #[test]
393    fn agent_spawn_with_wasm_backend_fails() {
394        let pt = Arc::new(crate::process::ProcessTable::new(64));
395        let tool = AgentSpawnTool::new(pt);
396        let result = tool.execute(serde_json::json!({
397            "agent_id": "wasm-agent",
398            "backend": "wasm",
399        }));
400        assert!(matches!(result, Err(ToolError::ExecutionFailed(_))));
401    }
402
403    #[test]
404    fn agent_spawn_returns_pid() {
405        let pt = Arc::new(crate::process::ProcessTable::new(64));
406        let tool = AgentSpawnTool::new(pt);
407        let result = tool
408            .execute(serde_json::json!({"agent_id": "pid-test"}))
409            .unwrap();
410        assert!(result.get("pid").is_some());
411        assert_eq!(result["agent_id"], "pid-test");
412    }
413
414    // --- ToolRegistry tests ---
415
416    #[test]
417    fn registry_register_and_execute() {
418        let mut registry = ToolRegistry::new();
419        let tool = Arc::new(FsReadFileTool::new());
420        registry.register(tool);
421        assert_eq!(registry.len(), 1);
422        assert!(registry.get("fs.read_file").is_some());
423    }
424
425    #[test]
426    fn registry_not_found() {
427        let registry = ToolRegistry::new();
428        let result = registry.execute("no.such.tool", serde_json::json!({}));
429        assert!(matches!(result, Err(ToolError::NotFound(_))));
430    }
431
432    // --- Hierarchical ToolRegistry tests (K4 A1) ---
433
434    #[test]
435    fn registry_parent_chain_lookup() {
436        let mut parent = ToolRegistry::new();
437        parent.register(Arc::new(FsReadFileTool::new()));
438        let parent = Arc::new(parent);
439
440        let child = ToolRegistry::with_parent(parent);
441        assert!(child.get("fs.read_file").is_some(), "child should find tool in parent");
442    }
443
444    #[test]
445    fn registry_child_overrides_parent() {
446        let mut parent = ToolRegistry::new();
447        parent.register(Arc::new(FsReadFileTool::new()));
448        let parent = Arc::new(parent);
449
450        let mut child = ToolRegistry::with_parent(parent);
451        child.register(Arc::new(FsReadFileTool::new()));
452        assert_eq!(child.len(), 1, "deduplicated count should be 1");
453        assert!(child.get("fs.read_file").is_some());
454    }
455
456    #[test]
457    fn registry_list_merges_parent() {
458        let mut parent = ToolRegistry::new();
459        parent.register(Arc::new(FsReadFileTool::new()));
460        let parent = Arc::new(parent);
461
462        let pt = Arc::new(crate::process::ProcessTable::new(64));
463        let mut child = ToolRegistry::with_parent(parent);
464        child.register(Arc::new(AgentSpawnTool::new(pt)));
465
466        let list = child.list();
467        assert!(list.contains(&"fs.read_file".to_string()), "should include parent tool");
468        assert!(list.contains(&"agent.spawn".to_string()), "should include child tool");
469        assert_eq!(list.len(), 2);
470    }
471
472    #[test]
473    fn registry_empty_child_delegates_all() {
474        let mut parent = ToolRegistry::new();
475        parent.register(Arc::new(FsReadFileTool::new()));
476        let parent = Arc::new(parent);
477
478        let child = ToolRegistry::with_parent(parent);
479        assert_eq!(child.len(), 1);
480        assert!(!child.is_empty());
481
482        let dir = std::env::temp_dir().join("clawft-delegate-test");
483        let _ = std::fs::create_dir_all(&dir);
484        let file = dir.join("test.txt");
485        std::fs::write(&file, "delegate").unwrap();
486        let result = child.execute("fs.read_file", serde_json::json!({"path": file.to_str().unwrap()}));
487        assert!(result.is_ok());
488        assert_eq!(result.unwrap()["content"], "delegate");
489        let _ = std::fs::remove_dir_all(&dir);
490    }
491
492    // --- Sandbox tests (K4 B1) ---
493
494    #[test]
495    fn sandbox_denies_path_outside_allowed() {
496        let sandbox = SandboxConfig {
497            allowed_paths: vec![std::env::temp_dir()],
498            ..Default::default()
499        };
500        let tool = FsReadFileTool::with_sandbox(sandbox);
501        let result = tool.execute(serde_json::json!({"path": "/etc/passwd"}));
502        assert!(matches!(result, Err(ToolError::PermissionDenied(_))));
503    }
504
505    #[test]
506    fn sandbox_allows_path_inside_allowed() {
507        let dir = std::env::temp_dir().join("clawft-sandbox-allow-test");
508        let _ = std::fs::create_dir_all(&dir);
509        let file = dir.join("allowed.txt");
510        std::fs::write(&file, "allowed").unwrap();
511
512        let sandbox = SandboxConfig {
513            allowed_paths: vec![std::env::temp_dir()],
514            ..Default::default()
515        };
516        let tool = FsReadFileTool::with_sandbox(sandbox);
517        let result = tool.execute(serde_json::json!({"path": file.to_str().unwrap()}));
518        assert!(result.is_ok());
519        assert_eq!(result.unwrap()["content"], "allowed");
520
521        let _ = std::fs::remove_dir_all(&dir);
522    }
523
524    #[test]
525    fn sandbox_default_allows_all() {
526        let dir = std::env::temp_dir().join("clawft-sandbox-default-test");
527        let _ = std::fs::create_dir_all(&dir);
528        let file = dir.join("default.txt");
529        std::fs::write(&file, "default").unwrap();
530
531        let tool = FsReadFileTool::new();
532        let result = tool.execute(serde_json::json!({"path": file.to_str().unwrap()}));
533        assert!(result.is_ok());
534
535        let _ = std::fs::remove_dir_all(&dir);
536    }
537
538    // --- K4 C1: Filesystem tool tests ---
539
540    #[test]
541    fn fs_write_file_creates_and_writes() {
542        let dir = std::env::temp_dir().join("clawft-fs-write-test");
543        let _ = std::fs::create_dir_all(&dir);
544        let file = dir.join("out.txt");
545        let tool = FsWriteFileTool::new();
546        let result = tool.execute(serde_json::json!({"path": file.to_str().unwrap(), "content": "hello"}));
547        assert!(result.is_ok());
548        assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello");
549        let _ = std::fs::remove_dir_all(&dir);
550    }
551
552    #[test]
553    fn fs_write_file_missing_content() {
554        let tool = FsWriteFileTool::new();
555        let result = tool.execute(serde_json::json!({"path": "/tmp/x"}));
556        assert!(matches!(result, Err(ToolError::InvalidArgs(_))));
557    }
558
559    #[test]
560    fn fs_read_dir_lists_entries() {
561        let dir = std::env::temp_dir().join("clawft-fs-readdir-test");
562        let _ = std::fs::create_dir_all(&dir);
563        std::fs::write(dir.join("a.txt"), "a").unwrap();
564        std::fs::write(dir.join("b.txt"), "b").unwrap();
565        let tool = FsReadDirTool::new();
566        let result = tool.execute(serde_json::json!({"path": dir.to_str().unwrap()})).unwrap();
567        assert!(result["count"].as_u64().unwrap() >= 2);
568        let _ = std::fs::remove_dir_all(&dir);
569    }
570
571    #[test]
572    fn fs_read_dir_not_found() {
573        let tool = FsReadDirTool::new();
574        let result = tool.execute(serde_json::json!({"path": "/no/such/dir/ever"}));
575        assert!(matches!(result, Err(ToolError::FileNotFound(_))));
576    }
577
578    #[test]
579    fn fs_create_dir_recursive() {
580        let dir = std::env::temp_dir().join("clawft-fs-mkdir-test/a/b/c");
581        let tool = FsCreateDirTool::new();
582        let result = tool.execute(serde_json::json!({"path": dir.to_str().unwrap()}));
583        assert!(result.is_ok());
584        assert!(dir.exists());
585        let _ = std::fs::remove_dir_all(std::env::temp_dir().join("clawft-fs-mkdir-test"));
586    }
587
588    #[test]
589    fn fs_create_dir_sandbox_denied() {
590        let tool = FsCreateDirTool::new();
591        let dir = std::env::temp_dir().join("clawft-fs-mkdir-sandbox-test");
592        let result = tool.execute(serde_json::json!({"path": dir.to_str().unwrap()}));
593        assert!(result.is_ok());
594        let _ = std::fs::remove_dir_all(&dir);
595    }
596
597    #[test]
598    fn fs_remove_file() {
599        let dir = std::env::temp_dir().join("clawft-fs-rm-test");
600        let _ = std::fs::create_dir_all(&dir);
601        let file = dir.join("delete_me.txt");
602        std::fs::write(&file, "bye").unwrap();
603        let tool = FsRemoveTool::new();
604        let result = tool.execute(serde_json::json!({"path": file.to_str().unwrap()}));
605        assert!(result.is_ok());
606        assert!(!file.exists());
607        let _ = std::fs::remove_dir_all(&dir);
608    }
609
610    #[test]
611    fn fs_remove_not_found() {
612        let tool = FsRemoveTool::new();
613        let result = tool.execute(serde_json::json!({"path": "/no/such/file/xyz"}));
614        assert!(matches!(result, Err(ToolError::FileNotFound(_))));
615    }
616
617    #[test]
618    fn fs_copy_file() {
619        let dir = std::env::temp_dir().join("clawft-fs-copy-test");
620        let _ = std::fs::create_dir_all(&dir);
621        let src = dir.join("src.txt");
622        let dst = dir.join("dst.txt");
623        std::fs::write(&src, "copy me").unwrap();
624        let tool = FsCopyTool::new();
625        let result = tool.execute(serde_json::json!({"src": src.to_str().unwrap(), "dst": dst.to_str().unwrap()}));
626        assert!(result.is_ok());
627        assert_eq!(std::fs::read_to_string(&dst).unwrap(), "copy me");
628        let _ = std::fs::remove_dir_all(&dir);
629    }
630
631    #[test]
632    fn fs_copy_not_found() {
633        let tool = FsCopyTool::new();
634        let result = tool.execute(serde_json::json!({"src": "/no/file", "dst": "/tmp/out"}));
635        assert!(matches!(result, Err(ToolError::FileNotFound(_))));
636    }
637
638    #[test]
639    fn fs_move_file() {
640        let dir = std::env::temp_dir().join("clawft-fs-move-test");
641        let _ = std::fs::create_dir_all(&dir);
642        let src = dir.join("old.txt");
643        let dst = dir.join("new.txt");
644        std::fs::write(&src, "move me").unwrap();
645        let tool = FsMoveTool::new();
646        let result = tool.execute(serde_json::json!({"src": src.to_str().unwrap(), "dst": dst.to_str().unwrap()}));
647        assert!(result.is_ok());
648        assert!(!src.exists());
649        assert_eq!(std::fs::read_to_string(&dst).unwrap(), "move me");
650        let _ = std::fs::remove_dir_all(&dir);
651    }
652
653    #[test]
654    fn fs_move_not_found() {
655        let tool = FsMoveTool::new();
656        let result = tool.execute(serde_json::json!({"src": "/no/file", "dst": "/tmp/out"}));
657        assert!(matches!(result, Err(ToolError::FileNotFound(_))));
658    }
659
660    #[test]
661    fn fs_stat_returns_metadata() {
662        let dir = std::env::temp_dir().join("clawft-fs-stat-test");
663        let _ = std::fs::create_dir_all(&dir);
664        let file = dir.join("stat.txt");
665        std::fs::write(&file, "data").unwrap();
666        let tool = FsStatTool::new();
667        let result = tool.execute(serde_json::json!({"path": file.to_str().unwrap()})).unwrap();
668        assert_eq!(result["size"], 4);
669        assert_eq!(result["is_file"], true);
670        assert_eq!(result["is_dir"], false);
671        let _ = std::fs::remove_dir_all(&dir);
672    }
673
674    #[test]
675    fn fs_stat_error() {
676        let tool = FsStatTool::new();
677        let result = tool.execute(serde_json::json!({"path": "/no/such/file"}));
678        assert!(matches!(result, Err(ToolError::ExecutionFailed(_))));
679    }
680
681    #[test]
682    fn fs_exists_checks() {
683        let tool = FsExistsTool::new();
684        let result = tool.execute(serde_json::json!({"path": "/tmp"})).unwrap();
685        assert_eq!(result["exists"], true);
686        assert_eq!(result["is_dir"], true);
687
688        let result = tool.execute(serde_json::json!({"path": "/no/such/path/xyz"})).unwrap();
689        assert_eq!(result["exists"], false);
690    }
691
692    #[test]
693    fn fs_glob_finds_files() {
694        let dir = std::env::temp_dir().join("clawft-fs-glob-test");
695        let _ = std::fs::create_dir_all(&dir);
696        std::fs::write(dir.join("test.rs"), "fn main() {}").unwrap();
697        std::fs::write(dir.join("test.txt"), "text").unwrap();
698        let tool = FsGlobTool::new();
699        let result = tool.execute(serde_json::json!({
700            "pattern": "*.rs",
701            "base_dir": dir.to_str().unwrap(),
702        })).unwrap();
703        assert!(result["count"].as_u64().unwrap() >= 1);
704        let _ = std::fs::remove_dir_all(&dir);
705    }
706
707    #[test]
708    fn fs_glob_no_match() {
709        let dir = std::env::temp_dir().join("clawft-fs-glob-nomatch-test");
710        let _ = std::fs::create_dir_all(&dir);
711        let tool = FsGlobTool::new();
712        let result = tool.execute(serde_json::json!({
713            "pattern": "*.xyz",
714            "base_dir": dir.to_str().unwrap(),
715        })).unwrap();
716        assert_eq!(result["count"], 0);
717        let _ = std::fs::remove_dir_all(&dir);
718    }
719
720    // --- K4 C2: Agent tool tests ---
721
722    #[test]
723    fn agent_stop_cancels_token() {
724        let pt = Arc::new(crate::process::ProcessTable::new(64));
725        let spawn = AgentSpawnTool::new(pt.clone());
726        let result = spawn.execute(serde_json::json!({"agent_id": "stop-test"})).unwrap();
727        let pid = result["pid"].as_u64().unwrap();
728
729        let tool = AgentStopTool::new(pt.clone());
730        let result = tool.execute(serde_json::json!({"pid": pid}));
731        assert!(result.is_ok());
732        let entry = pt.get(pid).unwrap();
733        assert!(entry.cancel_token.is_cancelled());
734    }
735
736    #[test]
737    fn agent_stop_not_found() {
738        let pt = Arc::new(crate::process::ProcessTable::new(64));
739        let tool = AgentStopTool::new(pt);
740        let result = tool.execute(serde_json::json!({"pid": 9999}));
741        assert!(matches!(result, Err(ToolError::NotFound(_))));
742    }
743
744    #[test]
745    fn agent_list_shows_agents() {
746        let pt = Arc::new(crate::process::ProcessTable::new(64));
747        let spawn = AgentSpawnTool::new(pt.clone());
748        spawn.execute(serde_json::json!({"agent_id": "list-a"})).unwrap();
749        spawn.execute(serde_json::json!({"agent_id": "list-b"})).unwrap();
750
751        let tool = AgentListTool::new(pt);
752        let result = tool.execute(serde_json::json!({})).unwrap();
753        assert!(result["count"].as_u64().unwrap() >= 2);
754    }
755
756    #[test]
757    fn agent_inspect_returns_details() {
758        let pt = Arc::new(crate::process::ProcessTable::new(64));
759        let spawn = AgentSpawnTool::new(pt.clone());
760        let r = spawn.execute(serde_json::json!({"agent_id": "inspect-me"})).unwrap();
761        let pid = r["pid"].as_u64().unwrap();
762
763        let tool = AgentInspectTool::new(pt);
764        let result = tool.execute(serde_json::json!({"pid": pid})).unwrap();
765        assert_eq!(result["agent_id"], "inspect-me");
766        assert!(result["capabilities"].is_object());
767    }
768
769    #[test]
770    fn agent_inspect_not_found() {
771        let pt = Arc::new(crate::process::ProcessTable::new(64));
772        let tool = AgentInspectTool::new(pt);
773        let result = tool.execute(serde_json::json!({"pid": 9999}));
774        assert!(matches!(result, Err(ToolError::NotFound(_))));
775    }
776
777    #[test]
778    fn agent_suspend_resume_cycle() {
779        let pt = Arc::new(crate::process::ProcessTable::new(64));
780        let spawn = AgentSpawnTool::new(pt.clone());
781        let r = spawn.execute(serde_json::json!({"agent_id": "sr-test"})).unwrap();
782        let pid = r["pid"].as_u64().unwrap();
783
784        let suspend = AgentSuspendTool::new(pt.clone());
785        suspend.execute(serde_json::json!({"pid": pid})).unwrap();
786        assert_eq!(pt.get(pid).unwrap().state, crate::process::ProcessState::Suspended);
787
788        let resume = AgentResumeTool::new(pt.clone());
789        resume.execute(serde_json::json!({"pid": pid})).unwrap();
790        assert_eq!(pt.get(pid).unwrap().state, crate::process::ProcessState::Running);
791    }
792
793    // --- K4 C3: System tool tests ---
794
795    #[test]
796    fn sys_env_get_returns_value() {
797        unsafe { std::env::set_var("CLAWFT_TEST_VAR", "test_value"); }
798        let tool = SysEnvGetTool::new();
799        let result = tool.execute(serde_json::json!({"name": "CLAWFT_TEST_VAR"})).unwrap();
800        assert_eq!(result["value"], "test_value");
801        unsafe { std::env::remove_var("CLAWFT_TEST_VAR"); }
802    }
803
804    #[test]
805    fn sys_env_get_missing_returns_null() {
806        let tool = SysEnvGetTool::new();
807        let result = tool.execute(serde_json::json!({"name": "NO_SUCH_VAR_EVER_XYZ"})).unwrap();
808        assert!(result["value"].is_null());
809    }
810
811    #[test]
812    fn sys_cron_add_list_remove() {
813        let cron = Arc::new(crate::cron::CronService::new());
814        let add = SysCronAddTool::new(cron.clone());
815        let result = add.execute(serde_json::json!({
816            "name": "test-job",
817            "interval_secs": 60,
818            "command": "ping",
819        })).unwrap();
820        let job_id = result["id"].as_str().unwrap().to_string();
821
822        let list = SysCronListTool::new(cron.clone());
823        let jobs = list.execute(serde_json::json!({})).unwrap();
824        assert!(jobs.as_array().map(|a| !a.is_empty()).unwrap_or(false));
825
826        let rm = SysCronRemoveTool::new(cron);
827        let result = rm.execute(serde_json::json!({"id": job_id}));
828        assert!(result.is_ok());
829    }
830
831    #[test]
832    fn sys_cron_remove_not_found() {
833        let cron = Arc::new(crate::cron::CronService::new());
834        let tool = SysCronRemoveTool::new(cron);
835        let result = tool.execute(serde_json::json!({"id": "no-such-job"}));
836        assert!(matches!(result, Err(ToolError::NotFound(_))));
837    }
838
839    #[test]
840    fn simple_glob_match_works() {
841        assert!(tools_fs::simple_glob_match("*.rs", "main.rs"));
842        assert!(tools_fs::simple_glob_match("*.rs", "lib.rs"));
843        assert!(!tools_fs::simple_glob_match("*.rs", "main.txt"));
844        assert!(tools_fs::simple_glob_match("test?", "test1"));
845        assert!(!tools_fs::simple_glob_match("test?", "test12"));
846        assert!(tools_fs::simple_glob_match("*", "anything"));
847    }
848
849    // --- K4 D2: Module cache tests ---
850
851    #[test]
852    fn cache_roundtrip() {
853        let dir = std::env::temp_dir().join("clawft-cache-rt-test");
854        let _ = std::fs::remove_dir_all(&dir);
855        let cache = CompiledModuleCache::new(dir.clone(), 1024 * 1024);
856        let hash = [0xAAu8; 32];
857        let data = b"compiled wasm bytes";
858        cache.put(&hash, data);
859        let got = cache.get(&hash).unwrap();
860        assert_eq!(got, data);
861        let _ = std::fs::remove_dir_all(&dir);
862    }
863
864    #[test]
865    fn cache_miss_returns_none() {
866        let dir = std::env::temp_dir().join("clawft-cache-miss-test");
867        let _ = std::fs::remove_dir_all(&dir);
868        let cache = CompiledModuleCache::new(dir.clone(), 1024 * 1024);
869        assert!(cache.get(&[0xBBu8; 32]).is_none());
870        let _ = std::fs::remove_dir_all(&dir);
871    }
872
873    #[test]
874    fn cache_eviction() {
875        let dir = std::env::temp_dir().join("clawft-cache-evict-test");
876        let _ = std::fs::remove_dir_all(&dir);
877        let cache = CompiledModuleCache::new(dir.clone(), 100);
878        for i in 0..10u8 {
879            let mut hash = [0u8; 32];
880            hash[0] = i;
881            cache.put(&hash, &[i; 20]);
882        }
883        let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
884        assert!(entries.len() < 10, "eviction should have removed some entries");
885        let _ = std::fs::remove_dir_all(&dir);
886    }
887
888    // --- K4 D3: WASI scope tests ---
889
890    #[test]
891    fn wasi_scope_default_is_none() {
892        let scope = WasiFsScope::default();
893        assert_eq!(scope, WasiFsScope::None);
894    }
895
896    #[test]
897    fn wasi_scope_serde_roundtrip() {
898        let scope = WasiFsScope::ReadOnly(PathBuf::from("/data"));
899        let json = serde_json::to_string(&scope).unwrap();
900        let restored: WasiFsScope = serde_json::from_str(&json).unwrap();
901        assert_eq!(restored, scope);
902    }
903
904    // --- K4 F1: Signing tests ---
905
906    #[test]
907    #[cfg(feature = "exochain")]
908    fn verify_kernel_signature() {
909        let key = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
910        let hash = [0xAAu8; 32];
911        use ed25519_dalek::Signer;
912        let sig = key.sign(&hash);
913        let pubkey = key.verifying_key().to_bytes();
914        assert!(verify_tool_signature(&hash, &sig.to_bytes(), &pubkey));
915    }
916
917    #[test]
918    #[cfg(feature = "exochain")]
919    fn verify_tampered_cert_fails() {
920        let key = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
921        let hash = [0xAAu8; 32];
922        use ed25519_dalek::Signer;
923        let sig = key.sign(&hash);
924        let mut bad_sig = sig.to_bytes();
925        bad_sig[0] ^= 0xFF;
926        let pubkey = key.verifying_key().to_bytes();
927        assert!(!verify_tool_signature(&hash, &bad_sig, &pubkey));
928    }
929
930    #[test]
931    fn signing_authority_serde() {
932        let auth = ToolSigningAuthority::Kernel;
933        let json = serde_json::to_string(&auth).unwrap();
934        let _: ToolSigningAuthority = serde_json::from_str(&json).unwrap();
935    }
936
937    #[test]
938    #[cfg(feature = "exochain")]
939    fn signing_verify_roundtrip() {
940        use ed25519_dalek::{SigningKey, Signer};
941        let mut rng = rand::rngs::OsRng;
942        let sk = SigningKey::generate(&mut rng);
943        let pk_bytes: [u8; 32] = sk.verifying_key().to_bytes();
944        let hash: [u8; 32] = [42u8; 32];
945        let sig = sk.sign(&hash);
946        let sig_bytes: [u8; 64] = sig.to_bytes();
947        assert!(verify_tool_signature(&hash, &sig_bytes, &pk_bytes));
948    }
949
950    #[test]
951    #[cfg(feature = "exochain")]
952    fn signing_tampered_fails() {
953        use ed25519_dalek::{SigningKey, Signer};
954        let mut rng = rand::rngs::OsRng;
955        let sk = SigningKey::generate(&mut rng);
956        let pk_bytes: [u8; 32] = sk.verifying_key().to_bytes();
957        let hash: [u8; 32] = [42u8; 32];
958        let sig = sk.sign(&hash);
959        let mut sig_bytes: [u8; 64] = sig.to_bytes();
960        sig_bytes[0] ^= 0xff;
961        assert!(!verify_tool_signature(&hash, &sig_bytes, &pk_bytes));
962    }
963
964    // --- K4 F2: Backend selection tests ---
965
966    #[test]
967    fn backend_low_risk_native() {
968        assert_eq!(BackendSelection::from_risk(0.1), BackendSelection::Native);
969        assert_eq!(BackendSelection::from_risk(0.3), BackendSelection::Native);
970    }
971
972    #[test]
973    fn backend_high_risk_wasm() {
974        assert_eq!(BackendSelection::from_risk(0.5), BackendSelection::Wasm);
975        assert_eq!(BackendSelection::from_risk(0.7), BackendSelection::Wasm);
976    }
977
978    // --- Module hash tests ---
979
980    #[test]
981    fn module_hash_deterministic() {
982        let data = b"test module bytes";
983        let h1 = compute_module_hash(data);
984        let h2 = compute_module_hash(data);
985        assert_eq!(h1, h2);
986    }
987
988    #[test]
989    fn module_hash_differs_for_different_input() {
990        let h1 = compute_module_hash(b"module A");
991        let h2 = compute_module_hash(b"module B");
992        assert_ne!(h1, h2);
993    }
994
995    // --- ToolVersion/DeployedTool serde ---
996
997    #[test]
998    fn tool_version_serde_roundtrip() {
999        use chrono::Utc;
1000        let tv = ToolVersion {
1001            version: 1,
1002            module_hash: [0xAA; 32],
1003            signature: [0xBB; 64],
1004            deployed_at: Utc::now(),
1005            revoked: false,
1006            chain_seq: 42,
1007        };
1008        let json = serde_json::to_string(&tv).unwrap();
1009        let restored: ToolVersion = serde_json::from_str(&json).unwrap();
1010        assert_eq!(restored.version, 1);
1011        assert_eq!(restored.chain_seq, 42);
1012        assert!(!restored.revoked);
1013    }
1014
1015    #[test]
1016    fn builtin_tool_spec_serde_roundtrip() {
1017        use crate::governance::EffectVector;
1018        let spec = BuiltinToolSpec {
1019            name: "test.tool".into(),
1020            category: ToolCategory::User,
1021            description: "A test tool".into(),
1022            parameters: serde_json::json!({"type": "object", "properties": {}}),
1023            gate_action: "tool.test".into(),
1024            effect: EffectVector::default(),
1025            native: true,
1026        };
1027        let json = serde_json::to_string(&spec).unwrap();
1028        let restored: BuiltinToolSpec = serde_json::from_str(&json).unwrap();
1029        assert_eq!(restored.name, "test.tool");
1030        assert_eq!(restored.category, ToolCategory::User);
1031    }
1032
1033    // --- K3: WASM execute_bytes tests ---
1034
1035    #[cfg(feature = "wasm-sandbox")]
1036    const NOOP_WAT: &str = r#"(module
1037        (memory (export "memory") 1)
1038        (func (export "_start"))
1039    )"#;
1040
1041    #[cfg(feature = "wasm-sandbox")]
1042    #[tokio::test]
1043    async fn execute_bytes_noop_module() {
1044        let runner = WasmToolRunner::new(WasmSandboxConfig::default());
1045        let result = runner
1046            .execute_bytes("noop", NOOP_WAT.as_bytes(), serde_json::json!({}))
1047            .await
1048            .unwrap();
1049        assert_eq!(result.exit_code, 0);
1050        assert!(result.fuel_consumed > 0);
1051    }
1052
1053    #[cfg(feature = "wasm-sandbox")]
1054    #[tokio::test]
1055    async fn execute_bytes_captures_fuel() {
1056        let config = WasmSandboxConfig {
1057            max_fuel: 10_000_000,
1058            ..Default::default()
1059        };
1060        let runner = WasmToolRunner::new(config);
1061        let result = runner
1062            .execute_bytes("fuel-test", NOOP_WAT.as_bytes(), serde_json::json!({}))
1063            .await
1064            .unwrap();
1065        assert_eq!(result.exit_code, 0);
1066        assert!(result.fuel_consumed > 0);
1067        assert!(result.fuel_consumed < 10_000_000);
1068    }
1069
1070    #[cfg(feature = "wasm-sandbox")]
1071    #[tokio::test]
1072    async fn execute_bytes_invalid_module() {
1073        let runner = WasmToolRunner::new(WasmSandboxConfig::default());
1074        let result = runner
1075            .execute_bytes("bad", b"not wasm at all", serde_json::json!({}))
1076            .await;
1077        assert!(matches!(result, Err(WasmError::CompilationFailed(_))));
1078    }
1079
1080    #[cfg(feature = "wasm-sandbox")]
1081    #[tokio::test]
1082    async fn execute_bytes_fuel_exhaustion() {
1083        let config = WasmSandboxConfig {
1084            max_fuel: 1,
1085            ..Default::default()
1086        };
1087        let runner = WasmToolRunner::new(config);
1088        let loop_wat = r#"(module
1089            (memory (export "memory") 1)
1090            (func (export "_start")
1091                (local $i i32)
1092                (block $break
1093                    (loop $loop
1094                        (br_if $break (i32.ge_u (local.get $i) (i32.const 1000)))
1095                        (local.set $i (i32.add (local.get $i) (i32.const 1)))
1096                        (br $loop)
1097                    )
1098                )
1099            )
1100        )"#;
1101        let result = runner
1102            .execute_bytes("loop", loop_wat.as_bytes(), serde_json::json!({}))
1103            .await;
1104        assert!(
1105            matches!(result, Err(WasmError::FuelExhausted { .. })),
1106            "expected FuelExhausted, got: {result:?}",
1107        );
1108    }
1109
1110    #[cfg(feature = "wasm-sandbox")]
1111    #[tokio::test]
1112    async fn execute_bytes_no_export_returns_error() {
1113        let no_export_wat = r#"(module
1114            (memory (export "memory") 1)
1115            (func $helper (nop))
1116        )"#;
1117        let runner = WasmToolRunner::new(WasmSandboxConfig::default());
1118        let result = runner
1119            .execute_bytes("noexport", no_export_wat.as_bytes(), serde_json::json!({}))
1120            .await
1121            .unwrap();
1122        assert_eq!(result.exit_code, 1);
1123        assert!(result.stderr.contains("no _start or execute export"));
1124    }
1125
1126    // --- K3: WasmToolAdapter / register_wasm_tool tests ---
1127
1128    #[cfg(feature = "wasm-sandbox")]
1129    #[test]
1130    fn register_wasm_tool_and_dispatch() {
1131        let runner = Arc::new(WasmToolRunner::new(WasmSandboxConfig::default()));
1132        let mut registry = ToolRegistry::new();
1133        registry
1134            .register_wasm_tool(
1135                "wasm.noop",
1136                "A noop WASM tool",
1137                NOOP_WAT.as_bytes().to_vec(),
1138                runner,
1139            )
1140            .expect("registration should succeed");
1141        assert!(registry.get("wasm.noop").is_some());
1142        let spec = registry.get("wasm.noop").unwrap().spec();
1143        assert!(!spec.native);
1144        assert_eq!(spec.gate_action, "tool.wasm.wasm.noop");
1145    }
1146
1147    #[cfg(feature = "wasm-sandbox")]
1148    #[test]
1149    fn register_wasm_tool_invalid_bytes_rejected() {
1150        let runner = Arc::new(WasmToolRunner::new(WasmSandboxConfig::default()));
1151        let mut registry = ToolRegistry::new();
1152        let result = registry.register_wasm_tool(
1153            "wasm.bad",
1154            "Invalid",
1155            b"not wasm".to_vec(),
1156            runner,
1157        );
1158        assert!(result.is_err());
1159    }
1160
1161    #[cfg(feature = "wasm-sandbox")]
1162    #[test]
1163    fn wasm_adapter_execute_runs_module() {
1164        let runner = Arc::new(WasmToolRunner::new(WasmSandboxConfig::default()));
1165        let mut registry = ToolRegistry::new();
1166        registry
1167            .register_wasm_tool(
1168                "wasm.noop",
1169                "noop",
1170                NOOP_WAT.as_bytes().to_vec(),
1171                runner,
1172            )
1173            .unwrap();
1174        let result = registry.execute("wasm.noop", serde_json::json!({}));
1175        assert!(result.is_ok(), "execute should succeed: {:?}", result.err());
1176        let val = result.unwrap();
1177        assert_eq!(val["exit_code"], 0);
1178        assert!(val["fuel_consumed"].as_u64().unwrap() > 0);
1179    }
1180
1181    #[cfg(feature = "wasm-sandbox")]
1182    #[test]
1183    fn wasm_adapter_listed_in_registry() {
1184        let runner = Arc::new(WasmToolRunner::new(WasmSandboxConfig::default()));
1185        let mut registry = ToolRegistry::new();
1186        registry.register(Arc::new(FsExistsTool::new()));
1187        registry
1188            .register_wasm_tool(
1189                "wasm.noop",
1190                "noop",
1191                NOOP_WAT.as_bytes().to_vec(),
1192                runner,
1193            )
1194            .unwrap();
1195        let list = registry.list();
1196        assert!(list.contains(&"fs.exists".to_string()));
1197        assert!(list.contains(&"wasm.noop".to_string()));
1198        assert_eq!(list.len(), 2);
1199    }
1200
1201    // --- K3 gate: sync execute_sync tests ---
1202
1203    #[cfg(feature = "wasm-sandbox")]
1204    #[test]
1205    fn k3_wasm_tool_loads_and_executes() {
1206        let runner = WasmToolRunner::new(WasmSandboxConfig::default());
1207        let result = runner
1208            .execute_sync("noop", NOOP_WAT.as_bytes(), serde_json::json!({}))
1209            .expect("execute_sync should succeed for noop WAT module");
1210        assert_eq!(result.exit_code, 0);
1211        assert!(result.fuel_consumed > 0, "fuel_consumed should be non-zero");
1212    }
1213
1214    #[cfg(feature = "wasm-sandbox")]
1215    #[test]
1216    fn k3_fuel_exhaustion_terminates_cleanly() {
1217        let config = WasmSandboxConfig {
1218            max_fuel: 1,
1219            ..Default::default()
1220        };
1221        let runner = WasmToolRunner::new(config);
1222
1223        let loop_wat = r#"(module
1224            (func (export "_start")
1225                (local $i i32)
1226                (block $break
1227                    (loop $loop
1228                        (br_if $break (i32.ge_u (local.get $i) (i32.const 1000)))
1229                        (local.set $i (i32.add (local.get $i) (i32.const 1)))
1230                        (br $loop)
1231                    )
1232                )
1233            )
1234        )"#;
1235
1236        let result = runner.execute_sync("loop", loop_wat.as_bytes(), serde_json::json!({}));
1237        assert!(
1238            matches!(result, Err(WasmError::FuelExhausted { .. })),
1239            "expected FuelExhausted, got: {result:?}",
1240        );
1241    }
1242
1243    #[cfg(feature = "wasm-sandbox")]
1244    #[test]
1245    fn k3_memory_limit_prevents_allocation_bomb() {
1246        let config = WasmSandboxConfig {
1247            max_memory_bytes: 64 * 1024,
1248            max_fuel: 1_000_000,
1249            ..Default::default()
1250        };
1251        let runner = WasmToolRunner::new(config);
1252
1253        let big_mem_wat = r#"(module
1254            (memory 32)
1255            (func (export "_start") (nop))
1256        )"#;
1257
1258        let result = runner.execute_sync(
1259            "alloc-bomb",
1260            big_mem_wat.as_bytes(),
1261            serde_json::json!({}),
1262        );
1263        assert!(
1264            result.is_err(),
1265            "module requesting 2 MiB with 64 KiB cap should fail, got: {result:?}",
1266        );
1267    }
1268
1269    #[test]
1270    fn k3_host_filesystem_not_accessible_from_sandbox() {
1271        let config = WasmSandboxConfig::default();
1272        assert!(!config.wasi_enabled, "WASI should be disabled by default");
1273        assert!(
1274            config.allowed_host_calls.is_empty(),
1275            "no host calls should be allowed by default",
1276        );
1277
1278        let scope = WasiFsScope::default();
1279        assert_eq!(scope, WasiFsScope::None, "default fs scope should be None");
1280
1281        #[cfg(feature = "wasm-sandbox")]
1282        {
1283            let runner = WasmToolRunner::new(config);
1284
1285            let wasi_import_wat = r#"(module
1286                (import "wasi_snapshot_preview1" "fd_write"
1287                    (func $fd_write (param i32 i32 i32 i32) (result i32)))
1288                (func (export "_start") (nop))
1289            )"#;
1290
1291            let result = runner.execute_sync(
1292                "fs-probe",
1293                wasi_import_wat.as_bytes(),
1294                serde_json::json!({}),
1295            );
1296            assert!(
1297                result.is_err(),
1298                "module importing WASI fd_write should fail in sandboxed execute_sync: {result:?}",
1299            );
1300        }
1301    }
1302
1303    // --- C5: ShellPipeline tests ---
1304
1305    #[test]
1306    fn shell_pipeline_creates_hash() {
1307        let pipeline =
1308            ShellPipeline::new("deploy", "cargo build --release && scp target/release/weft server:");
1309        assert_ne!(pipeline.content_hash, [0u8; 32]);
1310    }
1311
1312    #[test]
1313    fn shell_pipeline_deterministic_hash() {
1314        let p1 = ShellPipeline::new("test", "echo hello");
1315        let p2 = ShellPipeline::new("test", "echo hello");
1316        assert_eq!(p1.content_hash, p2.content_hash);
1317    }
1318
1319    #[test]
1320    fn shell_pipeline_different_commands_different_hash() {
1321        let p1 = ShellPipeline::new("a", "echo hello");
1322        let p2 = ShellPipeline::new("a", "echo world");
1323        assert_ne!(p1.content_hash, p2.content_hash);
1324    }
1325
1326    #[test]
1327    fn shell_pipeline_to_tool_spec() {
1328        let pipeline = ShellPipeline::new("build", "cargo build");
1329        let spec = pipeline.to_tool_spec();
1330        assert_eq!(spec.name, "shell.build");
1331        assert_eq!(spec.category, ToolCategory::User);
1332        assert!(spec.native);
1333        assert_eq!(spec.gate_action, "tool.shell.execute");
1334    }
1335
1336    #[test]
1337    fn shell_pipeline_initial_chain_seq_none() {
1338        let pipeline = ShellPipeline::new("test", "ls -la");
1339        assert!(pipeline.chain_seq.is_none());
1340    }
1341
1342    #[test]
1343    #[cfg(feature = "exochain")]
1344    fn shell_pipeline_anchor_to_chain() {
1345        let chain = crate::chain::ChainManager::new(0, 1000);
1346        let mut pipeline = ShellPipeline::new("deploy", "make deploy");
1347        assert!(pipeline.chain_seq.is_none());
1348
1349        pipeline.anchor_to_chain(&chain);
1350        assert!(pipeline.chain_seq.is_some());
1351
1352        let events = chain.tail(1);
1353        assert_eq!(events[0].kind, "shell.pipeline.register");
1354    }
1355
1356    #[test]
1357    fn shell_pipeline_serde_roundtrip() {
1358        let pipeline = ShellPipeline::new("test-serde", "echo roundtrip");
1359        let json = serde_json::to_string(&pipeline).unwrap();
1360        let restored: ShellPipeline = serde_json::from_str(&json).unwrap();
1361        assert_eq!(restored.name, "test-serde");
1362        assert_eq!(restored.command, "echo roundtrip");
1363        assert_eq!(restored.content_hash, pipeline.content_hash);
1364    }
1365
1366    // --- D9: Tool Signing tests ---
1367
1368    #[test]
1369    fn d9_register_unsigned_when_not_required_succeeds() {
1370        let mut registry = ToolRegistry::new();
1371        let tool: Arc<dyn BuiltinTool> = Arc::new(FsReadFileTool::new());
1372        registry.register(tool);
1373        assert!(registry.get("fs.read_file").is_some());
1374    }
1375
1376    #[test]
1377    fn d9_register_unsigned_when_required_fails() {
1378        let mut registry = ToolRegistry::new();
1379        registry.set_require_signatures(true);
1380        assert!(registry.requires_signatures());
1381
1382        let tool: Arc<dyn BuiltinTool> = Arc::new(FsReadFileTool::new());
1383        let result = registry.try_register(tool);
1384        assert!(
1385            matches!(result, Err(ToolError::SignatureRequired(_))),
1386            "expected SignatureRequired, got: {result:?}",
1387        );
1388        assert!(registry.get("fs.read_file").is_none());
1389    }
1390
1391    #[test]
1392    #[cfg(feature = "exochain")]
1393    fn d9_register_with_valid_signature_succeeds() {
1394        use ed25519_dalek::{SigningKey, Signer};
1395
1396        let sk = SigningKey::from_bytes(&[7u8; 32]);
1397        let pk = sk.verifying_key().to_bytes();
1398
1399        let tool: Arc<dyn BuiltinTool> = Arc::new(FsReadFileTool::new());
1400        let tool_hash = compute_module_hash(b"fs.read_file-definition");
1401        let sig = sk.sign(&tool_hash);
1402
1403        let tool_sig = ToolSignature::new(
1404            "fs.read_file",
1405            tool_hash,
1406            "test-signer",
1407            sig.to_bytes().to_vec(),
1408        );
1409
1410        let mut registry = ToolRegistry::new();
1411        registry.set_require_signatures(true);
1412        registry.add_trusted_key(pk);
1413
1414        let result = registry.register_signed(tool, tool_sig);
1415        assert!(result.is_ok(), "register_signed should succeed: {result:?}");
1416        assert!(registry.get("fs.read_file").is_some());
1417        assert!(registry.get_signature("fs.read_file").is_some());
1418    }
1419
1420    #[test]
1421    #[cfg(feature = "exochain")]
1422    fn d9_verify_tool_signature_roundtrip() {
1423        use ed25519_dalek::{SigningKey, Signer};
1424
1425        let sk = SigningKey::from_bytes(&[99u8; 32]);
1426        let pk = sk.verifying_key().to_bytes();
1427
1428        let tool_hash = compute_module_hash(b"my-tool-definition-bytes");
1429        let sig = sk.sign(&tool_hash);
1430
1431        let tool_sig = ToolSignature::new(
1432            "my.tool",
1433            tool_hash,
1434            "dev-alice",
1435            sig.to_bytes().to_vec(),
1436        );
1437
1438        assert!(tool_sig.verify(&pk));
1439
1440        let wrong_sk = SigningKey::from_bytes(&[100u8; 32]);
1441        let wrong_pk = wrong_sk.verifying_key().to_bytes();
1442        assert!(!tool_sig.verify(&wrong_pk));
1443    }
1444
1445    #[test]
1446    fn d9_tool_signature_serde_roundtrip() {
1447        let sig = ToolSignature::new(
1448            "test.tool",
1449            [0xAB; 32],
1450            "signer-1",
1451            vec![0xCD; 64],
1452        );
1453        let json = serde_json::to_string(&sig).unwrap();
1454        let restored: ToolSignature = serde_json::from_str(&json).unwrap();
1455        assert_eq!(restored.tool_name, "test.tool");
1456        assert_eq!(restored.tool_hash, [0xAB; 32]);
1457        assert_eq!(restored.signer_id, "signer-1");
1458        assert_eq!(restored.signature.len(), 64);
1459    }
1460
1461    #[test]
1462    fn d9_register_signed_with_invalid_signature_fails() {
1463        let mut registry = ToolRegistry::new();
1464        registry.set_require_signatures(true);
1465        registry.add_trusted_key([42u8; 32]);
1466
1467        let tool: Arc<dyn BuiltinTool> = Arc::new(FsReadFileTool::new());
1468        let bad_sig = ToolSignature::new(
1469            "fs.read_file",
1470            [0u8; 32],
1471            "bad-signer",
1472            vec![0u8; 64],
1473        );
1474
1475        let result = registry.register_signed(tool, bad_sig);
1476        assert!(
1477            matches!(result, Err(ToolError::InvalidSignature(_))),
1478            "expected InvalidSignature, got: {result:?}",
1479        );
1480    }
1481
1482    // --- D10: Shell Command Execution tests ---
1483
1484    #[test]
1485    fn d10_shell_command_serde_roundtrip() {
1486        let cmd = ShellCommand {
1487            command: "echo".into(),
1488            args: vec!["hello".into(), "world".into()],
1489            sandbox_config: Some(SandboxConfig::default()),
1490        };
1491        let json = serde_json::to_string(&cmd).unwrap();
1492        let restored: ShellCommand = serde_json::from_str(&json).unwrap();
1493        assert_eq!(restored.command, "echo");
1494        assert_eq!(restored.args, vec!["hello", "world"]);
1495        assert!(restored.sandbox_config.is_some());
1496    }
1497
1498    #[test]
1499    fn d10_execute_shell_echo() {
1500        let cmd = ShellCommand {
1501            command: "echo".into(),
1502            args: vec!["hello".into(), "world".into()],
1503            sandbox_config: None,
1504        };
1505        let result = execute_shell(&cmd).unwrap();
1506        assert_eq!(result.exit_code, 0);
1507        assert_eq!(result.stdout, "hello world");
1508        assert!(result.stderr.is_empty());
1509    }
1510
1511    #[test]
1512    fn d10_execute_shell_includes_execution_time() {
1513        let cmd = ShellCommand {
1514            command: "true".into(),
1515            args: vec![],
1516            sandbox_config: None,
1517        };
1518        let result = execute_shell(&cmd).unwrap();
1519        assert_eq!(result.exit_code, 0);
1520        assert!(result.execution_time_ms < 1000, "should complete in < 1s");
1521    }
1522
1523    #[test]
1524    fn d10_execute_shell_unknown_command() {
1525        let cmd = ShellCommand {
1526            command: "nonexistent".into(),
1527            args: vec![],
1528            sandbox_config: None,
1529        };
1530        let result = execute_shell(&cmd).unwrap();
1531        assert_eq!(result.exit_code, 127);
1532        assert!(result.stderr.contains("command not found"));
1533    }
1534
1535    #[test]
1536    fn d10_shell_exec_tool_dispatch() {
1537        let tool = ShellExecTool::new();
1538        assert_eq!(tool.name(), "shell.exec");
1539
1540        let result = tool.execute(serde_json::json!({
1541            "command": "echo",
1542            "args": ["test"]
1543        })).unwrap();
1544
1545        assert_eq!(result["exit_code"], 0);
1546        assert_eq!(result["stdout"], "test");
1547    }
1548
1549    #[test]
1550    fn d10_shell_result_serde_roundtrip() {
1551        let result = ShellResult {
1552            exit_code: 0,
1553            stdout: "output".into(),
1554            stderr: "".into(),
1555            execution_time_ms: 42,
1556        };
1557        let json = serde_json::to_string(&result).unwrap();
1558        let restored: ShellResult = serde_json::from_str(&json).unwrap();
1559        assert_eq!(restored.exit_code, 0);
1560        assert_eq!(restored.stdout, "output");
1561        assert_eq!(restored.execution_time_ms, 42);
1562    }
1563
1564    #[test]
1565    fn d10_execute_shell_false_returns_nonzero() {
1566        let cmd = ShellCommand {
1567            command: "false".into(),
1568            args: vec![],
1569            sandbox_config: None,
1570        };
1571        let result = execute_shell(&cmd).unwrap();
1572        assert_eq!(result.exit_code, 1);
1573    }
1574}