1use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12
13use deno_core::{v8, JsRuntime, PollEventLoopOptions, RuntimeOptions};
14use serde_json::Value;
15use tokio::sync::Semaphore;
16
17use crate::audit::{
18 AuditEntryBuilder, AuditLogger, AuditOperation, AuditingDispatcher, NoopAuditLogger,
19 ToolCallAudit,
20};
21use crate::error::SandboxError;
22use crate::ops::{forge_ext, ExecutionResult, ToolCallLimits};
23use crate::validator::validate_code;
24use crate::ToolDispatcher;
25
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub enum ExecutionMode {
29 #[default]
31 InProcess,
32 ChildProcess,
34}
35
36#[derive(Debug, Clone)]
38pub struct SandboxConfig {
39 pub timeout: Duration,
41 pub max_code_size: usize,
43 pub max_output_size: usize,
45 pub max_heap_size: usize,
47 pub max_concurrent: usize,
49 pub max_tool_calls: usize,
51 pub max_tool_call_args_size: usize,
53 pub execution_mode: ExecutionMode,
55}
56
57impl Default for SandboxConfig {
58 fn default() -> Self {
59 Self {
60 timeout: Duration::from_secs(5),
61 max_code_size: 64 * 1024, max_output_size: 1024 * 1024, max_heap_size: 64 * 1024 * 1024, max_concurrent: 8,
65 max_tool_calls: 50,
66 max_tool_call_args_size: 1024 * 1024, execution_mode: ExecutionMode::default(),
68 }
69 }
70}
71
72pub struct SandboxExecutor {
78 config: SandboxConfig,
79 semaphore: Arc<Semaphore>,
80 audit_logger: Arc<dyn AuditLogger>,
81}
82
83impl SandboxExecutor {
84 pub fn new(config: SandboxConfig) -> Self {
86 let semaphore = Arc::new(Semaphore::new(config.max_concurrent));
87 Self {
88 config,
89 semaphore,
90 audit_logger: Arc::new(NoopAuditLogger),
91 }
92 }
93
94 pub fn with_audit_logger(config: SandboxConfig, logger: Arc<dyn AuditLogger>) -> Self {
96 let semaphore = Arc::new(Semaphore::new(config.max_concurrent));
97 Self {
98 config,
99 semaphore,
100 audit_logger: logger,
101 }
102 }
103
104 pub async fn execute_search(
110 &self,
111 code: &str,
112 manifest: &Value,
113 ) -> Result<Value, SandboxError> {
114 tracing::info!(code_len = code.len(), "execute_search: starting");
115
116 let audit_builder = AuditEntryBuilder::new(code, AuditOperation::Search);
117
118 validate_code(code, Some(self.config.max_code_size))?;
119
120 let _permit = self.semaphore.clone().try_acquire_owned().map_err(|_| {
121 SandboxError::ConcurrencyLimit {
122 max: self.config.max_concurrent,
123 }
124 })?;
125
126 let code = code.to_string();
127 let manifest = manifest.clone();
128 let config = self.config.clone();
129
130 let (tx, rx) = tokio::sync::oneshot::channel();
132 std::thread::spawn(move || {
133 let rt = match tokio::runtime::Builder::new_current_thread()
134 .enable_all()
135 .build()
136 {
137 Ok(rt) => rt,
138 Err(e) => {
139 if tx.send(Err(SandboxError::Execution(e.into()))).is_err() {
140 tracing::warn!("sandbox result receiver dropped");
141 }
142 return;
143 }
144 };
145 let result = rt.block_on(run_search(&config, &code, &manifest));
146 if tx.send(result).is_err() {
147 tracing::warn!("sandbox result receiver dropped before result was sent");
148 }
149 });
150
151 let result = rx
152 .await
153 .map_err(|_| SandboxError::Execution(anyhow::anyhow!("sandbox thread panicked")))?;
154
155 let entry = audit_builder.finish(&result);
157 self.audit_logger.log(&entry).await;
158
159 match &result {
160 Ok(_) => tracing::info!("execute_search: complete"),
161 Err(e) => tracing::warn!(error = %e, "execute_search: failed"),
162 }
163
164 result
165 }
166
167 pub async fn execute_code(
175 &self,
176 code: &str,
177 dispatcher: Arc<dyn ToolDispatcher>,
178 ) -> Result<Value, SandboxError> {
179 tracing::info!(
180 code_len = code.len(),
181 mode = ?self.config.execution_mode,
182 "execute_code: starting"
183 );
184
185 let mut audit_builder = AuditEntryBuilder::new(code, AuditOperation::Execute);
186
187 validate_code(code, Some(self.config.max_code_size))?;
188
189 let _permit = self.semaphore.clone().try_acquire_owned().map_err(|_| {
190 SandboxError::ConcurrencyLimit {
191 max: self.config.max_concurrent,
192 }
193 })?;
194
195 let (audit_tx, mut audit_rx) = tokio::sync::mpsc::unbounded_channel::<ToolCallAudit>();
197 let auditing_dispatcher: Arc<dyn ToolDispatcher> =
198 Arc::new(AuditingDispatcher::new(dispatcher, audit_tx));
199
200 let result = match self.config.execution_mode {
201 ExecutionMode::ChildProcess => {
202 crate::host::SandboxHost::execute_in_child(code, &self.config, auditing_dispatcher)
203 .await
204 }
205 ExecutionMode::InProcess => {
206 self.execute_code_in_process(code, auditing_dispatcher)
207 .await
208 }
209 };
210
211 while let Ok(tool_audit) = audit_rx.try_recv() {
213 audit_builder.record_tool_call(tool_audit);
214 }
215
216 let entry = audit_builder.finish(&result);
218 self.audit_logger.log(&entry).await;
219
220 match &result {
221 Ok(_) => tracing::info!("execute_code: complete"),
222 Err(e) => tracing::warn!(error = %e, "execute_code: failed"),
223 }
224
225 result
226 }
227
228 async fn execute_code_in_process(
230 &self,
231 code: &str,
232 dispatcher: Arc<dyn ToolDispatcher>,
233 ) -> Result<Value, SandboxError> {
234 let code = code.to_string();
235 let config = self.config.clone();
236
237 let (tx, rx) = tokio::sync::oneshot::channel();
238 std::thread::spawn(move || {
239 let rt = match tokio::runtime::Builder::new_current_thread()
240 .enable_all()
241 .build()
242 {
243 Ok(rt) => rt,
244 Err(e) => {
245 if tx.send(Err(SandboxError::Execution(e.into()))).is_err() {
246 tracing::warn!("sandbox result receiver dropped");
247 }
248 return;
249 }
250 };
251 let result = rt.block_on(run_execute(&config, &code, dispatcher));
252 if tx.send(result).is_err() {
253 tracing::warn!("sandbox result receiver dropped before result was sent");
254 }
255 });
256
257 rx.await
258 .map_err(|_| SandboxError::Execution(anyhow::anyhow!("sandbox thread panicked")))?
259 }
260}
261
262struct HeapLimitState {
264 handle: v8::IsolateHandle,
265 triggered: AtomicBool,
268}
269
270extern "C" fn near_heap_limit_callback(
273 data: *mut std::ffi::c_void,
274 current_heap_limit: usize,
275 _initial_heap_limit: usize,
276) -> usize {
277 let state = unsafe { &*(data as *const HeapLimitState) };
284 if !state.triggered.swap(true, Ordering::SeqCst) {
285 state.handle.terminate_execution();
286 }
287 current_heap_limit + 1024 * 1024
289}
290
291pub async fn run_search(
296 config: &SandboxConfig,
297 code: &str,
298 manifest: &Value,
299) -> Result<Value, SandboxError> {
300 let mut runtime = create_runtime(None, config.max_heap_size, None)?;
301
302 let manifest_json = serde_json::to_string(manifest)?;
304 let bootstrap = format!("globalThis.manifest = {};", manifest_json);
305 runtime
306 .execute_script("[forge:manifest]", bootstrap)
307 .map_err(|e| SandboxError::JsError {
308 message: e.to_string(),
309 })?;
310
311 runtime
314 .execute_script(
315 "[forge:bootstrap]",
316 r#"
317 ((ops) => {
318 const setResult = (json) => ops.op_forge_set_result(json);
319 const log = (msg) => ops.op_forge_log(String(msg));
320 globalThis.forge = Object.freeze({
321 __setResult: setResult,
322 log: log,
323 });
324 delete globalThis.Deno;
325
326 // Remove code generation primitives to prevent prototype chain attacks.
327 // Even with the validator banning eval( and Function(, an attacker could
328 // reach Function via forge.log.constructor or similar prototype chain access.
329 delete globalThis.eval;
330 const AsyncFunction = (async function(){}).constructor;
331 const GeneratorFunction = (function*(){}).constructor;
332 Object.defineProperty(Function.prototype, 'constructor', {
333 value: undefined, configurable: false, writable: false
334 });
335 Object.defineProperty(AsyncFunction.prototype, 'constructor', {
336 value: undefined, configurable: false, writable: false
337 });
338 Object.defineProperty(GeneratorFunction.prototype, 'constructor', {
339 value: undefined, configurable: false, writable: false
340 });
341 })(Deno.core.ops);
342 "#,
343 )
344 .map_err(|e| SandboxError::JsError {
345 message: e.to_string(),
346 })?;
347
348 run_user_code(&mut runtime, code, config).await
349}
350
351pub async fn run_execute(
355 config: &SandboxConfig,
356 code: &str,
357 dispatcher: Arc<dyn ToolDispatcher>,
358) -> Result<Value, SandboxError> {
359 let limits = ToolCallLimits {
360 max_calls: config.max_tool_calls,
361 max_args_size: config.max_tool_call_args_size,
362 calls_made: 0,
363 };
364 let mut runtime = create_runtime(Some(dispatcher), config.max_heap_size, Some(limits))?;
365
366 runtime
370 .execute_script(
371 "[forge:bootstrap]",
372 r#"
373 ((ops) => {
374 const callToolOp = ops.op_forge_call_tool;
375 const setResult = (json) => ops.op_forge_set_result(json);
376 const log = (msg) => ops.op_forge_log(String(msg));
377
378 const callTool = async (server, tool, args) => {
379 const resultJson = await callToolOp(
380 server, tool, JSON.stringify(args || {})
381 );
382 return JSON.parse(resultJson);
383 };
384
385 globalThis.forge = Object.freeze({
386 __setResult: setResult,
387 log: log,
388 callTool: callTool,
389 server: (name) => {
390 return new Proxy({}, {
391 get(_target, category) {
392 return new Proxy({}, {
393 get(_target2, tool) {
394 return async (args) => {
395 return callTool(
396 name,
397 `${category}.${tool}`,
398 args || {}
399 );
400 };
401 }
402 });
403 }
404 });
405 }
406 });
407
408 delete globalThis.Deno;
409
410 // Remove code generation primitives to prevent prototype chain attacks.
411 delete globalThis.eval;
412 const AsyncFunction = (async function(){}).constructor;
413 const GeneratorFunction = (function*(){}).constructor;
414 Object.defineProperty(Function.prototype, 'constructor', {
415 value: undefined, configurable: false, writable: false
416 });
417 Object.defineProperty(AsyncFunction.prototype, 'constructor', {
418 value: undefined, configurable: false, writable: false
419 });
420 Object.defineProperty(GeneratorFunction.prototype, 'constructor', {
421 value: undefined, configurable: false, writable: false
422 });
423 })(Deno.core.ops);
424 "#,
425 )
426 .map_err(|e| SandboxError::JsError {
427 message: e.to_string(),
428 })?;
429
430 run_user_code(&mut runtime, code, config).await
431}
432
433pub fn create_runtime(
437 dispatcher: Option<Arc<dyn ToolDispatcher>>,
438 max_heap_size: usize,
439 tool_call_limits: Option<ToolCallLimits>,
440) -> Result<JsRuntime, SandboxError> {
441 let create_params = v8::CreateParams::default().heap_limits(0, max_heap_size);
442
443 let runtime = JsRuntime::new(RuntimeOptions {
444 extensions: vec![forge_ext::init()],
445 create_params: Some(create_params),
446 ..Default::default()
447 });
448
449 if let Some(d) = dispatcher {
450 runtime.op_state().borrow_mut().put(d);
451 }
452 if let Some(limits) = tool_call_limits {
453 runtime.op_state().borrow_mut().put(limits);
454 }
455
456 Ok(runtime)
457}
458
459async fn run_user_code(
466 runtime: &mut JsRuntime,
467 code: &str,
468 config: &SandboxConfig,
469) -> Result<Value, SandboxError> {
470 let heap_state = Box::new(HeapLimitState {
472 handle: runtime.v8_isolate().thread_safe_handle(),
473 triggered: AtomicBool::new(false),
474 });
475 runtime.v8_isolate().add_near_heap_limit_callback(
476 near_heap_limit_callback,
477 &*heap_state as *const HeapLimitState as *mut std::ffi::c_void,
478 );
479
480 let watchdog_handle = runtime.v8_isolate().thread_safe_handle();
482 let timed_out = Arc::new(AtomicBool::new(false));
483 let watchdog_timed_out = timed_out.clone();
484 let timeout = config.timeout;
485 let (cancel_tx, cancel_rx) = std::sync::mpsc::channel::<()>();
486
487 let watchdog = std::thread::spawn(move || {
488 if let Err(std::sync::mpsc::RecvTimeoutError::Timeout) = cancel_rx.recv_timeout(timeout) {
489 watchdog_timed_out.store(true, Ordering::SeqCst);
490 watchdog_handle.terminate_execution();
491 }
492 });
493
494 let wrapped = format!(
496 r#"
497 (async () => {{
498 try {{
499 const __userFn = {code};
500 const __result = await __userFn();
501 forge.__setResult(
502 JSON.stringify({{ ok: __result }})
503 );
504 }} catch (e) {{
505 forge.__setResult(
506 JSON.stringify({{ error: e.message || String(e) }})
507 );
508 }}
509 }})();
510 "#
511 );
512
513 let exec_error = match runtime.execute_script("[forge:execute]", wrapped) {
514 Ok(_) => {
515 match tokio::time::timeout(
517 config.timeout,
518 runtime.run_event_loop(PollEventLoopOptions::default()),
519 )
520 .await
521 {
522 Ok(Ok(())) => None,
523 Ok(Err(e)) => Some(e.to_string()),
524 Err(_) => Some("async timeout".to_string()),
525 }
526 }
527 Err(e) => Some(e.to_string()),
528 };
529
530 let _ = cancel_tx.send(());
534 let _ = watchdog.join();
535
536 if heap_state.triggered.load(Ordering::SeqCst) {
538 return Err(SandboxError::HeapLimitExceeded);
539 }
540
541 if timed_out.load(Ordering::SeqCst) {
542 return Err(SandboxError::Timeout {
543 timeout_ms: config.timeout.as_millis() as u64,
544 });
545 }
546
547 if let Some(err_msg) = exec_error {
548 return Err(SandboxError::JsError { message: err_msg });
549 }
550
551 let result_str = {
553 let state = runtime.op_state();
554 let state = state.borrow();
555 state
556 .try_borrow::<ExecutionResult>()
557 .map(|r| r.0.clone())
558 .ok_or_else(|| SandboxError::JsError {
559 message: "no result returned from sandbox execution".into(),
560 })?
561 };
562
563 if result_str.len() > config.max_output_size {
564 return Err(SandboxError::OutputTooLarge {
565 max: config.max_output_size,
566 });
567 }
568
569 let envelope: Value = serde_json::from_str(&result_str)?;
570
571 if let Some(error) = envelope.get("error") {
572 return Err(SandboxError::JsError {
573 message: error.as_str().unwrap_or("unknown error").to_string(),
574 });
575 }
576
577 Ok(envelope.get("ok").cloned().unwrap_or(Value::Null))
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 fn executor() -> SandboxExecutor {
585 SandboxExecutor::new(SandboxConfig::default())
586 }
587
588 struct TestDispatcher;
590
591 #[async_trait::async_trait]
592 impl ToolDispatcher for TestDispatcher {
593 async fn call_tool(
594 &self,
595 server: &str,
596 tool: &str,
597 args: serde_json::Value,
598 ) -> Result<serde_json::Value, anyhow::Error> {
599 Ok(serde_json::json!({
600 "server": server,
601 "tool": tool,
602 "args": args,
603 "status": "ok"
604 }))
605 }
606 }
607
608 #[tokio::test]
609 async fn search_returns_manifest_data() {
610 let exec = executor();
611 let manifest = serde_json::json!({
612 "tools": [
613 {"name": "parse_ast", "category": "ast"},
614 {"name": "find_symbols", "category": "symbols"},
615 ]
616 });
617
618 let code = r#"async () => {
619 return manifest.tools.filter(t => t.category === "ast");
620 }"#;
621
622 let result = exec.execute_search(code, &manifest).await.unwrap();
623 let tools = result.as_array().unwrap();
624 assert_eq!(tools.len(), 1);
625 assert_eq!(tools[0]["name"], "parse_ast");
626 }
627
628 #[tokio::test]
629 async fn search_handles_complex_queries() {
630 let exec = executor();
631 let manifest = serde_json::json!({
632 "servers": [
633 {
634 "name": "narsil",
635 "categories": {
636 "ast": { "tools": ["parse", "query", "walk"] },
637 "symbols": { "tools": ["find", "references"] }
638 }
639 }
640 ]
641 });
642
643 let code = r#"async () => {
644 return manifest.servers
645 .map(s => ({ name: s.name, categories: Object.keys(s.categories) }));
646 }"#;
647
648 let result = exec.execute_search(code, &manifest).await.unwrap();
649 let servers = result.as_array().unwrap();
650 assert_eq!(servers[0]["name"], "narsil");
651 }
652
653 #[tokio::test]
654 async fn timeout_is_enforced() {
655 let exec = SandboxExecutor::new(SandboxConfig {
656 timeout: Duration::from_millis(200),
657 ..Default::default()
658 });
659 let manifest = serde_json::json!({});
660
661 let code = r#"async () => {
663 await new Promise(() => {});
664 }"#;
665
666 let start = std::time::Instant::now();
667 let err = exec.execute_search(code, &manifest).await.unwrap_err();
668 let elapsed = start.elapsed();
669
670 match &err {
673 SandboxError::Timeout { .. } => {}
674 SandboxError::JsError { message } if message.contains("no result") => {
675 }
678 other => panic!("unexpected error: {other:?}, elapsed: {elapsed:?}"),
679 }
680 }
681
682 #[tokio::test]
683 async fn js_errors_are_captured() {
684 let exec = executor();
685 let manifest = serde_json::json!({});
686
687 let code = r#"async () => {
688 throw new Error("intentional test error");
689 }"#;
690
691 let err = exec.execute_search(code, &manifest).await.unwrap_err();
692 assert!(matches!(err, SandboxError::JsError { .. }));
693 let msg = err.to_string();
694 assert!(msg.contains("intentional test error"));
695 }
696
697 #[tokio::test]
698 async fn no_filesystem_access() {
699 let exec = executor();
700 let manifest = serde_json::json!({});
701
702 let code = r#"async () => {
704 const fs = require("fs");
705 return "ESCAPED";
706 }"#;
707
708 let err = exec.execute_search(code, &manifest).await;
709 assert!(err.is_err());
710 }
711
712 #[tokio::test]
713 async fn no_network_access() {
714 let exec = executor();
715 let manifest = serde_json::json!({});
716
717 let code = r#"async () => {
718 try {
719 await fetch("https://example.com");
720 return "ESCAPED";
721 } catch(e) {
722 return "CONTAINED";
723 }
724 }"#;
725
726 let result = exec.execute_search(code, &manifest).await.unwrap();
727 assert_eq!(result, "CONTAINED");
728 }
729
730 #[tokio::test]
733 async fn cpu_bound_infinite_loop_is_terminated() {
734 let exec = SandboxExecutor::new(SandboxConfig {
735 timeout: Duration::from_millis(500),
736 ..Default::default()
737 });
738 let manifest = serde_json::json!({});
739
740 let code = r#"async () => {
741 while(true) {}
742 }"#;
743
744 let start = std::time::Instant::now();
745 let err = exec.execute_search(code, &manifest).await.unwrap_err();
746 let elapsed = start.elapsed();
747
748 assert!(
749 matches!(err, SandboxError::Timeout { .. }),
750 "expected timeout, got: {err:?}"
751 );
752 assert!(
753 elapsed < Duration::from_secs(5),
754 "should complete reasonably fast, took: {elapsed:?}"
755 );
756 }
757
758 #[tokio::test]
759 async fn heap_limit_prevents_oom() {
760 let exec = SandboxExecutor::new(SandboxConfig {
761 max_heap_size: 10 * 1024 * 1024, timeout: Duration::from_secs(30), ..Default::default()
764 });
765 let manifest = serde_json::json!({});
766
767 let code = r#"async () => {
769 const arr = [];
770 while(true) {
771 arr.push(new Array(100000).fill("x"));
772 }
773 }"#;
774
775 let err = exec.execute_search(code, &manifest).await.unwrap_err();
776 assert!(
777 matches!(
778 err,
779 SandboxError::HeapLimitExceeded | SandboxError::JsError { .. }
780 ),
781 "expected heap limit or JS error, got: {err:?}"
782 );
783 }
784
785 #[tokio::test]
786 async fn concurrency_limit_enforced() {
787 let exec = SandboxExecutor::new(SandboxConfig {
789 max_concurrent: 0,
790 ..Default::default()
791 });
792
793 let code = r#"async () => { return 1; }"#;
794 let err = exec
795 .execute_search(code, &serde_json::json!({}))
796 .await
797 .unwrap_err();
798 assert!(
799 matches!(err, SandboxError::ConcurrencyLimit { max: 0 }),
800 "expected concurrency limit, got: {err:?}"
801 );
802 }
803
804 #[tokio::test]
805 async fn deno_global_is_not_accessible() {
806 let exec = executor();
807 let manifest = serde_json::json!({});
808
809 let code = r#"async () => {
810 const props = Object.getOwnPropertyNames(globalThis);
811 return !props.includes("Deno");
812 }"#;
813
814 let result = exec.execute_search(code, &manifest).await.unwrap();
815 assert_eq!(result, true);
816 }
817
818 #[tokio::test]
819 async fn forge_object_is_frozen() {
820 let exec = executor();
821 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
822
823 let code = r#"async () => {
824 return Object.isFrozen(forge);
825 }"#;
826
827 let result = exec.execute_code(code, dispatcher).await.unwrap();
828 assert_eq!(result, true);
829 }
830
831 #[tokio::test]
832 async fn tool_call_rate_limit() {
833 let exec = SandboxExecutor::new(SandboxConfig {
834 max_tool_calls: 2,
835 ..Default::default()
836 });
837 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
838
839 let code = r#"async () => {
840 await forge.callTool("test", "tool1", {});
841 await forge.callTool("test", "tool2", {});
842 try {
843 await forge.callTool("test", "tool3", {});
844 return "should not reach here";
845 } catch(e) {
846 return e.message;
847 }
848 }"#;
849
850 let result = exec.execute_code(code, dispatcher).await.unwrap();
851 assert!(
852 result
853 .as_str()
854 .unwrap()
855 .contains("tool call limit exceeded"),
856 "expected tool call limit message, got: {result:?}"
857 );
858 }
859
860 #[tokio::test]
861 async fn tool_call_args_size_limit() {
862 let exec = SandboxExecutor::new(SandboxConfig {
863 max_tool_call_args_size: 100,
864 ..Default::default()
865 });
866 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
867
868 let code = r#"async () => {
869 try {
870 await forge.callTool("test", "tool", { data: "x".repeat(200) });
871 return "should not reach here";
872 } catch(e) {
873 return e.message;
874 }
875 }"#;
876
877 let result = exec.execute_code(code, dispatcher).await.unwrap();
878 assert!(
879 result.as_str().unwrap().contains("too large"),
880 "expected args too large message, got: {result:?}"
881 );
882 }
883
884 #[tokio::test]
885 async fn forge_log_works() {
886 let exec = executor();
887 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
888
889 let code = r#"async () => {
890 forge.log("test message from sandbox");
891 return "ok";
892 }"#;
893
894 let result = exec.execute_code(code, dispatcher).await.unwrap();
895 assert_eq!(result, "ok");
896 }
897
898 #[tokio::test]
899 async fn forge_server_proxy_calls_tool() {
900 let exec = executor();
901 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
902
903 let code = r#"async () => {
904 const result = await forge.server("narsil").ast.parse({ file: "test.rs" });
905 return result;
906 }"#;
907
908 let result = exec.execute_code(code, dispatcher).await.unwrap();
909 assert_eq!(result["server"], "narsil");
910 assert_eq!(result["tool"], "ast.parse");
911 assert_eq!(result["status"], "ok");
912 }
913
914 #[tokio::test]
915 async fn multiple_tool_calls_in_single_execution() {
916 let exec = executor();
917 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
918
919 let code = r#"async () => {
920 const r1 = await forge.callTool("server1", "tool1", {});
921 const r2 = await forge.callTool("server2", "tool2", {});
922 return [r1, r2];
923 }"#;
924
925 let result = exec.execute_code(code, dispatcher).await.unwrap();
926 let arr = result.as_array().unwrap();
927 assert_eq!(arr.len(), 2);
928 assert_eq!(arr[0]["server"], "server1");
929 assert_eq!(arr[1]["server"], "server2");
930 }
931
932 #[tokio::test]
933 async fn eval_is_not_accessible() {
934 let exec = executor();
935 let manifest = serde_json::json!({});
936
937 let code = r#"async () => {
938 return typeof globalThis.eval;
939 }"#;
940
941 let result = exec.execute_search(code, &manifest).await.unwrap();
942 assert_eq!(result, "undefined");
943 }
944
945 #[tokio::test]
946 async fn function_constructor_is_blocked() {
947 let exec = executor();
948 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
949
950 let code = r#"async () => {
952 const ctor = forge.log.constructor;
953 return String(ctor);
954 }"#;
955
956 let result = exec.execute_code(code, dispatcher).await.unwrap();
957 assert_eq!(result, "undefined");
958 }
959
960 #[tokio::test]
961 async fn async_function_constructor_is_blocked() {
962 let exec = executor();
963 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
964
965 let code = r#"async () => {
967 const fn1 = async () => {};
968 const ctor = fn1.constructor;
969 return String(ctor);
970 }"#;
971
972 let result = exec.execute_code(code, dispatcher).await.unwrap();
973 assert_eq!(result, "undefined");
974 }
975
976 #[tokio::test]
977 async fn large_output_is_rejected() {
978 let exec = SandboxExecutor::new(SandboxConfig {
979 max_output_size: 100,
980 ..Default::default()
981 });
982 let manifest = serde_json::json!({});
983
984 let code = r#"async () => {
985 return "x".repeat(1000);
986 }"#;
987
988 let err = exec.execute_search(code, &manifest).await.unwrap_err();
989 assert!(
990 matches!(err, SandboxError::OutputTooLarge { .. }),
991 "expected output too large, got: {err:?}"
992 );
993 }
994}