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, AuditingResourceDispatcher,
19 AuditingStashDispatcher, NoopAuditLogger, ResourceReadAudit, StashOperationAudit,
20 ToolCallAudit,
21};
22use crate::error::SandboxError;
23use crate::ops::{
24 forge_ext, CurrentGroup, ExecutionResult, KnownServers, KnownTools, MaxResourceSize,
25 ToolCallLimits,
26};
27use crate::validator::validate_code;
28use crate::{ResourceDispatcher, StashDispatcher, ToolDispatcher};
29
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum ExecutionMode {
34 #[default]
36 InProcess,
37 ChildProcess,
39}
40
41#[derive(Debug, Clone)]
43pub struct SandboxConfig {
44 pub timeout: Duration,
46 pub max_code_size: usize,
48 pub max_output_size: usize,
50 pub max_heap_size: usize,
52 pub max_concurrent: usize,
54 pub max_tool_calls: usize,
56 pub max_tool_call_args_size: usize,
58 pub execution_mode: ExecutionMode,
60 pub max_resource_size: usize,
62 pub max_parallel: usize,
64 pub max_ipc_message_size: usize,
66}
67
68impl Default for SandboxConfig {
69 fn default() -> Self {
70 Self {
71 timeout: Duration::from_secs(5),
72 max_code_size: 64 * 1024, max_output_size: 1024 * 1024, max_heap_size: 64 * 1024 * 1024, max_concurrent: 8,
76 max_tool_calls: 50,
77 max_tool_call_args_size: 1024 * 1024, execution_mode: ExecutionMode::default(),
79 max_resource_size: 64 * 1024 * 1024, max_parallel: 8,
81 max_ipc_message_size: crate::ipc::DEFAULT_MAX_IPC_MESSAGE_SIZE,
82 }
83 }
84}
85
86pub struct SandboxExecutor {
92 config: SandboxConfig,
93 semaphore: Arc<Semaphore>,
94 audit_logger: Arc<dyn AuditLogger>,
95 pool: Option<Arc<crate::pool::WorkerPool>>,
97}
98
99impl SandboxExecutor {
100 pub fn new(config: SandboxConfig) -> Self {
102 let semaphore = Arc::new(Semaphore::new(config.max_concurrent));
103 Self {
104 config,
105 semaphore,
106 audit_logger: Arc::new(NoopAuditLogger),
107 pool: None,
108 }
109 }
110
111 pub fn with_audit_logger(config: SandboxConfig, logger: Arc<dyn AuditLogger>) -> Self {
113 let semaphore = Arc::new(Semaphore::new(config.max_concurrent));
114 Self {
115 config,
116 semaphore,
117 audit_logger: logger,
118 pool: None,
119 }
120 }
121
122 pub fn with_pool(mut self, pool: Arc<crate::pool::WorkerPool>) -> Self {
127 self.pool = Some(pool);
128 self
129 }
130
131 #[tracing::instrument(skip(self, code, manifest), fields(code_len = code.len()))]
137 pub async fn execute_search(
138 &self,
139 code: &str,
140 manifest: &Value,
141 ) -> Result<Value, SandboxError> {
142 tracing::info!("execute_search: starting");
143
144 let audit_builder = AuditEntryBuilder::new(code, AuditOperation::Search);
145
146 validate_code(code, Some(self.config.max_code_size))?;
147
148 let _permit = self.semaphore.clone().try_acquire_owned().map_err(|_| {
149 SandboxError::ConcurrencyLimit {
150 max: self.config.max_concurrent,
151 }
152 })?;
153
154 let code = code.to_string();
155 let manifest = manifest.clone();
156 let config = self.config.clone();
157
158 let (tx, rx) = tokio::sync::oneshot::channel();
160 std::thread::spawn(move || {
161 let rt = match tokio::runtime::Builder::new_current_thread()
162 .enable_all()
163 .build()
164 {
165 Ok(rt) => rt,
166 Err(e) => {
167 if tx.send(Err(SandboxError::Execution(e.into()))).is_err() {
168 tracing::warn!("sandbox result receiver dropped");
169 }
170 return;
171 }
172 };
173 let result = rt.block_on(run_search(&config, &code, &manifest));
174 if tx.send(result).is_err() {
175 tracing::warn!("sandbox result receiver dropped before result was sent");
176 }
177 });
178
179 let result = rx
180 .await
181 .map_err(|_| SandboxError::Execution(anyhow::anyhow!("sandbox thread panicked")))?;
182
183 let entry = audit_builder.finish(&result);
185 self.audit_logger.log(&entry).await;
186
187 match &result {
188 Ok(_) => tracing::info!("execute_search: complete"),
189 Err(e) => tracing::warn!(error = %e, "execute_search: failed"),
190 }
191
192 result
193 }
194
195 pub async fn execute_code(
205 &self,
206 code: &str,
207 dispatcher: Arc<dyn ToolDispatcher>,
208 resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
209 stash_dispatcher: Option<Arc<dyn StashDispatcher>>,
210 ) -> Result<Value, SandboxError> {
211 self.execute_code_with_options(
212 code,
213 dispatcher,
214 resource_dispatcher,
215 stash_dispatcher,
216 None,
217 None,
218 )
219 .await
220 }
221
222 #[tracing::instrument(skip(self, code, dispatcher, resource_dispatcher, stash_dispatcher, known_servers, known_tools), fields(code_len = code.len(), mode = ?self.config.execution_mode))]
225 pub async fn execute_code_with_options(
226 &self,
227 code: &str,
228 dispatcher: Arc<dyn ToolDispatcher>,
229 resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
230 stash_dispatcher: Option<Arc<dyn StashDispatcher>>,
231 known_servers: Option<std::collections::HashSet<String>>,
232 known_tools: Option<Vec<(String, String)>>,
233 ) -> Result<Value, SandboxError> {
234 tracing::info!("execute_code: starting");
235
236 let mut audit_builder = AuditEntryBuilder::new(code, AuditOperation::Execute);
237
238 validate_code(code, Some(self.config.max_code_size))?;
239
240 let _permit = self.semaphore.clone().try_acquire_owned().map_err(|_| {
241 SandboxError::ConcurrencyLimit {
242 max: self.config.max_concurrent,
243 }
244 })?;
245
246 let (audit_tx, mut audit_rx) = tokio::sync::mpsc::unbounded_channel::<ToolCallAudit>();
248 let auditing_dispatcher: Arc<dyn ToolDispatcher> =
249 Arc::new(AuditingDispatcher::new(dispatcher, audit_tx));
250
251 let (resource_audit_tx, mut resource_audit_rx) =
253 tokio::sync::mpsc::unbounded_channel::<ResourceReadAudit>();
254 let auditing_resource_dispatcher = resource_dispatcher.map(|rd| {
255 Arc::new(AuditingResourceDispatcher::new(rd, resource_audit_tx))
256 as Arc<dyn ResourceDispatcher>
257 });
258
259 let (stash_audit_tx, mut stash_audit_rx) =
261 tokio::sync::mpsc::unbounded_channel::<StashOperationAudit>();
262 let auditing_stash_dispatcher = stash_dispatcher.map(|sd| {
263 Arc::new(AuditingStashDispatcher::new(sd, stash_audit_tx)) as Arc<dyn StashDispatcher>
264 });
265
266 let result = match self.config.execution_mode {
267 ExecutionMode::ChildProcess => {
268 if let Some(ref pool) = self.pool {
269 match pool.acquire(&self.config).await {
271 Ok(mut worker) => {
272 let exec_result = worker
273 .execute(
274 code,
275 &self.config,
276 auditing_dispatcher,
277 auditing_resource_dispatcher,
278 auditing_stash_dispatcher,
279 )
280 .await;
281 let outcome = if is_fatal_sandbox_error(&exec_result) {
282 crate::pool::ReleaseOutcome::Fatal
283 } else {
284 crate::pool::ReleaseOutcome::Ok
285 };
286 pool.release(worker, outcome).await;
287 exec_result
288 }
289 Err(e) => {
290 tracing::warn!(error = %e, "pool acquire failed, falling back to fresh process");
291 crate::host::SandboxHost::execute_in_child(
292 code,
293 &self.config,
294 auditing_dispatcher,
295 auditing_resource_dispatcher,
296 auditing_stash_dispatcher,
297 known_servers,
298 known_tools,
299 )
300 .await
301 }
302 }
303 } else {
304 crate::host::SandboxHost::execute_in_child(
306 code,
307 &self.config,
308 auditing_dispatcher,
309 auditing_resource_dispatcher,
310 auditing_stash_dispatcher,
311 known_servers,
312 known_tools,
313 )
314 .await
315 }
316 }
317 ExecutionMode::InProcess => {
318 self.execute_code_in_process(
319 code,
320 auditing_dispatcher,
321 auditing_resource_dispatcher,
322 auditing_stash_dispatcher,
323 known_servers,
324 known_tools,
325 )
326 .await
327 }
328 };
329
330 while let Ok(tool_audit) = audit_rx.try_recv() {
332 audit_builder.record_tool_call(tool_audit);
333 }
334
335 while let Ok(resource_audit) = resource_audit_rx.try_recv() {
337 audit_builder.record_resource_read(resource_audit);
338 }
339
340 while let Ok(stash_audit) = stash_audit_rx.try_recv() {
342 audit_builder.record_stash_op(stash_audit);
343 }
344
345 let entry = audit_builder.finish(&result);
347 self.audit_logger.log(&entry).await;
348
349 match &result {
350 Ok(_) => tracing::info!("execute_code: complete"),
351 Err(e) => tracing::warn!(error = %e, "execute_code: failed"),
352 }
353
354 result
355 }
356
357 async fn execute_code_in_process(
359 &self,
360 code: &str,
361 dispatcher: Arc<dyn ToolDispatcher>,
362 resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
363 stash_dispatcher: Option<Arc<dyn StashDispatcher>>,
364 known_servers: Option<std::collections::HashSet<String>>,
365 known_tools: Option<Vec<(String, String)>>,
366 ) -> Result<Value, SandboxError> {
367 let code = code.to_string();
368 let config = self.config.clone();
369
370 let (tx, rx) = tokio::sync::oneshot::channel();
371 std::thread::spawn(move || {
372 let rt = match tokio::runtime::Builder::new_current_thread()
373 .enable_all()
374 .build()
375 {
376 Ok(rt) => rt,
377 Err(e) => {
378 if tx.send(Err(SandboxError::Execution(e.into()))).is_err() {
379 tracing::warn!("sandbox result receiver dropped");
380 }
381 return;
382 }
383 };
384 let result = rt.block_on(run_execute_with_known_servers(
385 &config,
386 &code,
387 dispatcher,
388 resource_dispatcher,
389 stash_dispatcher,
390 known_servers,
391 known_tools,
392 ));
393 if tx.send(result).is_err() {
394 tracing::warn!("sandbox result receiver dropped before result was sent");
395 }
396 });
397
398 rx.await
399 .map_err(|_| SandboxError::Execution(anyhow::anyhow!("sandbox thread panicked")))?
400 }
401}
402
403fn is_fatal_sandbox_error(result: &Result<Value, SandboxError>) -> bool {
410 matches!(
411 result,
412 Err(SandboxError::Timeout { .. }) | Err(SandboxError::HeapLimitExceeded)
413 )
414}
415
416struct HeapLimitState {
418 handle: v8::IsolateHandle,
419 triggered: AtomicBool,
422}
423
424extern "C" fn near_heap_limit_callback(
427 data: *mut std::ffi::c_void,
428 current_heap_limit: usize,
429 _initial_heap_limit: usize,
430) -> usize {
431 let state = unsafe { &*(data as *const HeapLimitState) };
438 if !state.triggered.swap(true, Ordering::SeqCst) {
439 state.handle.terminate_execution();
440 }
441 current_heap_limit + 1024 * 1024
443}
444
445pub async fn run_search(
450 config: &SandboxConfig,
451 code: &str,
452 manifest: &Value,
453) -> Result<Value, SandboxError> {
454 let mut runtime = create_runtime(
455 None,
456 None,
457 config.max_heap_size,
458 None,
459 None,
460 None,
461 None,
462 None,
463 )?;
464
465 let manifest_json = serde_json::to_string(manifest)?;
467 let bootstrap = format!("globalThis.manifest = {};", manifest_json);
468 runtime
469 .execute_script("[forge:manifest]", bootstrap)
470 .map_err(|e| SandboxError::JsError {
471 message: e.to_string(),
472 })?;
473
474 runtime
477 .execute_script(
478 "[forge:bootstrap]",
479 r#"
480 ((ops) => {
481 const setResult = (json) => ops.op_forge_set_result(json);
482 const log = (msg) => ops.op_forge_log(String(msg));
483 globalThis.forge = Object.freeze({
484 __setResult: setResult,
485 log: log,
486 });
487 delete globalThis.Deno;
488
489 // Remove code generation primitives to prevent prototype chain attacks.
490 // Even with the validator banning eval( and Function(, an attacker could
491 // reach Function via forge.log.constructor or similar prototype chain access.
492 delete globalThis.eval;
493 const AsyncFunction = (async function(){}).constructor;
494 const GeneratorFunction = (function*(){}).constructor;
495 Object.defineProperty(Function.prototype, 'constructor', {
496 value: undefined, configurable: false, writable: false
497 });
498 Object.defineProperty(AsyncFunction.prototype, 'constructor', {
499 value: undefined, configurable: false, writable: false
500 });
501 Object.defineProperty(GeneratorFunction.prototype, 'constructor', {
502 value: undefined, configurable: false, writable: false
503 });
504 })(Deno.core.ops);
505 "#,
506 )
507 .map_err(|e| SandboxError::JsError {
508 message: e.to_string(),
509 })?;
510
511 run_user_code(&mut runtime, code, config).await
512}
513
514pub async fn run_execute(
518 config: &SandboxConfig,
519 code: &str,
520 dispatcher: Arc<dyn ToolDispatcher>,
521 resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
522 stash_dispatcher: Option<Arc<dyn StashDispatcher>>,
523) -> Result<Value, SandboxError> {
524 run_execute_with_known_servers(
525 config,
526 code,
527 dispatcher,
528 resource_dispatcher,
529 stash_dispatcher,
530 None,
531 None,
532 )
533 .await
534}
535
536pub async fn run_execute_with_known_servers(
539 config: &SandboxConfig,
540 code: &str,
541 dispatcher: Arc<dyn ToolDispatcher>,
542 resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
543 stash_dispatcher: Option<Arc<dyn StashDispatcher>>,
544 known_servers: Option<std::collections::HashSet<String>>,
545 known_tools: Option<Vec<(String, String)>>,
546) -> Result<Value, SandboxError> {
547 let limits = ToolCallLimits {
548 max_calls: config.max_tool_calls,
549 max_args_size: config.max_tool_call_args_size,
550 calls_made: 0,
551 };
552 let mut runtime = create_runtime(
553 Some(dispatcher),
554 resource_dispatcher.clone(),
555 config.max_heap_size,
556 Some(limits),
557 Some(config.max_resource_size),
558 stash_dispatcher.clone(),
559 known_servers,
560 known_tools,
561 )?;
562
563 let has_resource_dispatcher = resource_dispatcher.is_some();
565 let has_stash_dispatcher = stash_dispatcher.is_some();
566
567 let bootstrap = build_execute_bootstrap(
572 has_resource_dispatcher,
573 has_stash_dispatcher,
574 config.max_parallel,
575 );
576
577 runtime
578 .execute_script("[forge:bootstrap]", bootstrap)
579 .map_err(|e| SandboxError::JsError {
580 message: e.to_string(),
581 })?;
582
583 run_user_code(&mut runtime, code, config).await
584}
585
586fn build_execute_bootstrap(has_resource: bool, has_stash: bool, max_parallel: usize) -> String {
591 let mut parts = Vec::new();
592
593 parts.push(format!(
595 r#"((ops) => {{
596 const callToolOp = ops.op_forge_call_tool;
597 const setResult = (json) => ops.op_forge_set_result(json);
598 const log = (msg) => ops.op_forge_log(String(msg));
599 const __MAX_PARALLEL = Object.freeze({max_parallel});
600
601 const callTool = async (server, tool, args) => {{
602 const resultJson = await callToolOp(
603 server, tool, JSON.stringify(args || {{}})
604 );
605 return JSON.parse(resultJson);
606 }};"#
607 ));
608
609 if has_resource {
611 parts.push(
612 r#"
613 const readResourceOp = ops.op_forge_read_resource;
614 const readResource = async (server, uri) => {
615 const resultJson = await readResourceOp(server, uri);
616 return JSON.parse(resultJson);
617 };"#
618 .to_string(),
619 );
620 }
621
622 if has_stash {
624 parts.push(
625 r#"
626 const stashPutOp = ops.op_forge_stash_put;
627 const stashGetOp = ops.op_forge_stash_get;
628 const stashDeleteOp = ops.op_forge_stash_delete;
629 const stashKeysOp = ops.op_forge_stash_keys;"#
630 .to_string(),
631 );
632 }
633
634 let mut forge_props = vec![
636 " __setResult: setResult".to_string(),
637 " log: log".to_string(),
638 " callTool: callTool".to_string(),
639 ];
640
641 if has_resource {
642 forge_props.push(" readResource: readResource".to_string());
643 }
644
645 if has_stash {
646 forge_props.push(
647 r#" stash: Object.freeze({
648 put: async (key, value, opts) => {
649 const ttl = (opts && opts.ttl) ? opts.ttl : 0;
650 const resultJson = await stashPutOp(key, JSON.stringify(value), ttl);
651 return JSON.parse(resultJson);
652 },
653 get: async (key) => {
654 const resultJson = await stashGetOp(key);
655 return JSON.parse(resultJson);
656 },
657 delete: async (key) => {
658 const resultJson = await stashDeleteOp(key);
659 return JSON.parse(resultJson);
660 },
661 keys: async () => {
662 const resultJson = await stashKeysOp();
663 return JSON.parse(resultJson);
664 }
665 })"#
666 .to_string(),
667 );
668 }
669
670 forge_props.push(
672 r#" server: (name) => {
673 return new Proxy({}, {
674 get(_target, category) {
675 return new Proxy({}, {
676 get(_target2, tool) {
677 return async (args) => {
678 const toolName = category === 'general' ? tool : `${category}.${tool}`;
679 return callTool(
680 name,
681 toolName,
682 args || {}
683 );
684 };
685 }
686 });
687 }
688 });
689 }"#
690 .to_string(),
691 );
692
693 forge_props.push(
695 r#" parallel: async (calls, opts) => {
696 opts = opts || {};
697 const concurrency = Math.min(
698 opts.concurrency || __MAX_PARALLEL,
699 __MAX_PARALLEL
700 );
701 const failFast = opts.failFast || false;
702 const results = new Array(calls.length).fill(null);
703 const errors = [];
704 let aborted = false;
705
706 for (let i = 0; i < calls.length && !aborted; i += concurrency) {
707 const batch = calls.slice(i, i + concurrency);
708 await Promise.allSettled(
709 batch.map((item, idx) => {
710 const fn_ = typeof item === 'function' ? item : item.fn;
711 return fn_().then(
712 val => {
713 if (val && val.error === true && val.code) {
714 errors.push({ index: i + idx, error: val.message || val.code });
715 } else {
716 results[i + idx] = val;
717 }
718 if (errors.length > 0 && failFast) aborted = true;
719 },
720 err => {
721 errors.push({ index: i + idx, error: err.message || String(err) });
722 if (failFast) aborted = true;
723 }
724 );
725 })
726 );
727 }
728
729 return { results, errors, aborted };
730 }"#
731 .to_string(),
732 );
733
734 let forge_obj = format!(
735 r#"
736 globalThis.forge = Object.freeze({{
737{}
738 }});"#,
739 forge_props.join(",\n")
740 );
741 parts.push(forge_obj);
742
743 parts.push(
745 r#"
746 delete globalThis.Deno;
747
748 // Remove code generation primitives to prevent prototype chain attacks.
749 delete globalThis.eval;
750 const AsyncFunction = (async function(){}).constructor;
751 const GeneratorFunction = (function*(){}).constructor;
752 Object.defineProperty(Function.prototype, 'constructor', {
753 value: undefined, configurable: false, writable: false
754 });
755 Object.defineProperty(AsyncFunction.prototype, 'constructor', {
756 value: undefined, configurable: false, writable: false
757 });
758 Object.defineProperty(GeneratorFunction.prototype, 'constructor', {
759 value: undefined, configurable: false, writable: false
760 });
761 })(Deno.core.ops);"#
762 .to_string(),
763 );
764
765 parts.join("\n")
766}
767
768#[allow(clippy::too_many_arguments)]
770pub(crate) fn create_runtime(
771 dispatcher: Option<Arc<dyn ToolDispatcher>>,
772 resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
773 max_heap_size: usize,
774 tool_call_limits: Option<ToolCallLimits>,
775 max_resource_size: Option<usize>,
776 stash_dispatcher: Option<Arc<dyn StashDispatcher>>,
777 known_servers: Option<std::collections::HashSet<String>>,
778 known_tools: Option<Vec<(String, String)>>,
779) -> Result<JsRuntime, SandboxError> {
780 let create_params = v8::CreateParams::default().heap_limits(0, max_heap_size);
781
782 let runtime = JsRuntime::new(RuntimeOptions {
783 extensions: vec![forge_ext::init()],
784 create_params: Some(create_params),
785 ..Default::default()
786 });
787
788 if let Some(d) = dispatcher {
789 runtime.op_state().borrow_mut().put(d);
790 }
791 if let Some(rd) = resource_dispatcher {
792 runtime.op_state().borrow_mut().put(rd);
793 }
794 if let Some(limits) = tool_call_limits {
795 runtime.op_state().borrow_mut().put(limits);
796 }
797 if let Some(size) = max_resource_size {
798 runtime.op_state().borrow_mut().put(MaxResourceSize(size));
799 }
800 if let Some(sd) = stash_dispatcher {
801 runtime.op_state().borrow_mut().put(sd);
802 runtime.op_state().borrow_mut().put(CurrentGroup(None));
804 }
805 if let Some(servers) = known_servers {
806 runtime.op_state().borrow_mut().put(KnownServers(servers));
807 }
808 if let Some(tools) = known_tools {
809 runtime.op_state().borrow_mut().put(KnownTools(tools));
810 }
811
812 Ok(runtime)
813}
814
815async fn run_user_code(
822 runtime: &mut JsRuntime,
823 code: &str,
824 config: &SandboxConfig,
825) -> Result<Value, SandboxError> {
826 let heap_state = Box::new(HeapLimitState {
828 handle: runtime.v8_isolate().thread_safe_handle(),
829 triggered: AtomicBool::new(false),
830 });
831 runtime.v8_isolate().add_near_heap_limit_callback(
832 near_heap_limit_callback,
833 &*heap_state as *const HeapLimitState as *mut std::ffi::c_void,
834 );
835
836 let watchdog_handle = runtime.v8_isolate().thread_safe_handle();
838 let timed_out = Arc::new(AtomicBool::new(false));
839 let watchdog_timed_out = timed_out.clone();
840 let timeout = config.timeout;
841 let (cancel_tx, cancel_rx) = std::sync::mpsc::channel::<()>();
842
843 let watchdog = std::thread::spawn(move || {
844 if let Err(std::sync::mpsc::RecvTimeoutError::Timeout) = cancel_rx.recv_timeout(timeout) {
845 watchdog_timed_out.store(true, Ordering::SeqCst);
846 watchdog_handle.terminate_execution();
847 }
848 });
849
850 let wrapped = format!(
852 r#"
853 (async () => {{
854 try {{
855 const __userFn = {code};
856 const __result = await __userFn();
857 forge.__setResult(
858 JSON.stringify({{ ok: __result }})
859 );
860 }} catch (e) {{
861 forge.__setResult(
862 JSON.stringify({{ error: e.message || String(e) }})
863 );
864 }}
865 }})();
866 "#
867 );
868
869 let exec_error = match runtime.execute_script("[forge:execute]", wrapped) {
870 Ok(_) => {
871 match tokio::time::timeout(
873 config.timeout,
874 runtime.run_event_loop(PollEventLoopOptions::default()),
875 )
876 .await
877 {
878 Ok(Ok(())) => None,
879 Ok(Err(e)) => Some(e.to_string()),
880 Err(_) => Some("async timeout".to_string()),
881 }
882 }
883 Err(e) => Some(e.to_string()),
884 };
885
886 let _ = cancel_tx.send(());
890 let _ = watchdog.join();
891
892 if heap_state.triggered.load(Ordering::SeqCst) {
894 return Err(SandboxError::HeapLimitExceeded);
895 }
896
897 if timed_out.load(Ordering::SeqCst) {
898 return Err(SandboxError::Timeout {
899 timeout_ms: config.timeout.as_millis() as u64,
900 });
901 }
902
903 if let Some(err_msg) = exec_error {
904 return Err(SandboxError::JsError { message: err_msg });
905 }
906
907 let result_str = {
909 let state = runtime.op_state();
910 let state = state.borrow();
911 state
912 .try_borrow::<ExecutionResult>()
913 .map(|r| r.0.clone())
914 .ok_or_else(|| SandboxError::JsError {
915 message: "no result returned from sandbox execution".into(),
916 })?
917 };
918
919 if result_str.len() > config.max_output_size {
920 return Err(SandboxError::OutputTooLarge {
921 max: config.max_output_size,
922 });
923 }
924
925 let envelope: Value = serde_json::from_str(&result_str)?;
926
927 if let Some(error) = envelope.get("error") {
928 return Err(SandboxError::JsError {
929 message: error.as_str().unwrap_or("unknown error").to_string(),
930 });
931 }
932
933 Ok(envelope.get("ok").cloned().unwrap_or(Value::Null))
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939
940 fn executor() -> SandboxExecutor {
941 SandboxExecutor::new(SandboxConfig::default())
942 }
943
944 struct TestDispatcher;
946
947 #[async_trait::async_trait]
948 impl ToolDispatcher for TestDispatcher {
949 async fn call_tool(
950 &self,
951 server: &str,
952 tool: &str,
953 args: serde_json::Value,
954 ) -> Result<serde_json::Value, forge_error::DispatchError> {
955 Ok(serde_json::json!({
956 "server": server,
957 "tool": tool,
958 "args": args,
959 "status": "ok"
960 }))
961 }
962 }
963
964 #[tokio::test]
965 async fn search_returns_manifest_data() {
966 let exec = executor();
967 let manifest = serde_json::json!({
968 "tools": [
969 {"name": "parse_ast", "category": "ast"},
970 {"name": "find_symbols", "category": "symbols"},
971 ]
972 });
973
974 let code = r#"async () => {
975 return manifest.tools.filter(t => t.category === "ast");
976 }"#;
977
978 let result = exec.execute_search(code, &manifest).await.unwrap();
979 let tools = result.as_array().unwrap();
980 assert_eq!(tools.len(), 1);
981 assert_eq!(tools[0]["name"], "parse_ast");
982 }
983
984 #[tokio::test]
985 async fn search_handles_complex_queries() {
986 let exec = executor();
987 let manifest = serde_json::json!({
988 "servers": [
989 {
990 "name": "narsil",
991 "categories": {
992 "ast": { "tools": ["parse", "query", "walk"] },
993 "symbols": { "tools": ["find", "references"] }
994 }
995 }
996 ]
997 });
998
999 let code = r#"async () => {
1000 return manifest.servers
1001 .map(s => ({ name: s.name, categories: Object.keys(s.categories) }));
1002 }"#;
1003
1004 let result = exec.execute_search(code, &manifest).await.unwrap();
1005 let servers = result.as_array().unwrap();
1006 assert_eq!(servers[0]["name"], "narsil");
1007 }
1008
1009 #[tokio::test]
1010 async fn timeout_is_enforced() {
1011 let exec = SandboxExecutor::new(SandboxConfig {
1012 timeout: Duration::from_millis(200),
1013 ..Default::default()
1014 });
1015 let manifest = serde_json::json!({});
1016
1017 let code = r#"async () => {
1019 await new Promise(() => {});
1020 }"#;
1021
1022 let start = std::time::Instant::now();
1023 let err = exec.execute_search(code, &manifest).await.unwrap_err();
1024 let elapsed = start.elapsed();
1025
1026 match &err {
1029 SandboxError::Timeout { .. } => {}
1030 SandboxError::JsError { message } if message.contains("no result") => {
1031 }
1034 other => panic!("unexpected error: {other:?}, elapsed: {elapsed:?}"),
1035 }
1036 }
1037
1038 #[tokio::test]
1039 async fn js_errors_are_captured() {
1040 let exec = executor();
1041 let manifest = serde_json::json!({});
1042
1043 let code = r#"async () => {
1044 throw new Error("intentional test error");
1045 }"#;
1046
1047 let err = exec.execute_search(code, &manifest).await.unwrap_err();
1048 assert!(matches!(err, SandboxError::JsError { .. }));
1049 let msg = err.to_string();
1050 assert!(msg.contains("intentional test error"));
1051 }
1052
1053 #[tokio::test]
1054 async fn no_filesystem_access() {
1055 let exec = executor();
1056 let manifest = serde_json::json!({});
1057
1058 let code = r#"async () => {
1060 const fs = require("fs");
1061 return "ESCAPED";
1062 }"#;
1063
1064 let err = exec.execute_search(code, &manifest).await;
1065 assert!(err.is_err());
1066 }
1067
1068 #[tokio::test]
1069 async fn no_network_access() {
1070 let exec = executor();
1071 let manifest = serde_json::json!({});
1072
1073 let code = r#"async () => {
1074 try {
1075 await fetch("https://example.com");
1076 return "ESCAPED";
1077 } catch(e) {
1078 return "CONTAINED";
1079 }
1080 }"#;
1081
1082 let result = exec.execute_search(code, &manifest).await.unwrap();
1083 assert_eq!(result, "CONTAINED");
1084 }
1085
1086 #[tokio::test]
1089 async fn cpu_bound_infinite_loop_is_terminated() {
1090 let exec = SandboxExecutor::new(SandboxConfig {
1091 timeout: Duration::from_millis(500),
1092 ..Default::default()
1093 });
1094 let manifest = serde_json::json!({});
1095
1096 let code = r#"async () => {
1097 while(true) {}
1098 }"#;
1099
1100 let start = std::time::Instant::now();
1101 let err = exec.execute_search(code, &manifest).await.unwrap_err();
1102 let elapsed = start.elapsed();
1103
1104 assert!(
1105 matches!(err, SandboxError::Timeout { .. }),
1106 "expected timeout, got: {err:?}"
1107 );
1108 assert!(
1109 elapsed < Duration::from_secs(5),
1110 "should complete reasonably fast, took: {elapsed:?}"
1111 );
1112 }
1113
1114 #[tokio::test]
1115 async fn heap_limit_prevents_oom() {
1116 let exec = SandboxExecutor::new(SandboxConfig {
1117 max_heap_size: 10 * 1024 * 1024, timeout: Duration::from_secs(30), ..Default::default()
1120 });
1121 let manifest = serde_json::json!({});
1122
1123 let code = r#"async () => {
1125 const arr = [];
1126 while(true) {
1127 arr.push(new Array(100000).fill("x"));
1128 }
1129 }"#;
1130
1131 let err = exec.execute_search(code, &manifest).await.unwrap_err();
1132 assert!(
1133 matches!(
1134 err,
1135 SandboxError::HeapLimitExceeded | SandboxError::JsError { .. }
1136 ),
1137 "expected heap limit or JS error, got: {err:?}"
1138 );
1139 }
1140
1141 #[tokio::test]
1142 async fn concurrency_limit_enforced() {
1143 let exec = SandboxExecutor::new(SandboxConfig {
1145 max_concurrent: 0,
1146 ..Default::default()
1147 });
1148
1149 let code = r#"async () => { return 1; }"#;
1150 let err = exec
1151 .execute_search(code, &serde_json::json!({}))
1152 .await
1153 .unwrap_err();
1154 assert!(
1155 matches!(err, SandboxError::ConcurrencyLimit { max: 0 }),
1156 "expected concurrency limit, got: {err:?}"
1157 );
1158 }
1159
1160 #[tokio::test]
1161 async fn deno_global_is_not_accessible() {
1162 let exec = executor();
1163 let manifest = serde_json::json!({});
1164
1165 let code = r#"async () => {
1166 const props = Object.getOwnPropertyNames(globalThis);
1167 return !props.includes("Deno");
1168 }"#;
1169
1170 let result = exec.execute_search(code, &manifest).await.unwrap();
1171 assert_eq!(result, true);
1172 }
1173
1174 #[tokio::test]
1175 async fn forge_object_is_frozen() {
1176 let exec = executor();
1177 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1178
1179 let code = r#"async () => {
1180 return Object.isFrozen(forge);
1181 }"#;
1182
1183 let result = exec
1184 .execute_code(code, dispatcher, None, None)
1185 .await
1186 .unwrap();
1187 assert_eq!(result, true);
1188 }
1189
1190 #[tokio::test]
1191 async fn tool_call_rate_limit() {
1192 let exec = SandboxExecutor::new(SandboxConfig {
1193 max_tool_calls: 2,
1194 ..Default::default()
1195 });
1196 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1197
1198 let code = r#"async () => {
1199 await forge.callTool("test", "tool1", {});
1200 await forge.callTool("test", "tool2", {});
1201 try {
1202 await forge.callTool("test", "tool3", {});
1203 return "should not reach here";
1204 } catch(e) {
1205 return e.message;
1206 }
1207 }"#;
1208
1209 let result = exec
1210 .execute_code(code, dispatcher, None, None)
1211 .await
1212 .unwrap();
1213 assert!(
1214 result
1215 .as_str()
1216 .unwrap()
1217 .contains("tool call limit exceeded"),
1218 "expected tool call limit message, got: {result:?}"
1219 );
1220 }
1221
1222 #[tokio::test]
1223 async fn tool_call_args_size_limit() {
1224 let exec = SandboxExecutor::new(SandboxConfig {
1225 max_tool_call_args_size: 100,
1226 ..Default::default()
1227 });
1228 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1229
1230 let code = r#"async () => {
1231 try {
1232 await forge.callTool("test", "tool", { data: "x".repeat(200) });
1233 return "should not reach here";
1234 } catch(e) {
1235 return e.message;
1236 }
1237 }"#;
1238
1239 let result = exec
1240 .execute_code(code, dispatcher, None, None)
1241 .await
1242 .unwrap();
1243 assert!(
1244 result.as_str().unwrap().contains("too large"),
1245 "expected args too large message, got: {result:?}"
1246 );
1247 }
1248
1249 #[tokio::test]
1250 async fn forge_log_works() {
1251 let exec = executor();
1252 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1253
1254 let code = r#"async () => {
1255 forge.log("test message from sandbox");
1256 return "ok";
1257 }"#;
1258
1259 let result = exec
1260 .execute_code(code, dispatcher, None, None)
1261 .await
1262 .unwrap();
1263 assert_eq!(result, "ok");
1264 }
1265
1266 #[tokio::test]
1267 async fn forge_server_proxy_calls_tool() {
1268 let exec = executor();
1269 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1270
1271 let code = r#"async () => {
1272 const result = await forge.server("narsil").ast.parse({ file: "test.rs" });
1273 return result;
1274 }"#;
1275
1276 let result = exec
1277 .execute_code(code, dispatcher, None, None)
1278 .await
1279 .unwrap();
1280 assert_eq!(result["server"], "narsil");
1281 assert_eq!(result["tool"], "ast.parse");
1282 assert_eq!(result["status"], "ok");
1283 }
1284
1285 #[tokio::test]
1286 async fn forge_server_proxy_general_category_strips_prefix() {
1287 let exec = executor();
1288 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1289
1290 let code = r#"async () => {
1292 const result = await forge.server("narsil").general.find_symbols({ pattern: "main" });
1293 return result;
1294 }"#;
1295
1296 let result = exec
1297 .execute_code(code, dispatcher, None, None)
1298 .await
1299 .unwrap();
1300 assert_eq!(result["server"], "narsil");
1301 assert_eq!(result["tool"], "find_symbols");
1302 assert_eq!(result["status"], "ok");
1303 }
1304
1305 #[tokio::test]
1306 async fn multiple_tool_calls_in_single_execution() {
1307 let exec = executor();
1308 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1309
1310 let code = r#"async () => {
1311 const r1 = await forge.callTool("server1", "tool1", {});
1312 const r2 = await forge.callTool("server2", "tool2", {});
1313 return [r1, r2];
1314 }"#;
1315
1316 let result = exec
1317 .execute_code(code, dispatcher, None, None)
1318 .await
1319 .unwrap();
1320 let arr = result.as_array().unwrap();
1321 assert_eq!(arr.len(), 2);
1322 assert_eq!(arr[0]["server"], "server1");
1323 assert_eq!(arr[1]["server"], "server2");
1324 }
1325
1326 #[tokio::test]
1327 async fn eval_is_not_accessible() {
1328 let exec = executor();
1329 let manifest = serde_json::json!({});
1330
1331 let code = r#"async () => {
1332 return typeof globalThis.eval;
1333 }"#;
1334
1335 let result = exec.execute_search(code, &manifest).await.unwrap();
1336 assert_eq!(result, "undefined");
1337 }
1338
1339 #[tokio::test]
1340 async fn function_constructor_is_blocked() {
1341 let exec = executor();
1342 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1343
1344 let code = r#"async () => {
1346 const ctor = forge.log.constructor;
1347 return String(ctor);
1348 }"#;
1349
1350 let result = exec
1351 .execute_code(code, dispatcher, None, None)
1352 .await
1353 .unwrap();
1354 assert_eq!(result, "undefined");
1355 }
1356
1357 #[tokio::test]
1358 async fn async_function_constructor_is_blocked() {
1359 let exec = executor();
1360 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1361
1362 let code = r#"async () => {
1364 const fn1 = async () => {};
1365 const ctor = fn1.constructor;
1366 return String(ctor);
1367 }"#;
1368
1369 let result = exec
1370 .execute_code(code, dispatcher, None, None)
1371 .await
1372 .unwrap();
1373 assert_eq!(result, "undefined");
1374 }
1375
1376 struct TestResourceDispatcher;
1380
1381 #[async_trait::async_trait]
1382 impl ResourceDispatcher for TestResourceDispatcher {
1383 async fn read_resource(
1384 &self,
1385 server: &str,
1386 uri: &str,
1387 ) -> Result<serde_json::Value, forge_error::DispatchError> {
1388 Ok(serde_json::json!({
1389 "server": server,
1390 "uri": uri,
1391 "content": "test resource content"
1392 }))
1393 }
1394 }
1395
1396 struct LargeResourceDispatcher {
1398 content_size: usize,
1399 }
1400
1401 #[async_trait::async_trait]
1402 impl ResourceDispatcher for LargeResourceDispatcher {
1403 async fn read_resource(
1404 &self,
1405 _server: &str,
1406 _uri: &str,
1407 ) -> Result<serde_json::Value, forge_error::DispatchError> {
1408 Ok(serde_json::json!({
1409 "data": "x".repeat(self.content_size)
1410 }))
1411 }
1412 }
1413
1414 struct FailingResourceDispatcher {
1416 error_msg: String,
1417 }
1418
1419 #[async_trait::async_trait]
1420 impl ResourceDispatcher for FailingResourceDispatcher {
1421 async fn read_resource(
1422 &self,
1423 _server: &str,
1424 _uri: &str,
1425 ) -> Result<serde_json::Value, forge_error::DispatchError> {
1426 Err(anyhow::anyhow!("{}", self.error_msg).into())
1427 }
1428 }
1429
1430 #[tokio::test]
1432 async fn rs_u01_read_resource_routes_to_correct_server() {
1433 let exec = executor();
1434 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1435 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1436 Some(Arc::new(TestResourceDispatcher));
1437
1438 let code = r#"async () => {
1439 const result = await forge.readResource("my-server", "file:///logs/app.log");
1440 return result;
1441 }"#;
1442
1443 let result = exec
1444 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1445 .await
1446 .unwrap();
1447 assert_eq!(result["server"], "my-server");
1448 assert_eq!(result["uri"], "file:///logs/app.log");
1449 assert_eq!(result["content"], "test resource content");
1450 }
1451
1452 #[tokio::test]
1454 async fn rs_u02_read_resource_shares_rate_limit_with_tool_calls() {
1455 let exec = SandboxExecutor::new(SandboxConfig {
1456 max_tool_calls: 3,
1457 ..Default::default()
1458 });
1459 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1460 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1461 Some(Arc::new(TestResourceDispatcher));
1462
1463 let code = r#"async () => {
1465 await forge.callTool("s", "t", {});
1466 await forge.readResource("s", "file:///a");
1467 await forge.readResource("s", "file:///b");
1468 try {
1469 await forge.readResource("s", "file:///c");
1470 return "should not reach here";
1471 } catch(e) {
1472 return e.message;
1473 }
1474 }"#;
1475
1476 let result = exec
1477 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1478 .await
1479 .unwrap();
1480 assert!(
1481 result
1482 .as_str()
1483 .unwrap()
1484 .contains("tool call limit exceeded"),
1485 "expected rate limit message, got: {result:?}"
1486 );
1487 }
1488
1489 #[tokio::test]
1491 async fn rs_u03_read_resource_rejects_when_limits_exhausted() {
1492 let exec = SandboxExecutor::new(SandboxConfig {
1493 max_tool_calls: 1,
1494 ..Default::default()
1495 });
1496 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1497 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1498 Some(Arc::new(TestResourceDispatcher));
1499
1500 let code = r#"async () => {
1501 await forge.readResource("s", "file:///a");
1502 try {
1503 await forge.readResource("s", "file:///b");
1504 return "should not reach here";
1505 } catch(e) {
1506 return e.message;
1507 }
1508 }"#;
1509
1510 let result = exec
1511 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1512 .await
1513 .unwrap();
1514 assert!(
1515 result
1516 .as_str()
1517 .unwrap()
1518 .contains("tool call limit exceeded"),
1519 "expected rate limit error, got: {result:?}"
1520 );
1521 }
1522
1523 #[tokio::test]
1525 async fn rs_u08_read_resource_truncates_at_max_resource_size() {
1526 let exec = SandboxExecutor::new(SandboxConfig {
1527 max_resource_size: 100, ..Default::default()
1529 });
1530 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1531 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1532 Some(Arc::new(LargeResourceDispatcher { content_size: 500 }));
1533
1534 let code = r#"async () => {
1536 try {
1537 await forge.readResource("s", "file:///big");
1538 return "no truncation";
1539 } catch(e) {
1540 return "truncated";
1541 }
1542 }"#;
1543
1544 let result = exec
1545 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1546 .await
1547 .unwrap();
1548 assert_eq!(result, "truncated", "large resource should be truncated");
1549 }
1550
1551 #[tokio::test]
1553 async fn rs_u09_read_resource_redacts_errors() {
1554 let exec = executor();
1555 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1556 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1557 Some(Arc::new(FailingResourceDispatcher {
1558 error_msg: "connection refused: http://internal.corp:9876/secret/path".into(),
1559 }));
1560
1561 let code = r#"async () => {
1563 const result = await forge.readResource("my-server", "file:///logs/secret.log");
1564 return result;
1565 }"#;
1566
1567 let result = exec
1568 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1569 .await
1570 .unwrap();
1571 assert_eq!(
1572 result["error"], true,
1573 "should be structured error: {result}"
1574 );
1575 let msg = result["message"].as_str().unwrap();
1576 assert!(
1577 !msg.contains("internal.corp"),
1578 "should not leak internal URL: {msg}"
1579 );
1580 assert!(!msg.contains("9876"), "should not leak port: {msg}");
1581 assert!(
1582 msg.contains("my-server"),
1583 "should mention server name: {msg}"
1584 );
1585 }
1586
1587 #[tokio::test]
1589 async fn rs_u10_read_resource_handles_binary_content() {
1590 struct Base64ResourceDispatcher;
1591
1592 #[async_trait::async_trait]
1593 impl ResourceDispatcher for Base64ResourceDispatcher {
1594 async fn read_resource(
1595 &self,
1596 _server: &str,
1597 _uri: &str,
1598 ) -> Result<serde_json::Value, forge_error::DispatchError> {
1599 Ok(serde_json::json!({
1600 "content": "SGVsbG8gV29ybGQ=",
1601 "_encoding": "base64"
1602 }))
1603 }
1604 }
1605
1606 let exec = executor();
1607 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1608 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1609 Some(Arc::new(Base64ResourceDispatcher));
1610
1611 let code = r#"async () => {
1612 const result = await forge.readResource("s", "file:///binary");
1613 return result;
1614 }"#;
1615
1616 let result = exec
1617 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1618 .await
1619 .unwrap();
1620 assert_eq!(result["_encoding"], "base64");
1621 assert_eq!(result["content"], "SGVsbG8gV29ybGQ=");
1622 }
1623
1624 #[tokio::test]
1626 async fn rs_u11_read_resource_error_for_nonexistent() {
1627 let exec = executor();
1628 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1629 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1630 Some(Arc::new(FailingResourceDispatcher {
1631 error_msg: "resource not found".into(),
1632 }));
1633
1634 let code = r#"async () => {
1636 const result = await forge.readResource("s", "file:///nonexistent");
1637 return result;
1638 }"#;
1639
1640 let result = exec
1641 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1642 .await
1643 .unwrap();
1644 assert_eq!(
1645 result["error"], true,
1646 "should be structured error: {result}"
1647 );
1648 let msg = result["message"].as_str().unwrap();
1649 assert!(
1650 msg.contains("failed"),
1651 "should indicate failure: {result:?}"
1652 );
1653 }
1654
1655 #[tokio::test]
1657 async fn rs_u12_read_resource_handles_large_content() {
1658 let exec = SandboxExecutor::new(SandboxConfig {
1659 max_resource_size: 2 * 1024 * 1024, timeout: Duration::from_secs(10),
1661 ..Default::default()
1662 });
1663 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1664 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1665 Some(Arc::new(LargeResourceDispatcher {
1666 content_size: 1_100_000,
1667 }));
1668
1669 let code = r#"async () => {
1670 const result = await forge.readResource("s", "file:///large");
1671 return result.data.length;
1672 }"#;
1673
1674 let result = exec
1675 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1676 .await
1677 .unwrap();
1678 assert_eq!(result, 1_100_000);
1679 }
1680
1681 #[tokio::test]
1683 async fn rs_s05_error_on_invalid_resource_uri_for_server() {
1684 let exec = executor();
1685 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1686 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1687 Some(Arc::new(FailingResourceDispatcher {
1688 error_msg: "unknown resource URI: file:///etc/shadow".into(),
1689 }));
1690
1691 let code = r#"async () => {
1693 const result = await forge.readResource("postgres-server", "file:///etc/shadow");
1694 return result;
1695 }"#;
1696
1697 let result = exec
1698 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1699 .await
1700 .unwrap();
1701 assert_eq!(
1702 result["error"], true,
1703 "should be structured error: {result}"
1704 );
1705 let msg = result["message"].as_str().unwrap();
1706 assert!(
1708 !msg.contains("/etc/shadow"),
1709 "should not leak file path: {msg}"
1710 );
1711 assert!(
1713 msg.contains("postgres-server"),
1714 "should mention server: {msg}"
1715 );
1716 assert!(
1717 msg.contains("readResource"),
1718 "should use safe identifier: {msg}"
1719 );
1720 }
1721
1722 #[tokio::test]
1724 async fn rs_s06_error_message_does_not_leak_full_uri() {
1725 let exec = executor();
1726 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1727 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1728 Some(Arc::new(FailingResourceDispatcher {
1729 error_msg: "file not found: /var/secrets/database/credentials.json".into(),
1730 }));
1731
1732 let code = r#"async () => {
1733 try {
1734 await forge.readResource("server", "file:///var/secrets/database/credentials.json");
1735 return "should not reach here";
1736 } catch(e) {
1737 return e.message;
1738 }
1739 }"#;
1740
1741 let result = exec
1742 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1743 .await
1744 .unwrap();
1745 let msg = result.as_str().unwrap();
1746 assert!(!msg.contains("/var/secrets"), "should not leak path: {msg}");
1748 assert!(
1749 !msg.contains("credentials.json"),
1750 "should not leak filename: {msg}"
1751 );
1752 assert!(
1754 !msg.contains("file:///var/secrets"),
1755 "should not leak URI: {msg}"
1756 );
1757 }
1758
1759 #[tokio::test]
1761 async fn rs_s07_large_content_truncated_not_oom() {
1762 let exec = SandboxExecutor::new(SandboxConfig {
1763 max_resource_size: 1024, timeout: Duration::from_secs(10),
1765 ..Default::default()
1766 });
1767 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1768 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1769 Some(Arc::new(LargeResourceDispatcher {
1770 content_size: 1_000_000, }));
1772
1773 let code = r#"async () => {
1774 try {
1775 const result = await forge.readResource("s", "file:///huge");
1776 return "got result without truncation";
1777 } catch(e) {
1778 return "safely truncated";
1779 }
1780 }"#;
1781
1782 let result = exec
1784 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1785 .await;
1786 assert!(result.is_ok(), "should complete without OOM: {result:?}");
1787 assert_eq!(result.unwrap(), "safely truncated");
1788 }
1789
1790 #[tokio::test]
1792 async fn rs_s08_many_reads_hit_rate_limit() {
1793 let exec = SandboxExecutor::new(SandboxConfig {
1794 max_tool_calls: 5,
1795 ..Default::default()
1796 });
1797 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1798 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1799 Some(Arc::new(TestResourceDispatcher));
1800
1801 let code = r#"async () => {
1802 let count = 0;
1803 for (let i = 0; i < 1000; i++) {
1804 try {
1805 await forge.readResource("s", "file:///r" + i);
1806 count++;
1807 } catch(e) {
1808 return { count, error: e.message };
1809 }
1810 }
1811 return { count, error: null };
1812 }"#;
1813
1814 let result = exec
1815 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1816 .await
1817 .unwrap();
1818 assert_eq!(
1819 result["count"], 5,
1820 "should allow exactly max_tool_calls reads"
1821 );
1822 assert!(result["error"]
1823 .as_str()
1824 .unwrap()
1825 .contains("tool call limit exceeded"));
1826 }
1827
1828 #[tokio::test]
1830 async fn rs_s09_search_mode_blocks_resource_read() {
1831 let exec = executor();
1832 let manifest = serde_json::json!({"servers": []});
1833
1834 let code = r#"async () => {
1836 return typeof forge.readResource;
1837 }"#;
1838
1839 let result = exec.execute_search(code, &manifest).await.unwrap();
1840 assert_eq!(
1841 result, "undefined",
1842 "readResource should not exist in search mode"
1843 );
1844 }
1845
1846 #[tokio::test]
1848 async fn sr_r6_unknown_server_rejected_at_op_level() {
1849 let exec = executor();
1850 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1851 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1852 Some(Arc::new(TestResourceDispatcher));
1853
1854 let mut known = std::collections::HashSet::new();
1856 known.insert("allowed-server".to_string());
1857
1858 let code = r#"async () => {
1859 try {
1860 await forge.readResource("nonexistent_server", "file:///x");
1861 return "should not reach here";
1862 } catch(e) {
1863 return e.message;
1864 }
1865 }"#;
1866
1867 let result = exec
1868 .execute_code_with_options(
1869 code,
1870 tool_dispatcher,
1871 resource_dispatcher,
1872 None,
1873 Some(known),
1874 None,
1875 )
1876 .await
1877 .unwrap();
1878 let msg = result.as_str().unwrap();
1879 assert!(
1880 msg.contains("unknown server"),
1881 "expected 'unknown server' error, got: {msg}"
1882 );
1883 assert!(
1884 msg.contains("nonexistent_server"),
1885 "should mention the server name: {msg}"
1886 );
1887 }
1888
1889 #[tokio::test]
1891 async fn rs_s10_audit_records_resource_reads_with_uri_hash() {
1892 struct CapturingAuditLogger {
1893 entries: std::sync::Mutex<Vec<crate::audit::AuditEntry>>,
1894 }
1895
1896 #[async_trait::async_trait]
1897 impl crate::audit::AuditLogger for CapturingAuditLogger {
1898 async fn log(&self, entry: &crate::audit::AuditEntry) {
1899 self.entries.lock().unwrap().push(entry.clone());
1900 }
1901 }
1902
1903 let logger = Arc::new(CapturingAuditLogger {
1904 entries: std::sync::Mutex::new(Vec::new()),
1905 });
1906 let exec = SandboxExecutor::with_audit_logger(SandboxConfig::default(), logger.clone());
1907 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
1908 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
1909 Some(Arc::new(TestResourceDispatcher));
1910
1911 let code = r#"async () => {
1912 await forge.readResource("my-server", "file:///logs/app.log");
1913 return "done";
1914 }"#;
1915
1916 let _ = exec
1917 .execute_code(code, tool_dispatcher, resource_dispatcher, None)
1918 .await
1919 .unwrap();
1920
1921 let entries = logger.entries.lock().unwrap();
1922 assert_eq!(entries.len(), 1);
1923 let entry = &entries[0];
1924 assert_eq!(entry.resource_reads.len(), 1);
1925
1926 let read = &entry.resource_reads[0];
1927 assert_eq!(read.server, "my-server");
1928 assert!(read.success);
1929 assert_ne!(
1931 read.uri_hash, "file:///logs/app.log",
1932 "URI should be hashed, not stored raw"
1933 );
1934 assert_eq!(read.uri_hash.len(), 64, "should be SHA-256 hex");
1936 assert!(read.uri_hash.chars().all(|c| c.is_ascii_hexdigit()));
1937 }
1938
1939 #[tokio::test]
1940 async fn large_output_is_rejected() {
1941 let exec = SandboxExecutor::new(SandboxConfig {
1942 max_output_size: 100,
1943 ..Default::default()
1944 });
1945 let manifest = serde_json::json!({});
1946
1947 let code = r#"async () => {
1948 return "x".repeat(1000);
1949 }"#;
1950
1951 let err = exec.execute_search(code, &manifest).await.unwrap_err();
1952 assert!(
1953 matches!(err, SandboxError::OutputTooLarge { .. }),
1954 "expected output too large, got: {err:?}"
1955 );
1956 }
1957
1958 struct DirectStashDispatcher {
1963 stash: Arc<tokio::sync::Mutex<crate::stash::SessionStash>>,
1964 current_group: Option<String>,
1965 }
1966
1967 #[async_trait::async_trait]
1968 impl crate::StashDispatcher for DirectStashDispatcher {
1969 async fn put(
1970 &self,
1971 key: &str,
1972 value: serde_json::Value,
1973 ttl_secs: Option<u32>,
1974 _current_group: Option<String>,
1975 ) -> Result<serde_json::Value, forge_error::DispatchError> {
1976 let ttl = ttl_secs
1977 .filter(|&s| s > 0)
1978 .map(|s| std::time::Duration::from_secs(s as u64));
1979 let mut stash = self.stash.lock().await;
1980 stash
1981 .put(key, value, ttl, self.current_group.as_deref())
1982 .map_err(|e| forge_error::DispatchError::Internal(e.into()))?;
1983 Ok(serde_json::json!({"ok": true}))
1984 }
1985
1986 async fn get(
1987 &self,
1988 key: &str,
1989 _current_group: Option<String>,
1990 ) -> Result<serde_json::Value, forge_error::DispatchError> {
1991 let stash = self.stash.lock().await;
1992 match stash
1993 .get(key, self.current_group.as_deref())
1994 .map_err(|e| forge_error::DispatchError::Internal(e.into()))?
1995 {
1996 Some(v) => Ok(v.clone()),
1997 None => Ok(serde_json::Value::Null),
1998 }
1999 }
2000
2001 async fn delete(
2002 &self,
2003 key: &str,
2004 _current_group: Option<String>,
2005 ) -> Result<serde_json::Value, forge_error::DispatchError> {
2006 let mut stash = self.stash.lock().await;
2007 let deleted = stash
2008 .delete(key, self.current_group.as_deref())
2009 .map_err(|e| forge_error::DispatchError::Internal(e.into()))?;
2010 Ok(serde_json::json!({"deleted": deleted}))
2011 }
2012
2013 async fn keys(
2014 &self,
2015 _current_group: Option<String>,
2016 ) -> Result<serde_json::Value, forge_error::DispatchError> {
2017 let stash = self.stash.lock().await;
2018 let keys: Vec<&str> = stash.keys(self.current_group.as_deref());
2019 Ok(serde_json::json!(keys))
2020 }
2021 }
2022
2023 fn make_stash(
2024 config: crate::stash::StashConfig,
2025 ) -> Arc<tokio::sync::Mutex<crate::stash::SessionStash>> {
2026 Arc::new(tokio::sync::Mutex::new(crate::stash::SessionStash::new(
2027 config,
2028 )))
2029 }
2030
2031 fn make_stash_dispatcher(
2032 stash: Arc<tokio::sync::Mutex<crate::stash::SessionStash>>,
2033 group: Option<&str>,
2034 ) -> Arc<dyn crate::StashDispatcher> {
2035 Arc::new(DirectStashDispatcher {
2036 stash,
2037 current_group: group.map(str::to_string),
2038 })
2039 }
2040
2041 #[tokio::test]
2043 async fn st_i01_stash_shared_across_executions() {
2044 let exec = executor();
2045 let stash = make_stash(crate::stash::StashConfig::default());
2046 let sd = make_stash_dispatcher(stash.clone(), None);
2047 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2048
2049 let code1 = r#"async () => {
2051 await forge.stash.put("shared-key", { value: 42 });
2052 return "stored";
2053 }"#;
2054 let result1 = exec
2055 .execute_code(code1, dispatcher.clone(), None, Some(sd.clone()))
2056 .await
2057 .unwrap();
2058 assert_eq!(result1, "stored");
2059
2060 let sd2 = make_stash_dispatcher(stash, None);
2062 let code2 = r#"async () => {
2063 const v = await forge.stash.get("shared-key");
2064 return v;
2065 }"#;
2066 let result2 = exec
2067 .execute_code(code2, dispatcher, None, Some(sd2))
2068 .await
2069 .unwrap();
2070 assert_eq!(result2["value"], 42);
2071 }
2072
2073 #[tokio::test]
2075 async fn st_i02_stash_put_get_single_execution() {
2076 let exec = executor();
2077 let stash = make_stash(crate::stash::StashConfig::default());
2078 let sd = make_stash_dispatcher(stash, None);
2079 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2080
2081 let code = r#"async () => {
2082 await forge.stash.put("key", "hello");
2083 const v = await forge.stash.get("key");
2084 return v;
2085 }"#;
2086 let result = exec
2087 .execute_code(code, dispatcher, None, Some(sd))
2088 .await
2089 .unwrap();
2090 assert_eq!(result, "hello");
2091 }
2092
2093 #[tokio::test]
2095 async fn st_i03_stash_group_isolation() {
2096 let exec = executor();
2097 let stash = make_stash(crate::stash::StashConfig::default());
2098 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2099
2100 let sd_a = make_stash_dispatcher(stash.clone(), Some("group-a"));
2102 let code1 = r#"async () => {
2103 await forge.stash.put("secret", "group-a-data");
2104 return "stored";
2105 }"#;
2106 exec.execute_code(code1, dispatcher.clone(), None, Some(sd_a))
2107 .await
2108 .unwrap();
2109
2110 let sd_b = make_stash_dispatcher(stash, Some("group-b"));
2112 let code2 = r#"async () => {
2113 try {
2114 await forge.stash.get("secret");
2115 return "should not reach here";
2116 } catch(e) {
2117 return e.message;
2118 }
2119 }"#;
2120 let result = exec
2121 .execute_code(code2, dispatcher, None, Some(sd_b))
2122 .await
2123 .unwrap();
2124 assert!(
2125 result.as_str().unwrap().contains("cross-group"),
2126 "expected cross-group error, got: {result:?}"
2127 );
2128 }
2129
2130 #[tokio::test]
2132 async fn st_i05_stash_combined_with_tool_and_resource() {
2133 let exec = executor();
2134 let stash = make_stash(crate::stash::StashConfig::default());
2135 let sd = make_stash_dispatcher(stash, None);
2136 let tool_dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2137 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
2138 Some(Arc::new(TestResourceDispatcher));
2139
2140 let code = r#"async () => {
2141 // Call a tool
2142 const toolResult = await forge.callTool("s", "t", {});
2143
2144 // Read a resource
2145 const resource = await forge.readResource("s", "file:///data");
2146
2147 // Store combined result in stash
2148 await forge.stash.put("combined", {
2149 tool: toolResult.server,
2150 resource: resource.content
2151 });
2152
2153 // Read it back
2154 const v = await forge.stash.get("combined");
2155 return v;
2156 }"#;
2157 let result = exec
2158 .execute_code(code, tool_dispatcher, resource_dispatcher, Some(sd))
2159 .await
2160 .unwrap();
2161 assert_eq!(result["tool"], "s");
2162 assert_eq!(result["resource"], "test resource content");
2163 }
2164
2165 #[tokio::test]
2167 async fn st_i06_stash_key_limit_error() {
2168 let exec = executor();
2169 let stash = make_stash(crate::stash::StashConfig {
2170 max_keys: 2,
2171 ..Default::default()
2172 });
2173 let sd = make_stash_dispatcher(stash, None);
2174 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2175
2176 let code = r#"async () => {
2177 await forge.stash.put("k1", 1);
2178 await forge.stash.put("k2", 2);
2179 try {
2180 await forge.stash.put("k3", 3);
2181 return "should not reach here";
2182 } catch(e) {
2183 return e.message;
2184 }
2185 }"#;
2186 let result = exec
2187 .execute_code(code, dispatcher, None, Some(sd))
2188 .await
2189 .unwrap();
2190 assert!(
2191 result.as_str().unwrap().contains("key limit"),
2192 "expected key limit error, got: {result:?}"
2193 );
2194 }
2195
2196 #[tokio::test]
2198 async fn st_i07_stash_value_size_limit_error() {
2199 let exec = executor();
2200 let stash = make_stash(crate::stash::StashConfig {
2201 max_value_size: 50,
2202 ..Default::default()
2203 });
2204 let sd = make_stash_dispatcher(stash, None);
2205 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2206
2207 let code = r#"async () => {
2208 try {
2209 await forge.stash.put("k", "x".repeat(100));
2210 return "should not reach here";
2211 } catch(e) {
2212 return e.message;
2213 }
2214 }"#;
2215 let result = exec
2216 .execute_code(code, dispatcher, None, Some(sd))
2217 .await
2218 .unwrap();
2219 assert!(
2220 result.as_str().unwrap().contains("too large"),
2221 "expected value too large error, got: {result:?}"
2222 );
2223 }
2224
2225 #[tokio::test]
2227 async fn st_i08_stash_keys_group_subset() {
2228 let exec = executor();
2229 let stash = make_stash(crate::stash::StashConfig::default());
2230 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2231
2232 let sd_none = make_stash_dispatcher(stash.clone(), None);
2234 let code1 = r#"async () => {
2235 await forge.stash.put("public-key", "pub");
2236 return "ok";
2237 }"#;
2238 exec.execute_code(code1, dispatcher.clone(), None, Some(sd_none))
2239 .await
2240 .unwrap();
2241
2242 let sd_a = make_stash_dispatcher(stash.clone(), Some("group-a"));
2243 let code2 = r#"async () => {
2244 await forge.stash.put("group-a-key", "secret");
2245 return "ok";
2246 }"#;
2247 exec.execute_code(code2, dispatcher.clone(), None, Some(sd_a))
2248 .await
2249 .unwrap();
2250
2251 let sd_a2 = make_stash_dispatcher(stash.clone(), Some("group-a"));
2253 let code3 = r#"async () => {
2254 const k = await forge.stash.keys();
2255 k.sort();
2256 return k;
2257 }"#;
2258 let result = exec
2259 .execute_code(code3, dispatcher.clone(), None, Some(sd_a2))
2260 .await
2261 .unwrap();
2262 let keys = result.as_array().unwrap();
2263 assert_eq!(keys.len(), 2);
2264
2265 let sd_none2 = make_stash_dispatcher(stash, None);
2267 let code4 = r#"async () => {
2268 const k = await forge.stash.keys();
2269 return k;
2270 }"#;
2271 let result2 = exec
2272 .execute_code(code4, dispatcher, None, Some(sd_none2))
2273 .await
2274 .unwrap();
2275 let keys2 = result2.as_array().unwrap();
2276 assert_eq!(keys2.len(), 1);
2277 assert_eq!(keys2[0], "public-key");
2278 }
2279
2280 #[tokio::test]
2284 async fn st_s01_stash_key_path_traversal_rejected() {
2285 let exec = executor();
2286 let stash = make_stash(crate::stash::StashConfig::default());
2287 let sd = make_stash_dispatcher(stash, None);
2288 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2289
2290 let code = r#"async () => {
2291 try {
2292 await forge.stash.put("../../etc/passwd", "evil");
2293 return "should not reach here";
2294 } catch(e) {
2295 return e.message;
2296 }
2297 }"#;
2298 let result = exec
2299 .execute_code(code, dispatcher, None, Some(sd))
2300 .await
2301 .unwrap();
2302 assert!(
2303 result.as_str().unwrap().contains("invalid"),
2304 "expected invalid key error, got: {result:?}"
2305 );
2306 }
2307
2308 #[tokio::test]
2310 async fn st_s02_stash_key_script_injection_rejected() {
2311 let exec = executor();
2312 let stash = make_stash(crate::stash::StashConfig::default());
2313 let sd = make_stash_dispatcher(stash, None);
2314 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2315
2316 let code = r#"async () => {
2317 try {
2318 await forge.stash.put("<script>alert(1)</script>", "evil");
2319 return "should not reach here";
2320 } catch(e) {
2321 return e.message;
2322 }
2323 }"#;
2324 let result = exec
2325 .execute_code(code, dispatcher, None, Some(sd))
2326 .await
2327 .unwrap();
2328 assert!(
2329 result.as_str().unwrap().contains("invalid"),
2330 "expected invalid key error, got: {result:?}"
2331 );
2332 }
2333
2334 #[tokio::test]
2336 async fn st_s03_stash_value_js_code_is_inert() {
2337 let exec = executor();
2338 let stash = make_stash(crate::stash::StashConfig::default());
2339 let sd = make_stash_dispatcher(stash, None);
2340 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2341
2342 let code = r#"async () => {
2345 const part1 = "function() { return ";
2346 const part2 = "globalThis.secret; }";
2347 const malicious = part1 + part2;
2348 await forge.stash.put("code-value", malicious);
2349 const v = await forge.stash.get("code-value");
2350 // The value should be a plain string, not executed
2351 return typeof v === "string" && v.includes("globalThis");
2352 }"#;
2353 let result = exec
2354 .execute_code(code, dispatcher, None, Some(sd))
2355 .await
2356 .unwrap();
2357 assert_eq!(result, true, "JS code in stash values should be inert data");
2358 }
2359
2360 #[tokio::test]
2362 async fn st_s04_stash_cross_group_get_error() {
2363 let exec = executor();
2364 let stash = make_stash(crate::stash::StashConfig::default());
2365 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2366
2367 let sd_a = make_stash_dispatcher(stash.clone(), Some("team-alpha"));
2369 let code1 = r#"async () => {
2370 await forge.stash.put("alpha-secret", "classified");
2371 return "stored";
2372 }"#;
2373 exec.execute_code(code1, dispatcher.clone(), None, Some(sd_a))
2374 .await
2375 .unwrap();
2376
2377 let sd_b = make_stash_dispatcher(stash, Some("team-beta"));
2379 let code2 = r#"async () => {
2380 try {
2381 await forge.stash.get("alpha-secret");
2382 return "leaked";
2383 } catch(e) {
2384 return e.message;
2385 }
2386 }"#;
2387 let result = exec
2388 .execute_code(code2, dispatcher, None, Some(sd_b))
2389 .await
2390 .unwrap();
2391 assert!(
2392 result.as_str().unwrap().contains("cross-group"),
2393 "expected cross-group error, got: {result:?}"
2394 );
2395 }
2396
2397 #[tokio::test]
2399 async fn st_s05_stash_grouped_entry_inaccessible_to_ungrouped() {
2400 let exec = executor();
2401 let stash = make_stash(crate::stash::StashConfig::default());
2402 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2403
2404 let sd_a = make_stash_dispatcher(stash.clone(), Some("group-x"));
2406 let code1 = r#"async () => {
2407 await forge.stash.put("gx-data", 999);
2408 return "stored";
2409 }"#;
2410 exec.execute_code(code1, dispatcher.clone(), None, Some(sd_a))
2411 .await
2412 .unwrap();
2413
2414 let sd_none = make_stash_dispatcher(stash, None);
2416 let code2 = r#"async () => {
2417 try {
2418 await forge.stash.get("gx-data");
2419 return "leaked";
2420 } catch(e) {
2421 return e.message;
2422 }
2423 }"#;
2424 let result = exec
2425 .execute_code(code2, dispatcher, None, Some(sd_none))
2426 .await
2427 .unwrap();
2428 assert!(
2429 result.as_str().unwrap().contains("cross-group"),
2430 "expected cross-group error, got: {result:?}"
2431 );
2432 }
2433
2434 #[tokio::test]
2436 async fn st_s06_stash_total_size_limit_prevents_oom() {
2437 let exec = executor();
2438 let stash = make_stash(crate::stash::StashConfig {
2439 max_total_size: 200,
2440 max_value_size: 1024,
2441 max_keys: 1000,
2442 ..Default::default()
2443 });
2444 let sd = make_stash_dispatcher(stash, None);
2445 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2446
2447 let code = r#"async () => {
2448 let count = 0;
2449 for (let i = 0; i < 100; i++) {
2450 try {
2451 await forge.stash.put("k" + i, "x".repeat(50));
2452 count++;
2453 } catch(e) {
2454 return { count, error: e.message };
2455 }
2456 }
2457 return { count, error: null };
2458 }"#;
2459 let result = exec
2460 .execute_code(code, dispatcher, None, Some(sd))
2461 .await
2462 .unwrap();
2463 let count = result["count"].as_i64().unwrap();
2465 assert!(
2466 count < 100,
2467 "total size limit should prevent all 100 puts, but {count} succeeded"
2468 );
2469 assert!(
2470 result["error"].as_str().unwrap().contains("total size"),
2471 "expected total size error, got: {:?}",
2472 result["error"]
2473 );
2474 }
2475
2476 #[tokio::test]
2478 async fn st_s07_stash_ops_blocked_in_search_mode() {
2479 let exec = executor();
2480 let manifest = serde_json::json!({"servers": []});
2481
2482 let code = r#"async () => {
2484 return typeof forge.stash;
2485 }"#;
2486
2487 let result = exec.execute_search(code, &manifest).await.unwrap();
2488 assert_eq!(result, "undefined", "stash should not exist in search mode");
2489 }
2490
2491 #[tokio::test]
2493 async fn st_s09_stash_error_messages_dont_leak_data() {
2494 let exec = executor();
2495 let stash = make_stash(crate::stash::StashConfig::default());
2496 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2497
2498 let sd_a = make_stash_dispatcher(stash.clone(), Some("group-a"));
2500 let code1 = r#"async () => {
2501 await forge.stash.put("secret-key", "top-secret-value-12345");
2502 return "stored";
2503 }"#;
2504 exec.execute_code(code1, dispatcher.clone(), None, Some(sd_a))
2505 .await
2506 .unwrap();
2507
2508 let sd_b = make_stash_dispatcher(stash, Some("group-b"));
2510 let code2 = r#"async () => {
2511 try {
2512 await forge.stash.get("secret-key");
2513 return "should not reach here";
2514 } catch(e) {
2515 return e.message;
2516 }
2517 }"#;
2518 let result = exec
2519 .execute_code(code2, dispatcher, None, Some(sd_b))
2520 .await
2521 .unwrap();
2522 let msg = result.as_str().unwrap();
2523 assert!(
2524 !msg.contains("top-secret-value-12345"),
2525 "error should not leak value: {msg}"
2526 );
2527 assert!(
2528 !msg.contains("secret-key"),
2529 "error should not leak key names: {msg}"
2530 );
2531 }
2532
2533 #[tokio::test]
2535 async fn st_s10_stash_ttl_expiry_enforced() {
2536 let exec = executor();
2537 let stash = make_stash(crate::stash::StashConfig::default());
2538 let sd = make_stash_dispatcher(stash.clone(), None);
2539 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2540
2541 let code1 = r#"async () => {
2543 await forge.stash.put("ttl-key", "ephemeral", {ttl: 1});
2544 const v = await forge.stash.get("ttl-key");
2545 return v;
2546 }"#;
2547 let result1 = exec
2548 .execute_code(code1, dispatcher.clone(), None, Some(sd))
2549 .await
2550 .unwrap();
2551 assert_eq!(result1, "ephemeral", "should be readable immediately");
2552
2553 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
2555
2556 let sd2 = make_stash_dispatcher(stash, None);
2558 let code2 = r#"async () => {
2559 const v = await forge.stash.get("ttl-key");
2560 return v;
2561 }"#;
2562 let result2 = exec
2563 .execute_code(code2, dispatcher, None, Some(sd2))
2564 .await
2565 .unwrap();
2566 assert_eq!(
2567 result2,
2568 serde_json::Value::Null,
2569 "expired key should return null"
2570 );
2571 }
2572
2573 #[tokio::test]
2579 async fn pl_u01_parallel_three_successful_calls() {
2580 let exec = executor();
2581 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2582
2583 let code = r#"async () => {
2584 const result = await forge.parallel([
2585 () => forge.callTool("s1", "t1", { id: 1 }),
2586 () => forge.callTool("s2", "t2", { id: 2 }),
2587 () => forge.callTool("s3", "t3", { id: 3 }),
2588 ]);
2589 return result;
2590 }"#;
2591
2592 let result = exec
2593 .execute_code(code, dispatcher, None, None)
2594 .await
2595 .unwrap();
2596 let results = result["results"].as_array().unwrap();
2597 assert_eq!(results.len(), 3);
2598 assert_eq!(results[0]["server"], "s1");
2599 assert_eq!(results[1]["server"], "s2");
2600 assert_eq!(results[2]["server"], "s3");
2601 assert_eq!(result["errors"].as_array().unwrap().len(), 0);
2602 assert_eq!(result["aborted"], false);
2603 }
2604
2605 #[tokio::test]
2607 async fn pl_u02_parallel_partial_failure() {
2608 struct PartialFailDispatcher;
2609
2610 #[async_trait::async_trait]
2611 impl ToolDispatcher for PartialFailDispatcher {
2612 async fn call_tool(
2613 &self,
2614 _server: &str,
2615 tool: &str,
2616 _args: serde_json::Value,
2617 ) -> Result<serde_json::Value, forge_error::DispatchError> {
2618 if tool == "fail" {
2619 Err(anyhow::anyhow!("deliberate failure").into())
2620 } else {
2621 Ok(serde_json::json!({"tool": tool, "ok": true}))
2622 }
2623 }
2624 }
2625
2626 let exec = executor();
2627 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(PartialFailDispatcher);
2628
2629 let code = r#"async () => {
2630 return await forge.parallel([
2631 () => forge.callTool("s", "ok1", {}),
2632 () => forge.callTool("s", "fail", {}),
2633 () => forge.callTool("s", "ok2", {}),
2634 ]);
2635 }"#;
2636
2637 let result = exec
2638 .execute_code(code, dispatcher, None, None)
2639 .await
2640 .unwrap();
2641 let results = result["results"].as_array().unwrap();
2642 assert!(results[0]["ok"] == true);
2643 assert!(results[1].is_null(), "failed call should have null result");
2644 assert!(results[2]["ok"] == true);
2645 let errors = result["errors"].as_array().unwrap();
2646 assert_eq!(errors.len(), 1);
2647 assert_eq!(errors[0]["index"], 1);
2648 }
2649
2650 #[tokio::test]
2652 async fn pl_u03_parallel_fail_fast() {
2653 let exec = SandboxExecutor::new(SandboxConfig {
2654 max_tool_calls: 50,
2655 max_parallel: 2, ..Default::default()
2657 });
2658
2659 struct FailOnSecondDispatcher {
2660 calls: std::sync::Mutex<u32>,
2661 }
2662
2663 #[async_trait::async_trait]
2664 impl ToolDispatcher for FailOnSecondDispatcher {
2665 async fn call_tool(
2666 &self,
2667 _server: &str,
2668 tool: &str,
2669 _args: serde_json::Value,
2670 ) -> Result<serde_json::Value, forge_error::DispatchError> {
2671 let mut c = self.calls.lock().unwrap();
2672 *c += 1;
2673 if tool == "fail" {
2674 Err(anyhow::anyhow!("fail").into())
2675 } else {
2676 Ok(serde_json::json!({"ok": true}))
2677 }
2678 }
2679 }
2680
2681 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(FailOnSecondDispatcher {
2682 calls: std::sync::Mutex::new(0),
2683 });
2684
2685 let code = r#"async () => {
2687 return await forge.parallel([
2688 () => forge.callTool("s", "ok", {}),
2689 () => forge.callTool("s", "fail", {}),
2690 () => forge.callTool("s", "ok", {}),
2691 () => forge.callTool("s", "ok", {}),
2692 ], { failFast: true });
2693 }"#;
2694
2695 let result = exec
2696 .execute_code(code, dispatcher, None, None)
2697 .await
2698 .unwrap();
2699 assert_eq!(result["aborted"], true);
2700 assert!(!result["errors"].as_array().unwrap().is_empty());
2701 }
2702
2703 #[tokio::test]
2705 async fn pl_u04_parallel_respects_concurrency_limit() {
2706 let exec = SandboxExecutor::new(SandboxConfig {
2707 max_parallel: 2,
2708 timeout: Duration::from_secs(10),
2709 ..Default::default()
2710 });
2711
2712 struct ConcurrencyTracker {
2713 current: std::sync::atomic::AtomicUsize,
2714 peak: std::sync::atomic::AtomicUsize,
2715 }
2716
2717 #[async_trait::async_trait]
2718 impl ToolDispatcher for ConcurrencyTracker {
2719 async fn call_tool(
2720 &self,
2721 _server: &str,
2722 _tool: &str,
2723 _args: serde_json::Value,
2724 ) -> Result<serde_json::Value, forge_error::DispatchError> {
2725 let c = self
2726 .current
2727 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
2728 + 1;
2729 self.peak.fetch_max(c, std::sync::atomic::Ordering::SeqCst);
2731 tokio::time::sleep(Duration::from_millis(10)).await;
2733 self.current
2734 .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
2735 Ok(serde_json::json!({"peak": self.peak.load(std::sync::atomic::Ordering::SeqCst)}))
2736 }
2737 }
2738
2739 let tracker = Arc::new(ConcurrencyTracker {
2740 current: std::sync::atomic::AtomicUsize::new(0),
2741 peak: std::sync::atomic::AtomicUsize::new(0),
2742 });
2743 let dispatcher: Arc<dyn ToolDispatcher> = tracker.clone();
2744
2745 let code = r#"async () => {
2747 return await forge.parallel([
2748 () => forge.callTool("s", "t", {}),
2749 () => forge.callTool("s", "t", {}),
2750 () => forge.callTool("s", "t", {}),
2751 () => forge.callTool("s", "t", {}),
2752 () => forge.callTool("s", "t", {}),
2753 () => forge.callTool("s", "t", {}),
2754 ]);
2755 }"#;
2756
2757 let result = exec
2758 .execute_code(code, dispatcher, None, None)
2759 .await
2760 .unwrap();
2761 assert_eq!(result["errors"].as_array().unwrap().len(), 0);
2762 let peak = tracker.peak.load(std::sync::atomic::Ordering::SeqCst);
2763 assert!(peak <= 2, "peak concurrency should be <= 2, was: {peak}");
2764 }
2765
2766 #[tokio::test]
2768 async fn pl_u05_parallel_empty_array() {
2769 let exec = executor();
2770 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2771
2772 let code = r#"async () => {
2773 return await forge.parallel([]);
2774 }"#;
2775
2776 let result = exec
2777 .execute_code(code, dispatcher, None, None)
2778 .await
2779 .unwrap();
2780 assert_eq!(result["results"].as_array().unwrap().len(), 0);
2781 assert_eq!(result["errors"].as_array().unwrap().len(), 0);
2782 assert_eq!(result["aborted"], false);
2783 }
2784
2785 #[tokio::test]
2787 async fn pl_u06_parallel_single_call() {
2788 let exec = executor();
2789 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2790
2791 let code = r#"async () => {
2792 return await forge.parallel([
2793 () => forge.callTool("s", "t", { id: 1 }),
2794 ]);
2795 }"#;
2796
2797 let result = exec
2798 .execute_code(code, dispatcher, None, None)
2799 .await
2800 .unwrap();
2801 let results = result["results"].as_array().unwrap();
2802 assert_eq!(results.len(), 1);
2803 assert_eq!(results[0]["server"], "s");
2804 }
2805
2806 #[tokio::test]
2808 async fn pl_u07_parallel_errors_redacted() {
2809 struct LeakyDispatcher;
2810
2811 #[async_trait::async_trait]
2812 impl ToolDispatcher for LeakyDispatcher {
2813 async fn call_tool(
2814 &self,
2815 _server: &str,
2816 _tool: &str,
2817 _args: serde_json::Value,
2818 ) -> Result<serde_json::Value, forge_error::DispatchError> {
2819 Err(anyhow::anyhow!("connection to http://internal.secret:9999/api failed").into())
2820 }
2821 }
2822
2823 let exec = executor();
2824 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(LeakyDispatcher);
2825
2826 let code = r#"async () => {
2827 return await forge.parallel([
2828 () => forge.callTool("server", "tool", {}),
2829 ]);
2830 }"#;
2831
2832 let result = exec
2833 .execute_code(code, dispatcher, None, None)
2834 .await
2835 .unwrap();
2836 let errors = result["errors"].as_array().unwrap();
2837 assert_eq!(errors.len(), 1);
2838 let msg = errors[0]["error"].as_str().unwrap();
2839 assert!(!msg.contains("internal.secret"), "should redact URL: {msg}");
2840 }
2841
2842 #[tokio::test]
2844 async fn pl_u08_parallel_with_read_resource() {
2845 let exec = executor();
2846 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2847 let resource_dispatcher: Option<Arc<dyn ResourceDispatcher>> =
2848 Some(Arc::new(TestResourceDispatcher));
2849
2850 let code = r#"async () => {
2851 return await forge.parallel([
2852 () => forge.callTool("s", "t", {}),
2853 () => forge.readResource("rs", "file:///log"),
2854 ]);
2855 }"#;
2856
2857 let result = exec
2858 .execute_code(code, dispatcher, resource_dispatcher, None)
2859 .await
2860 .unwrap();
2861 let results = result["results"].as_array().unwrap();
2862 assert_eq!(results.len(), 2);
2863 assert_eq!(results[0]["server"], "s");
2864 assert_eq!(results[1]["server"], "rs");
2865 }
2866
2867 #[tokio::test]
2869 async fn pl_u09_parallel_exceeds_rate_limit() {
2870 let exec = SandboxExecutor::new(SandboxConfig {
2871 max_tool_calls: 3,
2872 ..Default::default()
2873 });
2874 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2875
2876 let code = r#"async () => {
2877 return await forge.parallel([
2878 () => forge.callTool("s", "t1", {}),
2879 () => forge.callTool("s", "t2", {}),
2880 () => forge.callTool("s", "t3", {}),
2881 () => forge.callTool("s", "t4", {}),
2882 () => forge.callTool("s", "t5", {}),
2883 ]);
2884 }"#;
2885
2886 let result = exec
2887 .execute_code(code, dispatcher, None, None)
2888 .await
2889 .unwrap();
2890 let errors = result["errors"].as_array().unwrap();
2892 assert!(!errors.is_empty(), "should have errors from rate limiting");
2893 let results = result["results"].as_array().unwrap();
2895 let successes = results.iter().filter(|r| !r.is_null()).count();
2896 assert_eq!(successes, 3, "should have exactly 3 successful calls");
2897 }
2898
2899 #[tokio::test]
2901 async fn pl_s01_cannot_exceed_max_parallel() {
2902 let exec = SandboxExecutor::new(SandboxConfig {
2903 max_parallel: 2,
2904 timeout: Duration::from_secs(10),
2905 ..Default::default()
2906 });
2907
2908 struct ConcurrencyCounter {
2909 peak: std::sync::atomic::AtomicUsize,
2910 current: std::sync::atomic::AtomicUsize,
2911 }
2912
2913 #[async_trait::async_trait]
2914 impl ToolDispatcher for ConcurrencyCounter {
2915 async fn call_tool(
2916 &self,
2917 _server: &str,
2918 _tool: &str,
2919 _args: serde_json::Value,
2920 ) -> Result<serde_json::Value, forge_error::DispatchError> {
2921 let c = self
2922 .current
2923 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
2924 + 1;
2925 self.peak.fetch_max(c, std::sync::atomic::Ordering::SeqCst);
2926 tokio::time::sleep(Duration::from_millis(10)).await;
2927 self.current
2928 .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
2929 Ok(serde_json::json!({}))
2930 }
2931 }
2932
2933 let counter = Arc::new(ConcurrencyCounter {
2934 peak: std::sync::atomic::AtomicUsize::new(0),
2935 current: std::sync::atomic::AtomicUsize::new(0),
2936 });
2937 let dispatcher: Arc<dyn ToolDispatcher> = counter.clone();
2938
2939 let code = r#"async () => {
2941 return await forge.parallel([
2942 () => forge.callTool("s", "t", {}),
2943 () => forge.callTool("s", "t", {}),
2944 () => forge.callTool("s", "t", {}),
2945 () => forge.callTool("s", "t", {}),
2946 ], { concurrency: 9999 });
2947 }"#;
2948
2949 let _ = exec
2950 .execute_code(code, dispatcher, None, None)
2951 .await
2952 .unwrap();
2953 let peak = counter.peak.load(std::sync::atomic::Ordering::SeqCst);
2954 assert!(
2955 peak <= 2,
2956 "peak should be capped at max_parallel=2, was: {peak}"
2957 );
2958 }
2959
2960 #[tokio::test]
2962 async fn pl_s02_parallel_mixed_strict_groups() {
2963 use crate::groups::{GroupEnforcingDispatcher, GroupPolicy};
2964 use std::collections::HashMap;
2965
2966 let mut groups = HashMap::new();
2967 groups.insert(
2968 "internal".to_string(),
2969 (vec!["vault".to_string()], "strict".to_string()),
2970 );
2971 groups.insert(
2972 "external".to_string(),
2973 (vec!["slack".to_string()], "strict".to_string()),
2974 );
2975 let policy = Arc::new(GroupPolicy::from_config(&groups));
2976 let inner: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
2977 let enforcer = GroupEnforcingDispatcher::new(inner, policy);
2978 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(enforcer);
2979
2980 let exec = executor();
2981
2982 let code = r#"async () => {
2984 return await forge.parallel([
2985 () => forge.callTool("vault", "secrets.list", {}),
2986 () => forge.callTool("slack", "messages.send", {}),
2987 ]);
2988 }"#;
2989
2990 let result = exec
2991 .execute_code(code, dispatcher, None, None)
2992 .await
2993 .unwrap();
2994 let errors = result["errors"].as_array().unwrap();
2995 assert!(
2997 !errors.is_empty(),
2998 "should have cross-group error: {result:?}"
2999 );
3000 let has_cross_group = errors
3001 .iter()
3002 .any(|e| e["error"].as_str().unwrap_or("").contains("cross-group"));
3003 assert!(has_cross_group, "should mention cross-group: {result:?}");
3004 }
3005
3006 #[tokio::test]
3008 async fn pl_s03_many_parallel_calls_hit_rate_limit() {
3009 let exec = SandboxExecutor::new(SandboxConfig {
3010 max_tool_calls: 10,
3011 ..Default::default()
3012 });
3013 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3014
3015 let code = r#"async () => {
3016 const calls = [];
3017 for (let i = 0; i < 100; i++) {
3018 calls.push(() => forge.callTool("s", "t", { i }));
3019 }
3020 return await forge.parallel(calls);
3021 }"#;
3022
3023 let result = exec
3024 .execute_code(code, dispatcher, None, None)
3025 .await
3026 .unwrap();
3027 let errors = result["errors"].as_array().unwrap();
3028 let results = result["results"].as_array().unwrap();
3029 let successes = results.iter().filter(|r| !r.is_null()).count();
3030 assert_eq!(
3031 successes, 10,
3032 "should have exactly max_tool_calls successes"
3033 );
3034 assert_eq!(errors.len(), 90, "remaining 90 should be rate limited");
3035 }
3036
3037 #[tokio::test]
3039 async fn pl_s04_max_parallel_not_modifiable() {
3040 let exec = SandboxExecutor::new(SandboxConfig {
3041 max_parallel: 3,
3042 ..Default::default()
3043 });
3044 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3045
3046 let code = r#"async () => {
3048 try {
3049 // __MAX_PARALLEL is a local const in the bootstrap closure,
3050 // not accessible from user code. Attempting to use it would fail.
3051 return typeof __MAX_PARALLEL;
3052 } catch(e) {
3053 return "error";
3054 }
3055 }"#;
3056
3057 let result = exec
3058 .execute_code(code, dispatcher, None, None)
3059 .await
3060 .unwrap();
3061 assert_eq!(
3063 result, "undefined",
3064 "__MAX_PARALLEL should not be accessible"
3065 );
3066 }
3067
3068 #[tokio::test]
3070 async fn pl_s05_raw_promise_all_hits_rate_limit() {
3071 let exec = SandboxExecutor::new(SandboxConfig {
3072 max_tool_calls: 3,
3073 ..Default::default()
3074 });
3075 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3076
3077 let code = r#"async () => {
3079 const results = await Promise.allSettled([
3080 forge.callTool("s", "t1", {}),
3081 forge.callTool("s", "t2", {}),
3082 forge.callTool("s", "t3", {}),
3083 forge.callTool("s", "t4", {}),
3084 forge.callTool("s", "t5", {}),
3085 ]);
3086 const fulfilled = results.filter(r => r.status === "fulfilled").length;
3087 const rejected = results.filter(r => r.status === "rejected").length;
3088 return { fulfilled, rejected };
3089 }"#;
3090
3091 let result = exec
3092 .execute_code(code, dispatcher, None, None)
3093 .await
3094 .unwrap();
3095 assert_eq!(result["fulfilled"], 3, "should have 3 successful calls");
3096 assert_eq!(result["rejected"], 2, "should have 2 rate-limited calls");
3097 }
3098
3099 #[tokio::test]
3105 async fn bs_01_forge_object_is_frozen() {
3106 let exec = executor();
3107 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3108 let resource: Arc<dyn ResourceDispatcher> = Arc::new(TestResourceDispatcher);
3109 let stash_store = make_stash(Default::default());
3110 let stash = make_stash_dispatcher(stash_store, None);
3111
3112 let code = r#"async () => {
3113 return Object.isFrozen(forge);
3114 }"#;
3115
3116 let result = exec
3117 .execute_code(code, dispatcher, Some(resource), Some(stash))
3118 .await
3119 .unwrap();
3120 assert_eq!(result, true, "forge object must be frozen");
3121 }
3122
3123 #[tokio::test]
3125 async fn bs_02_forge_stash_is_frozen() {
3126 let exec = executor();
3127 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3128 let stash_store = make_stash(Default::default());
3129 let stash = make_stash_dispatcher(stash_store, None);
3130
3131 let code = r#"async () => {
3132 return Object.isFrozen(forge.stash);
3133 }"#;
3134
3135 let result = exec
3136 .execute_code(code, dispatcher, None, Some(stash))
3137 .await
3138 .unwrap();
3139 assert_eq!(result, true, "forge.stash must be frozen");
3140 }
3141
3142 #[tokio::test]
3144 async fn bs_03_max_parallel_not_accessible_as_global() {
3145 let exec = executor();
3146 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3147
3148 let code = r#"async () => {
3149 return {
3150 global: typeof globalThis.__MAX_PARALLEL,
3151 direct: typeof __MAX_PARALLEL,
3152 };
3153 }"#;
3154
3155 let result = exec
3156 .execute_code(code, dispatcher, None, None)
3157 .await
3158 .unwrap();
3159 assert_eq!(
3160 result["global"], "undefined",
3161 "__MAX_PARALLEL must not be on globalThis"
3162 );
3163 assert_eq!(
3167 result["direct"], "undefined",
3168 "__MAX_PARALLEL must not be accessible from user scope"
3169 );
3170 }
3171
3172 #[tokio::test]
3174 async fn bs_04_read_resource_is_function_in_execute_mode() {
3175 let exec = executor();
3176 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3177 let resource: Arc<dyn ResourceDispatcher> = Arc::new(TestResourceDispatcher);
3178
3179 let code = r#"async () => {
3180 return typeof forge.readResource;
3181 }"#;
3182
3183 let result = exec
3184 .execute_code(code, dispatcher, Some(resource), None)
3185 .await
3186 .unwrap();
3187 assert_eq!(result, "function", "forge.readResource must be a function");
3188 }
3189
3190 #[tokio::test]
3192 async fn bs_05_read_resource_undefined_in_search_mode() {
3193 let exec = executor();
3194 let manifest = serde_json::json!({"servers": []});
3195
3196 let code = r#"async () => {
3197 return typeof forge.readResource;
3198 }"#;
3199
3200 let result = exec.execute_search(code, &manifest).await.unwrap();
3201 assert_eq!(
3202 result, "undefined",
3203 "forge.readResource must be undefined in search mode"
3204 );
3205 }
3206
3207 #[tokio::test]
3209 async fn bs_06_stash_has_all_methods_in_execute_mode() {
3210 let exec = executor();
3211 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3212 let stash_store = make_stash(Default::default());
3213 let stash = make_stash_dispatcher(stash_store, None);
3214
3215 let code = r#"async () => {
3216 return {
3217 type: typeof forge.stash,
3218 put: typeof forge.stash.put,
3219 get: typeof forge.stash.get,
3220 del: typeof forge.stash.delete,
3221 keys: typeof forge.stash.keys,
3222 };
3223 }"#;
3224
3225 let result = exec
3226 .execute_code(code, dispatcher, None, Some(stash))
3227 .await
3228 .unwrap();
3229 assert_eq!(result["type"], "object", "forge.stash must be an object");
3230 assert_eq!(result["put"], "function");
3231 assert_eq!(result["get"], "function");
3232 assert_eq!(result["del"], "function");
3233 assert_eq!(result["keys"], "function");
3234 }
3235
3236 #[tokio::test]
3238 async fn bs_07_stash_undefined_in_search_mode() {
3239 let exec = executor();
3240 let manifest = serde_json::json!({"servers": []});
3241
3242 let code = r#"async () => {
3243 return typeof forge.stash;
3244 }"#;
3245
3246 let result = exec.execute_search(code, &manifest).await.unwrap();
3247 assert_eq!(
3248 result, "undefined",
3249 "forge.stash must be undefined in search mode"
3250 );
3251 }
3252
3253 #[tokio::test]
3255 async fn bs_08_parallel_is_function_in_execute_mode() {
3256 let exec = executor();
3257 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3258
3259 let code = r#"async () => {
3260 return typeof forge.parallel;
3261 }"#;
3262
3263 let result = exec
3264 .execute_code(code, dispatcher, None, None)
3265 .await
3266 .unwrap();
3267 assert_eq!(result, "function", "forge.parallel must be a function");
3268 }
3269
3270 #[tokio::test]
3272 async fn bs_09_parallel_undefined_in_search_mode() {
3273 let exec = executor();
3274 let manifest = serde_json::json!({"servers": []});
3275
3276 let code = r#"async () => {
3277 return typeof forge.parallel;
3278 }"#;
3279
3280 let result = exec.execute_search(code, &manifest).await.unwrap();
3281 assert_eq!(
3282 result, "undefined",
3283 "forge.parallel must be undefined in search mode"
3284 );
3285 }
3286
3287 #[tokio::test]
3289 async fn bs_10_server_proxy_still_works() {
3290 let exec = executor();
3291 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3292 let resource: Arc<dyn ResourceDispatcher> = Arc::new(TestResourceDispatcher);
3293 let stash_store = make_stash(Default::default());
3294 let stash = make_stash_dispatcher(stash_store, None);
3295
3296 let code = r#"async () => {
3297 const result = await forge.server("myserver").ast.parse({ file: "test.rs" });
3298 return result;
3299 }"#;
3300
3301 let result = exec
3302 .execute_code(code, dispatcher, Some(resource), Some(stash))
3303 .await
3304 .unwrap();
3305 assert_eq!(result["server"], "myserver");
3306 assert_eq!(result["tool"], "ast.parse");
3307 assert_eq!(result["args"]["file"], "test.rs");
3308 }
3309
3310 #[tokio::test]
3312 async fn bs_11_deno_deleted_in_execute_mode() {
3313 let exec = executor();
3314 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3315 let resource: Arc<dyn ResourceDispatcher> = Arc::new(TestResourceDispatcher);
3316 let stash_store = make_stash(Default::default());
3317 let stash = make_stash_dispatcher(stash_store, None);
3318
3319 let code = r#"async () => {
3320 return typeof globalThis.Deno;
3321 }"#;
3322
3323 let result = exec
3324 .execute_code(code, dispatcher, Some(resource), Some(stash))
3325 .await
3326 .unwrap();
3327 assert_eq!(result, "undefined", "Deno must be deleted in execute mode");
3328 }
3329
3330 #[tokio::test]
3332 async fn bs_12_function_constructor_undefined_in_execute_mode() {
3333 let exec = executor();
3334 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3335 let resource: Arc<dyn ResourceDispatcher> = Arc::new(TestResourceDispatcher);
3336 let stash_store = make_stash(Default::default());
3337 let stash = make_stash_dispatcher(stash_store, None);
3338
3339 let code = r#"async () => {
3343 const funcCtor = typeof Function.prototype.constructor;
3344 // AsyncFunction and GeneratorFunction constructors are also wiped
3345 // because they inherit from Function.prototype.
3346 const asyncFn = async function(){};
3347 const genFn = function*(){};
3348 const asyncCtor = typeof asyncFn.constructor;
3349 const genCtor = typeof genFn.constructor;
3350 return { funcCtor, asyncCtor, genCtor };
3351 }"#;
3352
3353 let result = exec
3354 .execute_code(code, dispatcher, Some(resource), Some(stash))
3355 .await
3356 .unwrap();
3357 assert_eq!(
3358 result["funcCtor"], "undefined",
3359 "Function.prototype.constructor must be undefined"
3360 );
3361 assert_eq!(
3362 result["asyncCtor"], "undefined",
3363 "AsyncFunction .constructor must be undefined"
3364 );
3365 assert_eq!(
3366 result["genCtor"], "undefined",
3367 "GeneratorFunction .constructor must be undefined"
3368 );
3369 }
3370
3371 #[tokio::test]
3373 async fn inv_01_search_mode_no_call_tool() {
3374 let exec = executor();
3375 let manifest = serde_json::json!({"servers": []});
3376
3377 let code = r#"async () => {
3378 return typeof forge.callTool;
3379 }"#;
3380
3381 let result = exec.execute_search(code, &manifest).await.unwrap();
3382 assert_eq!(
3383 result, "undefined",
3384 "forge.callTool must not exist in search mode"
3385 );
3386 }
3387
3388 #[tokio::test]
3390 async fn inv_02_search_mode_no_read_resource() {
3391 let exec = executor();
3392 let manifest = serde_json::json!({"servers": []});
3393
3394 let code = r#"async () => {
3395 return typeof forge.readResource;
3396 }"#;
3397
3398 let result = exec.execute_search(code, &manifest).await.unwrap();
3399 assert_eq!(
3400 result, "undefined",
3401 "forge.readResource must not exist in search mode"
3402 );
3403 }
3404
3405 #[tokio::test]
3407 async fn inv_03_search_mode_no_stash() {
3408 let exec = executor();
3409 let manifest = serde_json::json!({"servers": []});
3410
3411 let code = r#"async () => {
3412 return typeof forge.stash;
3413 }"#;
3414
3415 let result = exec.execute_search(code, &manifest).await.unwrap();
3416 assert_eq!(
3417 result, "undefined",
3418 "forge.stash must not exist in search mode"
3419 );
3420 }
3421
3422 #[tokio::test]
3424 async fn inv_04_search_mode_no_parallel() {
3425 let exec = executor();
3426 let manifest = serde_json::json!({"servers": []});
3427
3428 let code = r#"async () => {
3429 return typeof forge.parallel;
3430 }"#;
3431
3432 let result = exec.execute_search(code, &manifest).await.unwrap();
3433 assert_eq!(
3434 result, "undefined",
3435 "forge.parallel must not exist in search mode"
3436 );
3437 }
3438
3439 #[tokio::test]
3441 async fn inv_05_eval_undefined_in_all_modes() {
3442 let exec = executor();
3443
3444 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3446 let code = r#"async () => { return typeof eval; }"#;
3447 let result = exec
3448 .execute_code(code, dispatcher, None, None)
3449 .await
3450 .unwrap();
3451 assert_eq!(
3452 result, "undefined",
3453 "eval must be undefined in execute mode"
3454 );
3455
3456 let manifest = serde_json::json!({"servers": []});
3458 let result = exec.execute_search(code, &manifest).await.unwrap();
3459 assert_eq!(result, "undefined", "eval must be undefined in search mode");
3460 }
3461
3462 #[tokio::test]
3464 async fn inv_06_function_constructor_undefined_all_modes() {
3465 let exec = executor();
3466
3467 let code = r#"async () => {
3468 return typeof Function.prototype.constructor;
3469 }"#;
3470
3471 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3473 let result = exec
3474 .execute_code(code, dispatcher, None, None)
3475 .await
3476 .unwrap();
3477 assert_eq!(
3478 result, "undefined",
3479 "Function.prototype.constructor must be undefined in execute mode"
3480 );
3481
3482 let manifest = serde_json::json!({"servers": []});
3484 let result = exec.execute_search(code, &manifest).await.unwrap();
3485 assert_eq!(
3486 result, "undefined",
3487 "Function.prototype.constructor must be undefined in search mode"
3488 );
3489 }
3490
3491 #[tokio::test]
3493 async fn inv_07_deno_undefined_all_modes() {
3494 let exec = executor();
3495
3496 let code = r#"async () => { return typeof globalThis.Deno; }"#;
3497
3498 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3500 let result = exec
3501 .execute_code(code, dispatcher, None, None)
3502 .await
3503 .unwrap();
3504 assert_eq!(
3505 result, "undefined",
3506 "Deno must be undefined in execute mode"
3507 );
3508
3509 let manifest = serde_json::json!({"servers": []});
3511 let result = exec.execute_search(code, &manifest).await.unwrap();
3512 assert_eq!(result, "undefined", "Deno must be undefined in search mode");
3513 }
3514
3515 #[tokio::test]
3517 async fn inv_08_forge_frozen_all_modes() {
3518 let exec = executor();
3519
3520 let code = r#"async () => { return Object.isFrozen(forge); }"#;
3521
3522 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3524 let result = exec
3525 .execute_code(code, dispatcher, None, None)
3526 .await
3527 .unwrap();
3528 assert_eq!(result, true, "forge must be frozen in execute mode");
3529
3530 let manifest = serde_json::json!({"servers": []});
3532 let result = exec.execute_search(code, &manifest).await.unwrap();
3533 assert_eq!(result, true, "forge must be frozen in search mode");
3534 }
3535
3536 #[tokio::test]
3538 async fn inv_09_stash_frozen_in_execute_mode() {
3539 let exec = executor();
3540 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3541 let stash_store = make_stash(Default::default());
3542 let stash = make_stash_dispatcher(stash_store, None);
3543
3544 let code = r#"async () => {
3546 const frozen = Object.isFrozen(forge.stash);
3547 let mutated = false;
3548 try {
3549 forge.stash.evil = () => {};
3550 mutated = forge.stash.evil !== undefined;
3551 } catch (e) {
3552 // TypeError in strict mode, which is fine
3553 }
3554 return { frozen, mutated };
3555 }"#;
3556
3557 let result = exec
3558 .execute_code(code, dispatcher, None, Some(stash))
3559 .await
3560 .unwrap();
3561 assert_eq!(result["frozen"], true, "forge.stash must be frozen");
3562 assert_eq!(result["mutated"], false, "forge.stash must not be mutable");
3563 }
3564
3565 #[tokio::test]
3567 async fn inv_10_error_messages_redacted() {
3568 let exec = executor();
3569
3570 let failing_resource: Arc<dyn ResourceDispatcher> = Arc::new(FailingResourceDispatcher {
3572 error_msg: "connection refused to /var/secret/db.sock".to_string(),
3573 });
3574 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
3575
3576 let code = r#"async () => {
3578 const result = await forge.readResource("secret-server", "file:///data/log.txt");
3579 return result;
3580 }"#;
3581
3582 let result = exec
3583 .execute_code(code, dispatcher, Some(failing_resource), None)
3584 .await
3585 .unwrap();
3586 assert_eq!(
3587 result["error"], true,
3588 "should be structured error: {result}"
3589 );
3590 let error_msg = result["message"].as_str().unwrap();
3591 assert!(
3593 !error_msg.contains("/var/secret/db.sock"),
3594 "error must be redacted, got: {error_msg}"
3595 );
3596 assert!(
3598 error_msg.contains("secret-server"),
3599 "error should reference server name: {error_msg}"
3600 );
3601 }
3602
3603 struct ErrorDispatcher;
3607
3608 #[async_trait::async_trait]
3609 impl ToolDispatcher for ErrorDispatcher {
3610 async fn call_tool(
3611 &self,
3612 server: &str,
3613 _tool: &str,
3614 _args: serde_json::Value,
3615 ) -> Result<serde_json::Value, forge_error::DispatchError> {
3616 Err(forge_error::DispatchError::ServerNotFound(
3617 server.to_string(),
3618 ))
3619 }
3620 }
3621
3622 struct ToolNotFoundDispatcher;
3624
3625 #[async_trait::async_trait]
3626 impl ToolDispatcher for ToolNotFoundDispatcher {
3627 async fn call_tool(
3628 &self,
3629 server: &str,
3630 tool: &str,
3631 _args: serde_json::Value,
3632 ) -> Result<serde_json::Value, forge_error::DispatchError> {
3633 Err(forge_error::DispatchError::ToolNotFound {
3634 server: server.to_string(),
3635 tool: tool.to_string(),
3636 })
3637 }
3638 }
3639
3640 #[tokio::test]
3641 async fn se_wire_01_tool_call_error_returns_structured_json() {
3642 let exec = executor();
3643 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(ErrorDispatcher);
3644
3645 let code = r#"async () => {
3646 const result = await forge.callTool("bad_server", "bad_tool", {});
3647 return result;
3648 }"#;
3649
3650 let result = exec
3651 .execute_code(code, dispatcher, None, None)
3652 .await
3653 .unwrap();
3654 assert_eq!(result["error"], true, "should be an error: {result}");
3655 }
3656
3657 #[tokio::test]
3658 async fn se_wire_02_structured_error_has_code_field() {
3659 let exec = executor();
3660 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(ErrorDispatcher);
3661
3662 let code = r#"async () => {
3663 const result = await forge.callTool("bad_server", "bad_tool", {});
3664 return result;
3665 }"#;
3666
3667 let result = exec
3668 .execute_code(code, dispatcher, None, None)
3669 .await
3670 .unwrap();
3671 assert_eq!(
3672 result["code"], "SERVER_NOT_FOUND",
3673 "should have code field: {result}"
3674 );
3675 }
3676
3677 #[tokio::test]
3678 async fn se_wire_03_structured_error_has_suggested_fix() {
3679 let exec = executor();
3680 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(ToolNotFoundDispatcher);
3681
3682 let known_tools = vec![("narsil".to_string(), "find_symbols".to_string())];
3684
3685 let code = r#"async () => {
3686 const result = await forge.callTool("narsil", "fnd_symbols", {});
3687 return result;
3688 }"#;
3689
3690 let result = exec
3691 .execute_code_with_options(code, dispatcher, None, None, None, Some(known_tools))
3692 .await
3693 .unwrap();
3694 assert_eq!(result["code"], "TOOL_NOT_FOUND", "code: {result}");
3695 let fix = result["suggested_fix"]
3696 .as_str()
3697 .expect("should have suggested_fix");
3698 assert!(
3699 fix.contains("find_symbols"),
3700 "should suggest find_symbols, got: {fix}"
3701 );
3702 }
3703
3704 #[tokio::test]
3705 async fn se_wire_04_structured_error_message_is_redacted() {
3706 struct CredLeakDispatcher;
3708
3709 #[async_trait::async_trait]
3710 impl ToolDispatcher for CredLeakDispatcher {
3711 async fn call_tool(
3712 &self,
3713 server: &str,
3714 _tool: &str,
3715 _args: serde_json::Value,
3716 ) -> Result<serde_json::Value, forge_error::DispatchError> {
3717 Err(forge_error::DispatchError::Upstream {
3718 server: server.to_string(),
3719 message: "auth failed with Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.rg2e at https://internal.corp:9999/api".to_string(),
3720 })
3721 }
3722 }
3723
3724 let exec = executor();
3725 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(CredLeakDispatcher);
3726
3727 let code = r#"async () => {
3728 const result = await forge.callTool("narsil", "find", {});
3729 return result;
3730 }"#;
3731
3732 let result = exec
3733 .execute_code(code, dispatcher, None, None)
3734 .await
3735 .unwrap();
3736 let msg = result["message"].as_str().expect("should have message");
3737 assert!(!msg.contains("eyJhbGci"), "JWT should be redacted: {msg}");
3738 assert!(
3739 !msg.contains("internal.corp"),
3740 "URL should be redacted: {msg}"
3741 );
3742 }
3743}