Skip to main content

split_brain_harness/
wasm_forge.rs

1/// Phase 4 of the Ephemeral Tool Forge — WASM/WASI sandboxed execution.
2///
3/// Takes a verified `GeneratedTool` (Phase 3 output), compiles it to
4/// `wasm32-wasip1`, executes it in the `wasmtime` CLI sandbox (no network,
5/// no filesystem access), captures stdout, then destroys the binary.
6///
7/// **Status**: production-quality, full test coverage.
8/// **Requires**: `rustup target add wasm32-wasip1` and `wasmtime` in PATH.
9///   Run `sbh doctor` to verify the toolchain is installed.
10/// **CLI entry point**: `sbh forge "<capability>" "<input>"` (via `regenerative_forge`)
11/// and stores fingerprint metrics only.
12///
13/// The supervisor code calls `std::process::Command` — that is the supervisor's
14/// own privilege. Model-generated source code is still forbidden from using it
15/// (enforced by static analysis in Phase 3).
16use std::io::Write as _;
17use std::process::{Command, Stdio};
18use std::time::{Duration, Instant};
19
20/// Hard wall-clock limit on rustc compilation. Model-generated code is stdlib-only
21/// so 60 s is generous; pathological macro/trait-resolution abuse gets cut off here.
22const COMPILE_TIMEOUT: Duration = Duration::from_secs(60);
23
24/// Hard wall-clock limit passed directly to the wasmtime `-W timeout=` flag.
25/// Must be >= the capability constraint max (10 000 ms) and expressed as ms string.
26const WASM_EXEC_TIMEOUT_MS: u64 = 15_000;
27
28use serde::{Deserialize, Serialize};
29
30use crate::capability::{Budget, CapabilityMemoryRecord, CapabilityRequest, ToolMetrics};
31use crate::code_gen::GeneratedTool;
32use crate::input_validation;
33use crate::policy::{self, PolicyState};
34use crate::tool_memory::CapabilityMemory;
35
36// ---------------------------------------------------------------------------
37// WASM main() wrapper
38//
39// Appended to the generated source before compilation. Reads from stdin,
40// calls `run()`, writes JSON to stdout.
41// ---------------------------------------------------------------------------
42
43const WASM_MAIN: &str = r#"
44
45fn main() {
46    use std::io::Read;
47    let mut input = String::new();
48    if std::io::stdin().read_to_string(&mut input).is_err() {
49        std::process::exit(2);
50    }
51    match run(&input) {
52        Ok(output) => print!("{}", output),
53        Err(e) => {
54            eprintln!("run error: {}", e);
55            std::process::exit(1);
56        }
57    }
58}
59"#;
60
61// ---------------------------------------------------------------------------
62// Outcome enums
63// ---------------------------------------------------------------------------
64
65/// Result of the `rustc --target wasm32-wasi` compilation step.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub enum CompileOutcome {
68    Success {
69        wasm_bytes: Vec<u8>,
70        compilation_ms: u64,
71    },
72    /// The wasm32-wasi (or wasm32-wasip1) target is not installed.
73    TargetNotInstalled { attempted_target: String },
74    /// rustc ran but returned a non-zero exit code.
75    CompilationFailed { stderr: String, compilation_ms: u64 },
76    /// rustc could not be spawned (not on PATH, OS error, etc.).
77    CompilerNotFound { error: String },
78}
79
80/// Result of executing the compiled WASM binary.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub enum ExecuteOutcome {
83    Success {
84        stdout: String,
85        execution_ms: u64,
86    },
87    /// wasmtime binary is not available on PATH.
88    RuntimeNotFound,
89    /// wasmtime ran but the WASM process exited non-zero.
90    ExecutionFailed {
91        stderr: String,
92        exit_code: i32,
93        execution_ms: u64,
94    },
95    /// wasmtime could not be spawned (OS error).
96    RuntimeError {
97        error: String,
98    },
99}
100
101// ---------------------------------------------------------------------------
102// Phase 4 report
103// ---------------------------------------------------------------------------
104
105#[derive(Debug, Serialize, Deserialize, Clone)]
106pub struct WasmMetrics {
107    pub compilation_ms: u64,
108    pub execution_ms: u64,
109    pub wasm_binary_bytes: usize,
110    pub input_bytes: usize,
111    pub output_bytes: usize,
112}
113
114#[derive(Debug, Serialize, Deserialize, Clone)]
115pub struct WasmExecutionReport {
116    pub accepted: bool,
117    pub rejection_reasons: Vec<String>,
118    /// True when compilation succeeded.
119    pub compiled: bool,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub compilation_error: Option<String>,
122    /// True when the WASM executed successfully (exit code 0).
123    pub executed: bool,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub execution_error: Option<String>,
126    /// Captured stdout from the WASM process.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub output: Option<String>,
129    /// Binary destroyed after use: always true once compilation completes.
130    pub destroyed: bool,
131    pub metrics: WasmMetrics,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub memory_update: Option<CapabilityMemoryRecord>,
134}
135
136// ---------------------------------------------------------------------------
137// Compiler and executor traits — injectable for testing
138// ---------------------------------------------------------------------------
139
140pub trait WasmCompiler: Send + Sync {
141    fn compile(&self, source: &str) -> CompileOutcome;
142}
143
144pub trait WasmExecutor: Send + Sync {
145    fn execute(&self, wasm_bytes: &[u8], input: &str) -> ExecuteOutcome;
146}
147
148// ---------------------------------------------------------------------------
149// Real compiler: rustc --target wasm32-wasi
150// ---------------------------------------------------------------------------
151
152pub struct RustcCompiler;
153
154impl WasmCompiler for RustcCompiler {
155    fn compile(&self, source: &str) -> CompileOutcome {
156        // Detect installed WASM target (wasm32-wasip1 is the new name, wasm32-wasi the old)
157        let target = match detect_wasm_target() {
158            Some(t) => t,
159            None => {
160                return CompileOutcome::TargetNotInstalled {
161                    attempted_target: "wasm32-wasip1 / wasm32-wasi".into(),
162                }
163            }
164        };
165
166        // Write source + wrapper to a temp file
167        let tmp_dir = std::env::temp_dir().join(format!("sbh-wasm-{}", monotonic_id()));
168        if std::fs::create_dir_all(&tmp_dir).is_err() {
169            return CompileOutcome::CompilationFailed {
170                stderr: "failed to create temp directory".into(),
171                compilation_ms: 0,
172            };
173        }
174
175        let src_path = tmp_dir.join("tool.rs");
176        let wasm_path = tmp_dir.join("tool.wasm");
177        let full_source = format!("{}\n{}", source, WASM_MAIN);
178
179        if std::fs::write(&src_path, &full_source).is_err() {
180            let _ = std::fs::remove_dir_all(&tmp_dir);
181            return CompileOutcome::CompilationFailed {
182                stderr: "failed to write source file".into(),
183                compilation_ms: 0,
184            };
185        }
186
187        let start = Instant::now();
188
189        // Spawn rustc as a child so we can enforce a wall-clock timeout.
190        let child = Command::new("rustc")
191            .args(["--target", target, "--edition", "2021", "-o"])
192            .arg(&wasm_path)
193            .arg(&src_path)
194            .stdout(Stdio::piped())
195            .stderr(Stdio::piped())
196            .spawn();
197
198        let child = match child {
199            Err(e) => {
200                let _ = std::fs::remove_file(&src_path);
201                let _ = std::fs::remove_dir_all(&tmp_dir);
202                return CompileOutcome::CompilerNotFound {
203                    error: format!("could not spawn rustc: {e}"),
204                };
205            }
206            Ok(c) => c,
207        };
208
209        // Move child into a thread; wait for output or kill after COMPILE_TIMEOUT.
210        let pid = child.id();
211        let (tx, rx) = std::sync::mpsc::channel::<std::io::Result<std::process::Output>>();
212        std::thread::spawn(move || { let _ = tx.send(child.wait_with_output()); });
213
214        let result = match rx.recv_timeout(COMPILE_TIMEOUT) {
215            Ok(r) => r,
216            Err(_) => {
217                // Kill the rustc process by PID — it's already moved into the thread,
218                // so we signal it via the OS. Cleanup is best-effort.
219                let _ = Command::new("kill").args(["-9", &pid.to_string()]).status();
220                let _ = std::fs::remove_file(&src_path);
221                let _ = std::fs::remove_dir_all(&tmp_dir);
222                return CompileOutcome::CompilationFailed {
223                    stderr: format!("rustc timed out after {}s", COMPILE_TIMEOUT.as_secs()),
224                    compilation_ms: COMPILE_TIMEOUT.as_millis() as u64,
225                };
226            }
227        };
228
229        let compilation_ms = start.elapsed().as_millis() as u64;
230
231        // Remove source immediately (never persist model-generated code)
232        let _ = std::fs::remove_file(&src_path);
233
234        match result {
235            Err(e) => {
236                let _ = std::fs::remove_dir_all(&tmp_dir);
237                CompileOutcome::CompilerNotFound {
238                    error: format!("rustc wait error: {e}"),
239                }
240            }
241            Ok(out) if !out.status.success() => {
242                let _ = std::fs::remove_dir_all(&tmp_dir);
243                CompileOutcome::CompilationFailed {
244                    stderr: String::from_utf8_lossy(&out.stderr)
245                        .chars()
246                        .take(2048)
247                        .collect(),
248                    compilation_ms,
249                }
250            }
251            Ok(_) => {
252                let wasm_bytes = std::fs::read(&wasm_path).unwrap_or_default();
253                // Destroy the compiled WASM — metrics only survive
254                let _ = std::fs::remove_dir_all(&tmp_dir);
255                CompileOutcome::Success {
256                    wasm_bytes,
257                    compilation_ms,
258                }
259            }
260        }
261    }
262}
263
264/// Detect an installed WASM/WASI target via `rustup target list --installed`.
265/// Returns the preferred target name, or None if neither is installed.
266fn detect_wasm_target() -> Option<&'static str> {
267    let out = Command::new("rustup")
268        .args(["target", "list", "--installed"])
269        .output()
270        .ok()?;
271    let targets = String::from_utf8_lossy(&out.stdout);
272    if targets.contains("wasm32-wasip1") {
273        Some("wasm32-wasip1")
274    } else if targets.contains("wasm32-wasi") {
275        Some("wasm32-wasi")
276    } else {
277        None
278    }
279}
280
281// ---------------------------------------------------------------------------
282// Real executor: wasmtime CLI subprocess
283// ---------------------------------------------------------------------------
284
285pub struct WasmtimeCli;
286
287impl WasmExecutor for WasmtimeCli {
288    fn execute(&self, wasm_bytes: &[u8], input: &str) -> ExecuteOutcome {
289        // Write WASM to a temp file — destroyed immediately after the call
290        let tmp_wasm = std::env::temp_dir().join(format!("sbh-run-{}.wasm", monotonic_id()));
291        if std::fs::write(&tmp_wasm, wasm_bytes).is_err() {
292            return ExecuteOutcome::RuntimeError {
293                error: "failed to write wasm to temp file".into(),
294            };
295        }
296
297        let start = Instant::now();
298
299        // Spawn wasmtime and feed input via stdin. We wait for the process to
300        // finish BEFORE deleting the WASM file — spawning is non-blocking so
301        // deleting first would cause a race where wasmtime tries to open a
302        // file that no longer exists.
303        let spawn_result = Command::new("wasmtime")
304            .arg(format!("-W timeout={WASM_EXEC_TIMEOUT_MS}ms"))
305            .arg("--")
306            .arg(&tmp_wasm)
307            .stdin(Stdio::piped())
308            .stdout(Stdio::piped())
309            .stderr(Stdio::piped())
310            .spawn();
311
312        let output_result = match spawn_result {
313            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
314                let _ = std::fs::remove_file(&tmp_wasm);
315                return ExecuteOutcome::RuntimeNotFound;
316            }
317            Err(e) => {
318                let _ = std::fs::remove_file(&tmp_wasm);
319                return ExecuteOutcome::RuntimeError {
320                    error: format!("spawn error: {e}"),
321                };
322            }
323            Ok(mut child) => {
324                if let Some(mut stdin) = child.stdin.take() {
325                    let _ = stdin.write_all(input.as_bytes());
326                }
327                child.wait_with_output()
328            }
329        };
330
331        // WASM binary destroyed after the process has exited and closed the fd
332        let _ = std::fs::remove_file(&tmp_wasm);
333
334        match output_result {
335            Err(e) => ExecuteOutcome::RuntimeError {
336                error: format!("wait error: {e}"),
337            },
338            Ok(out) => {
339                let execution_ms = start.elapsed().as_millis() as u64;
340                let stdout = String::from_utf8_lossy(&out.stdout)
341                    .chars()
342                    .take(65_536)
343                    .collect();
344                let exit_code = out.status.code().unwrap_or(-1);
345
346                if out.status.success() {
347                    ExecuteOutcome::Success {
348                        stdout,
349                        execution_ms,
350                    }
351                } else {
352                    let stderr = String::from_utf8_lossy(&out.stderr)
353                        .chars()
354                        .take(1024)
355                        .collect();
356                    ExecuteOutcome::ExecutionFailed {
357                        stderr,
358                        exit_code,
359                        execution_ms,
360                    }
361                }
362            }
363        }
364    }
365}
366
367// ---------------------------------------------------------------------------
368// Supervisor
369// ---------------------------------------------------------------------------
370
371pub struct WasmForge {
372    budget: Budget,
373    state: PolicyState,
374    pub memory: CapabilityMemory,
375    compiler: Box<dyn WasmCompiler>,
376    executor: Box<dyn WasmExecutor>,
377    session_log: Vec<WasmExecutionReport>,
378}
379
380impl WasmForge {
381    pub fn new() -> Self {
382        Self::with_deps(Box::new(RustcCompiler), Box::new(WasmtimeCli))
383    }
384
385    pub fn with_deps(compiler: Box<dyn WasmCompiler>, executor: Box<dyn WasmExecutor>) -> Self {
386        Self {
387            // Tighter budget for Phase 4: compilation is expensive
388            budget: Budget {
389                max_tools_per_session: 2,
390                max_total_runtime_ms: 120_000, // 2 minutes total
391                require_approval_after_failures: 1,
392            },
393            state: PolicyState::default(),
394            memory: CapabilityMemory::new(),
395            compiler,
396            executor,
397            session_log: vec![],
398        }
399    }
400
401    pub fn audit(&self) -> &[WasmExecutionReport] {
402        &self.session_log
403    }
404
405    /// Process a Phase 3 GeneratedTool:
406    ///   validate → policy check → verify tool passed Phase 3 →
407    ///   compile → execute → destroy → memory update
408    pub fn handle(
409        &mut self,
410        req: &CapabilityRequest,
411        tool: &GeneratedTool,
412        input: &str,
413    ) -> WasmExecutionReport {
414        let report = self.handle_inner(req, tool, input);
415        self.session_log.push(report.clone());
416        report
417    }
418
419    fn handle_inner(
420        &mut self,
421        req: &CapabilityRequest,
422        tool: &GeneratedTool,
423        input: &str,
424    ) -> WasmExecutionReport {
425        // Input validation
426        if let Err(e) = input_validation::validate_forge_input(input) {
427            return rejected(vec![format!("input validation: {e}")]);
428        }
429        if let Err(e) = input_validation::validate_capability_fields(req) {
430            return rejected(vec![format!("capability field validation: {e}")]);
431        }
432
433        // Budget check
434        if let Some(reason) = self.state.budget_exceeded(&self.budget) {
435            return rejected(vec![reason]);
436        }
437
438        // Policy checks
439        let violations = policy::check_request(req);
440        if !violations.is_empty() {
441            return rejected(violations.into_iter().map(|v| v.detail).collect());
442        }
443
444        // Require Phase 3 verification to have passed
445        if !tool.static_analysis.passed {
446            let reasons: Vec<String> = tool
447                .static_analysis
448                .violations
449                .iter()
450                .map(|v| format!("static_analysis: {} at line {}", v.kind, v.line))
451                .collect();
452            return rejected(reasons);
453        }
454        if !tool.tests_included {
455            return rejected(vec![
456                "generated tool does not include the required minimum of 2 #[test] functions"
457                    .into(),
458            ]);
459        }
460
461        // Compile
462        let compile_outcome = self.compiler.compile(&tool.source);
463        let (wasm_bytes, compilation_ms) = match compile_outcome {
464            CompileOutcome::Success {
465                wasm_bytes,
466                compilation_ms,
467            } => (wasm_bytes, compilation_ms),
468            CompileOutcome::TargetNotInstalled { attempted_target } => {
469                let metrics = zero_metrics(input);
470                self.state.record_run(&ToolMetrics {
471                    success: false,
472                    input_bytes: input.len(),
473                    ..Default::default()
474                });
475                return WasmExecutionReport {
476                    accepted: true,
477                    rejection_reasons: vec![],
478                    compiled: false,
479                    compilation_error: Some(format!(
480                        "WASM target not installed: {attempted_target} — \
481                         run: rustup target add wasm32-wasip1"
482                    )),
483                    executed: false,
484                    execution_error: None,
485                    output: None,
486                    destroyed: true,
487                    metrics,
488                    memory_update: None,
489                };
490            }
491            CompileOutcome::CompilationFailed {
492                stderr,
493                compilation_ms,
494            } => {
495                let metrics = WasmMetrics {
496                    compilation_ms,
497                    ..zero_metrics(input)
498                };
499                self.state.record_run(&ToolMetrics {
500                    success: false,
501                    input_bytes: input.len(),
502                    runtime_ms: compilation_ms,
503                    ..Default::default()
504                });
505                return WasmExecutionReport {
506                    accepted: true,
507                    rejection_reasons: vec![],
508                    compiled: false,
509                    compilation_error: Some(stderr),
510                    executed: false,
511                    execution_error: None,
512                    output: None,
513                    destroyed: true,
514                    metrics,
515                    memory_update: None,
516                };
517            }
518            CompileOutcome::CompilerNotFound { error } => {
519                let metrics = zero_metrics(input);
520                self.state.record_run(&ToolMetrics {
521                    success: false,
522                    input_bytes: input.len(),
523                    ..Default::default()
524                });
525                return WasmExecutionReport {
526                    accepted: true,
527                    rejection_reasons: vec![],
528                    compiled: false,
529                    compilation_error: Some(error),
530                    executed: false,
531                    execution_error: None,
532                    output: None,
533                    destroyed: true,
534                    metrics,
535                    memory_update: None,
536                };
537            }
538        };
539
540        // WASM binary size (before destruction by executor)
541        let wasm_binary_bytes = wasm_bytes.len();
542
543        // Execute
544        let execute_outcome = self.executor.execute(&wasm_bytes, input);
545        // wasm_bytes is no longer needed — drop it now
546        drop(wasm_bytes);
547
548        let (executed, stdout, execution_ms, execution_error) = match execute_outcome {
549            ExecuteOutcome::Success {
550                stdout,
551                execution_ms,
552            } => (true, Some(stdout), execution_ms, None),
553            ExecuteOutcome::RuntimeNotFound => (
554                false,
555                None,
556                0,
557                Some("wasmtime not found on PATH — install from https://wasmtime.dev".into()),
558            ),
559            ExecuteOutcome::ExecutionFailed {
560                stderr,
561                execution_ms,
562                ..
563            } => (false, None, execution_ms, Some(stderr)),
564            ExecuteOutcome::RuntimeError { error } => (false, None, 0, Some(error)),
565        };
566
567        let success = executed && execution_error.is_none();
568        let tool_metrics = ToolMetrics {
569            runtime_ms: compilation_ms + execution_ms,
570            input_bytes: input.len(),
571            output_bytes: stdout.as_deref().map(|s| s.len()).unwrap_or(0),
572            success,
573        };
574        self.state.record_run(&tool_metrics);
575
576        let metrics = WasmMetrics {
577            compilation_ms,
578            execution_ms,
579            wasm_binary_bytes,
580            input_bytes: input.len(),
581            output_bytes: stdout.as_deref().map(|s| s.len()).unwrap_or(0),
582        };
583
584        // Update capability memory on success
585        let memory_update = if success {
586            let signature = CapabilityMemory::derive_signature(req);
587            let record = CapabilityMemoryRecord {
588                problem_signature: signature,
589                solution_pattern: format!("wasm:{}", req.capability),
590                input_shape: shape_token(&req.input_contract),
591                output_shape: shape_token(&req.output_contract),
592                constraints: req.constraints.clone(),
593            };
594            self.memory.upsert(record.clone(), &tool_metrics);
595            Some(record)
596        } else {
597            None
598        };
599
600        WasmExecutionReport {
601            accepted: true,
602            rejection_reasons: vec![],
603            compiled: true,
604            compilation_error: None,
605            executed,
606            execution_error,
607            output: stdout,
608            destroyed: true,
609            metrics,
610            memory_update,
611        }
612    }
613
614    pub fn tools_invoked(&self) -> usize {
615        self.state.tools_invoked
616    }
617}
618
619impl Default for WasmForge {
620    fn default() -> Self {
621        Self::new()
622    }
623}
624
625// ---------------------------------------------------------------------------
626// Helpers
627// ---------------------------------------------------------------------------
628
629fn rejected(reasons: Vec<String>) -> WasmExecutionReport {
630    WasmExecutionReport {
631        accepted: false,
632        rejection_reasons: reasons,
633        compiled: false,
634        compilation_error: None,
635        executed: false,
636        execution_error: None,
637        output: None,
638        destroyed: false,
639        metrics: WasmMetrics {
640            compilation_ms: 0,
641            execution_ms: 0,
642            wasm_binary_bytes: 0,
643            input_bytes: 0,
644            output_bytes: 0,
645        },
646        memory_update: None,
647    }
648}
649
650fn zero_metrics(input: &str) -> WasmMetrics {
651    WasmMetrics {
652        compilation_ms: 0,
653        execution_ms: 0,
654        wasm_binary_bytes: 0,
655        input_bytes: input.len(),
656        output_bytes: 0,
657    }
658}
659
660fn shape_token(contract: &str) -> String {
661    contract
662        .split_whitespace()
663        .take(3)
664        .map(|w| {
665            w.to_lowercase()
666                .trim_matches(|c: char| !c.is_alphanumeric())
667                .to_string()
668        })
669        .filter(|s| !s.is_empty())
670        .collect::<Vec<_>>()
671        .join("_")
672}
673
674fn monotonic_id() -> String {
675    let pid = std::process::id();
676    std::time::SystemTime::now()
677        .duration_since(std::time::UNIX_EPOCH)
678        .map(|d| format!("{}-{}{}", pid, d.as_secs(), d.subsec_nanos()))
679        .unwrap_or_else(|_| format!("{}-0", pid))
680}
681
682// ---------------------------------------------------------------------------
683// Tests — use mock compiler + executor so CI works without wasm32-wasi
684// ---------------------------------------------------------------------------
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use crate::capability::CapabilityConstraints;
690    use crate::static_analysis;
691
692    // --- Mock compiler ---
693
694    struct MockCompiler(CompileOutcome);
695
696    impl WasmCompiler for MockCompiler {
697        fn compile(&self, _source: &str) -> CompileOutcome {
698            self.0.clone()
699        }
700    }
701
702    // --- Mock executor ---
703
704    struct MockExecutor(ExecuteOutcome);
705
706    impl WasmExecutor for MockExecutor {
707        fn execute(&self, _bytes: &[u8], _input: &str) -> ExecuteOutcome {
708            self.0.clone()
709        }
710    }
711
712    // --- Test helpers ---
713
714    fn clean_req(cap: &str) -> CapabilityRequest {
715        CapabilityRequest {
716            kind: "capability_request".into(),
717            capability: cap.into(),
718            input_contract: "utf8 text".into(),
719            output_contract: "json object".into(),
720            constraints: CapabilityConstraints::default(),
721            reason: "text reasoning insufficient".into(),
722        }
723    }
724
725    fn verified_tool() -> GeneratedTool {
726        let source = r#"pub fn run(input: &str) -> Result<String, String> {
727    let c = input.split_whitespace().count();
728    Ok(format!("{\"count\":{}}", c))
729}
730#[test] fn t1() { assert!(run("a b").is_ok()); }
731#[test] fn t2() { assert!(run("").is_ok()); }"#;
732        GeneratedTool {
733            source: source.into(),
734            function_name: "run".into(),
735            tests_included: true,
736            test_count: 2,
737            static_analysis: static_analysis::check(source),
738        }
739    }
740
741    fn unverified_tool_unsafe() -> GeneratedTool {
742        let source = r#"pub fn run(input: &str) -> Result<String, String> {
743    unsafe { }
744    Ok("ok".into())
745}
746#[test] fn t1() {}
747#[test] fn t2() {}"#;
748        GeneratedTool {
749            source: source.into(),
750            function_name: "run".into(),
751            tests_included: true,
752            test_count: 2,
753            static_analysis: static_analysis::check(source),
754        }
755    }
756
757    fn unverified_tool_no_tests() -> GeneratedTool {
758        let source = "pub fn run(input: &str) -> Result<String, String> { Ok(\"ok\".into()) }";
759        GeneratedTool {
760            source: source.into(),
761            function_name: "run".into(),
762            tests_included: false,
763            test_count: 0,
764            static_analysis: static_analysis::check(source),
765        }
766    }
767
768    fn forge_with(compile: CompileOutcome, exec: ExecuteOutcome) -> WasmForge {
769        WasmForge::with_deps(
770            Box::new(MockCompiler(compile)),
771            Box::new(MockExecutor(exec)),
772        )
773    }
774
775    const MOCK_WASM: &[u8] = b"\x00asm\x01\x00\x00\x00"; // minimal WASM magic bytes
776
777    // --- Acceptance path ---
778
779    #[test]
780    fn successful_compile_and_execute() {
781        let mut forge = forge_with(
782            CompileOutcome::Success {
783                wasm_bytes: MOCK_WASM.to_vec(),
784                compilation_ms: 800,
785            },
786            ExecuteOutcome::Success {
787                stdout: r#"{"count":2}"#.into(),
788                execution_ms: 12,
789            },
790        );
791        let report = forge.handle(&clean_req("word_count"), &verified_tool(), "hello world");
792        assert!(report.accepted);
793        assert!(report.compiled);
794        assert!(report.executed);
795        assert!(report.destroyed, "binary must be destroyed after execution");
796        assert_eq!(report.output.as_deref(), Some(r#"{"count":2}"#));
797        assert!(report.memory_update.is_some());
798    }
799
800    // --- Rejection before compilation ---
801
802    #[test]
803    fn rejects_tool_that_failed_static_analysis() {
804        let mut forge = forge_with(
805            CompileOutcome::Success {
806                wasm_bytes: MOCK_WASM.to_vec(),
807                compilation_ms: 0,
808            },
809            ExecuteOutcome::Success {
810                stdout: "ok".into(),
811                execution_ms: 0,
812            },
813        );
814        let report = forge.handle(&clean_req("x"), &unverified_tool_unsafe(), "input");
815        assert!(!report.accepted);
816        assert!(report
817            .rejection_reasons
818            .iter()
819            .any(|r| r.contains("static_analysis")));
820    }
821
822    #[test]
823    fn rejects_tool_without_tests() {
824        let mut forge = forge_with(
825            CompileOutcome::Success {
826                wasm_bytes: MOCK_WASM.to_vec(),
827                compilation_ms: 0,
828            },
829            ExecuteOutcome::Success {
830                stdout: "ok".into(),
831                execution_ms: 0,
832            },
833        );
834        let report = forge.handle(&clean_req("x"), &unverified_tool_no_tests(), "input");
835        assert!(!report.accepted);
836        assert!(report.rejection_reasons[0].contains("#[test]"));
837    }
838
839    #[test]
840    fn rejects_policy_violation() {
841        let mut req = clean_req("fetch_url");
842        req.constraints.no_network = false;
843        let mut forge = forge_with(
844            CompileOutcome::Success {
845                wasm_bytes: MOCK_WASM.to_vec(),
846                compilation_ms: 0,
847            },
848            ExecuteOutcome::RuntimeNotFound,
849        );
850        let report = forge.handle(&req, &verified_tool(), "input");
851        assert!(!report.accepted);
852        assert!(report
853            .rejection_reasons
854            .iter()
855            .any(|r| r.contains("no_network")));
856    }
857
858    #[test]
859    fn rejects_oversized_input() {
860        let mut forge = forge_with(
861            CompileOutcome::Success {
862                wasm_bytes: MOCK_WASM.to_vec(),
863                compilation_ms: 0,
864            },
865            ExecuteOutcome::Success {
866                stdout: "ok".into(),
867                execution_ms: 0,
868            },
869        );
870        let big = "x".repeat(crate::input_validation::MAX_FORGE_INPUT_BYTES + 1);
871        let report = forge.handle(&clean_req("x"), &verified_tool(), &big);
872        assert!(!report.accepted);
873        assert!(report.rejection_reasons[0].contains("input validation"));
874    }
875
876    #[test]
877    fn rejects_when_budget_exhausted() {
878        let mut forge = WasmForge {
879            budget: Budget {
880                max_tools_per_session: 1,
881                ..Budget::default()
882            },
883            state: PolicyState::default(),
884            memory: CapabilityMemory::new(),
885            compiler: Box::new(MockCompiler(CompileOutcome::Success {
886                wasm_bytes: MOCK_WASM.to_vec(),
887                compilation_ms: 0,
888            })),
889            executor: Box::new(MockExecutor(ExecuteOutcome::Success {
890                stdout: "ok".into(),
891                execution_ms: 0,
892            })),
893            session_log: vec![],
894        };
895        forge.handle(&clean_req("x"), &verified_tool(), "a");
896        let report = forge.handle(&clean_req("x"), &verified_tool(), "b");
897        assert!(!report.accepted);
898        assert!(report.rejection_reasons[0].contains("session tool limit"));
899    }
900
901    // --- Compilation failure paths ---
902
903    #[test]
904    fn compilation_failure_reports_stderr() {
905        let mut forge = forge_with(
906            CompileOutcome::CompilationFailed {
907                stderr: "error[E0001]: syntax error".into(),
908                compilation_ms: 300,
909            },
910            ExecuteOutcome::RuntimeNotFound,
911        );
912        let report = forge.handle(&clean_req("x"), &verified_tool(), "input");
913        assert!(report.accepted);
914        assert!(!report.compiled);
915        assert!(!report.executed);
916        assert!(report.destroyed, "nothing to destroy but flag must be set");
917        assert!(report
918            .compilation_error
919            .as_deref()
920            .unwrap_or("")
921            .contains("syntax error"));
922    }
923
924    #[test]
925    fn target_not_installed_returns_clear_message() {
926        let mut forge = forge_with(
927            CompileOutcome::TargetNotInstalled {
928                attempted_target: "wasm32-wasip1".into(),
929            },
930            ExecuteOutcome::RuntimeNotFound,
931        );
932        let report = forge.handle(&clean_req("x"), &verified_tool(), "input");
933        assert!(report.accepted);
934        assert!(!report.compiled);
935        let err = report.compilation_error.unwrap_or_default();
936        assert!(err.contains("wasm32-wasip1") || err.contains("target not installed"));
937    }
938
939    // --- Execution failure paths ---
940
941    #[test]
942    fn runtime_not_found_does_not_panic() {
943        let mut forge = forge_with(
944            CompileOutcome::Success {
945                wasm_bytes: MOCK_WASM.to_vec(),
946                compilation_ms: 500,
947            },
948            ExecuteOutcome::RuntimeNotFound,
949        );
950        let report = forge.handle(&clean_req("x"), &verified_tool(), "input");
951        assert!(report.accepted);
952        assert!(report.compiled);
953        assert!(!report.executed);
954        assert!(report
955            .execution_error
956            .as_deref()
957            .unwrap_or("")
958            .contains("wasmtime"));
959    }
960
961    #[test]
962    fn execution_failure_reports_stderr() {
963        let mut forge = forge_with(
964            CompileOutcome::Success {
965                wasm_bytes: MOCK_WASM.to_vec(),
966                compilation_ms: 500,
967            },
968            ExecuteOutcome::ExecutionFailed {
969                stderr: "runtime trap".into(),
970                exit_code: 1,
971                execution_ms: 5,
972            },
973        );
974        let report = forge.handle(&clean_req("x"), &verified_tool(), "input");
975        assert!(report.compiled);
976        assert!(!report.executed);
977        assert!(report
978            .execution_error
979            .as_deref()
980            .unwrap_or("")
981            .contains("runtime trap"));
982    }
983
984    // --- Memory and audit ---
985
986    #[test]
987    fn memory_not_updated_when_execution_fails() {
988        let mut forge = forge_with(
989            CompileOutcome::Success {
990                wasm_bytes: MOCK_WASM.to_vec(),
991                compilation_ms: 0,
992            },
993            ExecuteOutcome::RuntimeNotFound,
994        );
995        forge.handle(&clean_req("x"), &verified_tool(), "input");
996        assert_eq!(forge.memory.len(), 0);
997    }
998
999    #[test]
1000    fn session_log_records_all_calls() {
1001        let mut forge = forge_with(
1002            CompileOutcome::Success {
1003                wasm_bytes: MOCK_WASM.to_vec(),
1004                compilation_ms: 0,
1005            },
1006            ExecuteOutcome::Success {
1007                stdout: "ok".into(),
1008                execution_ms: 0,
1009            },
1010        );
1011        forge.handle(&clean_req("x"), &verified_tool(), "a");
1012        let mut req2 = clean_req("y");
1013        req2.constraints.no_network = false;
1014        forge.handle(&req2, &verified_tool(), "b");
1015        assert_eq!(forge.audit().len(), 2);
1016    }
1017
1018    #[test]
1019    fn wasm_wrapper_contains_stdin_read_and_run_call() {
1020        assert!(WASM_MAIN.contains("read_to_string"));
1021        assert!(WASM_MAIN.contains("run(&input)"));
1022        assert!(WASM_MAIN.contains("process::exit"));
1023    }
1024
1025    #[test]
1026    fn metrics_record_binary_bytes() {
1027        let wasm = vec![0u8; 42_000];
1028        let mut forge = forge_with(
1029            CompileOutcome::Success {
1030                wasm_bytes: wasm,
1031                compilation_ms: 900,
1032            },
1033            ExecuteOutcome::Success {
1034                stdout: "result".into(),
1035                execution_ms: 15,
1036            },
1037        );
1038        let report = forge.handle(&clean_req("x"), &verified_tool(), "input");
1039        assert_eq!(report.metrics.wasm_binary_bytes, 42_000);
1040        assert_eq!(report.metrics.compilation_ms, 900);
1041        assert_eq!(report.metrics.execution_ms, 15);
1042    }
1043}