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