1use super::traits::{Tool, ToolResult};
2use crate::runtime::RuntimeAdapter;
3use crate::security::SecurityPolicy;
4use crate::security::traits::Sandbox;
5use async_trait::async_trait;
6use serde_json::json;
7use std::collections::HashSet;
8use std::sync::Arc;
9use std::time::Duration;
10
11const DEFAULT_SHELL_TIMEOUT_SECS: u64 = 60;
13const MAX_OUTPUT_BYTES: usize = 1_048_576;
15
16#[cfg(not(target_os = "windows"))]
19const SAFE_ENV_VARS: &[&str] = &[
20 "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
21];
22
23#[cfg(target_os = "windows")]
26const SAFE_ENV_VARS: &[&str] = &[
27 "PATH",
28 "PATHEXT",
29 "HOME",
30 "USERPROFILE",
31 "HOMEDRIVE",
32 "HOMEPATH",
33 "SYSTEMROOT",
34 "SYSTEMDRIVE",
35 "WINDIR",
36 "COMSPEC",
37 "TEMP",
38 "TMP",
39 "TERM",
40 "LANG",
41 "USERNAME",
42];
43
44pub struct ShellTool {
46 security: Arc<SecurityPolicy>,
47 runtime: Arc<dyn RuntimeAdapter>,
48 sandbox: Arc<dyn Sandbox>,
49 timeout_secs: u64,
50}
51
52impl ShellTool {
53 pub fn new(security: Arc<SecurityPolicy>, runtime: Arc<dyn RuntimeAdapter>) -> Self {
54 Self {
55 security,
56 runtime,
57 sandbox: Arc::new(crate::security::NoopSandbox),
58 timeout_secs: DEFAULT_SHELL_TIMEOUT_SECS,
59 }
60 }
61
62 pub fn new_with_sandbox(
63 security: Arc<SecurityPolicy>,
64 runtime: Arc<dyn RuntimeAdapter>,
65 sandbox: Arc<dyn Sandbox>,
66 ) -> Self {
67 Self {
68 security,
69 runtime,
70 sandbox,
71 timeout_secs: DEFAULT_SHELL_TIMEOUT_SECS,
72 }
73 }
74
75 pub fn with_timeout_secs(mut self, secs: u64) -> Self {
77 self.timeout_secs = secs;
78 self
79 }
80}
81
82fn is_valid_env_var_name(name: &str) -> bool {
83 let mut chars = name.chars();
84 match chars.next() {
85 Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
86 _ => return false,
87 }
88 chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
89}
90
91fn collect_allowed_shell_env_vars(security: &SecurityPolicy) -> Vec<String> {
92 let mut out = Vec::new();
93 let mut seen = HashSet::new();
94 for key in SAFE_ENV_VARS
95 .iter()
96 .copied()
97 .chain(security.shell_env_passthrough.iter().map(|s| s.as_str()))
98 {
99 let candidate = key.trim();
100 if candidate.is_empty() || !is_valid_env_var_name(candidate) {
101 continue;
102 }
103 if seen.insert(candidate.to_string()) {
104 out.push(candidate.to_string());
105 }
106 }
107 out
108}
109
110#[async_trait]
111impl Tool for ShellTool {
112 fn name(&self) -> &str {
113 "shell"
114 }
115
116 fn description(&self) -> &str {
117 "Execute a shell command in the workspace directory"
118 }
119
120 fn parameters_schema(&self) -> serde_json::Value {
121 json!({
122 "type": "object",
123 "properties": {
124 "command": {
125 "type": "string",
126 "description": "The shell command to execute"
127 },
128 "approved": {
129 "type": "boolean",
130 "description": "Set true to explicitly approve medium/high-risk commands in supervised mode",
131 "default": false
132 }
133 },
134 "required": ["command"]
135 })
136 }
137
138 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
139 let command = args
140 .get("command")
141 .and_then(|v| v.as_str())
142 .ok_or_else(|| anyhow::anyhow!("Missing 'command' parameter"))?;
143 let approved = args
144 .get("approved")
145 .and_then(|v| v.as_bool())
146 .unwrap_or(false);
147
148 if self.security.is_rate_limited() {
149 return Ok(ToolResult {
150 success: false,
151 output: String::new(),
152 error: Some("Rate limit exceeded: too many actions in the last hour".into()),
153 });
154 }
155
156 match self.security.validate_command_execution(command, approved) {
157 Ok(_) => {}
158 Err(reason) => {
159 return Ok(ToolResult {
160 success: false,
161 output: String::new(),
162 error: Some(reason),
163 });
164 }
165 }
166
167 if let Some(path) = self.security.forbidden_path_argument(command) {
168 return Ok(ToolResult {
169 success: false,
170 output: String::new(),
171 error: Some(format!("Path blocked by security policy: {path}")),
172 });
173 }
174
175 if !self.security.record_action() {
176 return Ok(ToolResult {
177 success: false,
178 output: String::new(),
179 error: Some("Rate limit exceeded: action budget exhausted".into()),
180 });
181 }
182
183 let mut cmd = match self
187 .runtime
188 .build_shell_command(command, &self.security.workspace_dir)
189 {
190 Ok(cmd) => cmd,
191 Err(e) => {
192 return Ok(ToolResult {
193 success: false,
194 output: String::new(),
195 error: Some(format!("Failed to build runtime command: {e}")),
196 });
197 }
198 };
199
200 self.sandbox
204 .wrap_command(cmd.as_std_mut())
205 .map_err(|e| anyhow::anyhow!("Sandbox error: {}", e))?;
206
207 cmd.env_clear();
208
209 for var in collect_allowed_shell_env_vars(&self.security) {
210 if let Ok(val) = std::env::var(&var) {
211 cmd.env(&var, val);
212 }
213 }
214
215 let timeout_secs = self.timeout_secs;
216 let result = tokio::time::timeout(Duration::from_secs(timeout_secs), cmd.output()).await;
217
218 match result {
219 Ok(Ok(output)) => {
220 let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
221 let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
222
223 if stdout.len() > MAX_OUTPUT_BYTES {
225 let mut b = MAX_OUTPUT_BYTES.min(stdout.len());
226 while b > 0 && !stdout.is_char_boundary(b) {
227 b -= 1;
228 }
229 stdout.truncate(b);
230 stdout.push_str("\n... [output truncated at 1MB]");
231 }
232 if stderr.len() > MAX_OUTPUT_BYTES {
233 let mut b = MAX_OUTPUT_BYTES.min(stderr.len());
234 while b > 0 && !stderr.is_char_boundary(b) {
235 b -= 1;
236 }
237 stderr.truncate(b);
238 stderr.push_str("\n... [stderr truncated at 1MB]");
239 }
240
241 Ok(ToolResult {
242 success: output.status.success(),
243 output: stdout,
244 error: if stderr.is_empty() {
245 None
246 } else {
247 Some(stderr)
248 },
249 })
250 }
251 Ok(Err(e)) => Ok(ToolResult {
252 success: false,
253 output: String::new(),
254 error: Some(format!("Failed to execute command: {e}")),
255 }),
256 Err(_) => Ok(ToolResult {
257 success: false,
258 output: String::new(),
259 error: Some(format!(
260 "Command timed out after {timeout_secs}s and was killed"
261 )),
262 }),
263 }
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::runtime::{NativeRuntime, RuntimeAdapter};
271 use crate::security::{AutonomyLevel, SecurityPolicy};
272
273 fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
274 Arc::new(SecurityPolicy {
275 autonomy,
276 workspace_dir: std::env::temp_dir(),
277 ..SecurityPolicy::default()
278 })
279 }
280
281 fn test_runtime() -> Arc<dyn RuntimeAdapter> {
282 Arc::new(NativeRuntime::new())
283 }
284
285 #[test]
286 fn shell_tool_name() {
287 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
288 assert_eq!(tool.name(), "shell");
289 }
290
291 #[test]
292 fn shell_tool_description() {
293 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
294 assert!(!tool.description().is_empty());
295 }
296
297 #[test]
298 fn shell_tool_schema_has_command() {
299 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
300 let schema = tool.parameters_schema();
301 assert!(schema["properties"]["command"].is_object());
302 assert!(
303 schema["required"]
304 .as_array()
305 .expect("schema required field should be an array")
306 .contains(&json!("command"))
307 );
308 assert!(schema["properties"]["approved"].is_object());
309 }
310
311 #[tokio::test]
312 async fn shell_executes_allowed_command() {
313 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
314 let result = tool
315 .execute(json!({"command": "echo hello"}))
316 .await
317 .expect("echo command execution should succeed");
318 assert!(result.success);
319 assert!(result.output.trim().contains("hello"));
320 assert!(result.error.is_none());
321 }
322
323 #[tokio::test]
324 async fn shell_blocks_disallowed_command() {
325 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
326 let result = tool
327 .execute(json!({"command": "rm -rf /"}))
328 .await
329 .expect("disallowed command execution should return a result");
330 assert!(!result.success);
331 let error = result.error.as_deref().unwrap_or("");
332 assert!(error.contains("not allowed") || error.contains("high-risk"));
333 }
334
335 #[tokio::test]
336 async fn shell_blocks_readonly() {
337 let tool = ShellTool::new(test_security(AutonomyLevel::ReadOnly), test_runtime());
338 let result = tool
339 .execute(json!({"command": "ls"}))
340 .await
341 .expect("readonly command execution should return a result");
342 assert!(!result.success);
343 assert!(
344 result
345 .error
346 .as_ref()
347 .expect("error field should be present for blocked command")
348 .contains("not allowed")
349 );
350 }
351
352 #[tokio::test]
353 async fn shell_missing_command_param() {
354 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
355 let result = tool.execute(json!({})).await;
356 assert!(result.is_err());
357 assert!(result.unwrap_err().to_string().contains("command"));
358 }
359
360 #[tokio::test]
361 async fn shell_wrong_type_param() {
362 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
363 let result = tool.execute(json!({"command": 123})).await;
364 assert!(result.is_err());
365 }
366
367 #[tokio::test]
368 async fn shell_captures_exit_code() {
369 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
370 let result = tool
371 .execute(json!({"command": "ls /nonexistent_dir_xyz"}))
372 .await
373 .expect("command with nonexistent path should return a result");
374 assert!(!result.success);
375 }
376
377 #[tokio::test]
378 async fn shell_blocks_absolute_path_argument() {
379 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
380 let result = tool
381 .execute(json!({"command": "cat /etc/passwd"}))
382 .await
383 .expect("absolute path argument should be blocked");
384 assert!(!result.success);
385 assert!(
386 result
387 .error
388 .as_deref()
389 .unwrap_or("")
390 .contains("Path blocked")
391 );
392 }
393
394 #[tokio::test]
395 async fn shell_blocks_option_assignment_path_argument() {
396 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
397 let result = tool
398 .execute(json!({"command": "grep --file=/etc/passwd root ./src"}))
399 .await
400 .expect("option-assigned forbidden path should be blocked");
401 assert!(!result.success);
402 assert!(
403 result
404 .error
405 .as_deref()
406 .unwrap_or("")
407 .contains("Path blocked")
408 );
409 }
410
411 #[tokio::test]
412 async fn shell_blocks_short_option_attached_path_argument() {
413 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
414 let result = tool
415 .execute(json!({"command": "grep -f/etc/passwd root ./src"}))
416 .await
417 .expect("short option attached forbidden path should be blocked");
418 assert!(!result.success);
419 assert!(
420 result
421 .error
422 .as_deref()
423 .unwrap_or("")
424 .contains("Path blocked")
425 );
426 }
427
428 #[tokio::test]
429 async fn shell_blocks_tilde_user_path_argument() {
430 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
431 let result = tool
432 .execute(json!({"command": "cat ~root/.ssh/id_rsa"}))
433 .await
434 .expect("tilde-user path should be blocked");
435 assert!(!result.success);
436 assert!(
437 result
438 .error
439 .as_deref()
440 .unwrap_or("")
441 .contains("Path blocked")
442 );
443 }
444
445 #[tokio::test]
446 async fn shell_blocks_input_redirection_path_bypass() {
447 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
448 let result = tool
449 .execute(json!({"command": "cat </etc/passwd"}))
450 .await
451 .expect("input redirection bypass should be blocked");
452 assert!(!result.success);
453 assert!(
454 result
455 .error
456 .as_deref()
457 .unwrap_or("")
458 .contains("not allowed")
459 );
460 }
461
462 fn test_security_with_env_cmd() -> Arc<SecurityPolicy> {
463 Arc::new(SecurityPolicy {
464 autonomy: AutonomyLevel::Supervised,
465 workspace_dir: std::env::temp_dir(),
466 allowed_commands: vec!["env".into(), "echo".into()],
467 ..SecurityPolicy::default()
468 })
469 }
470
471 fn test_security_with_env_passthrough(vars: &[&str]) -> Arc<SecurityPolicy> {
472 Arc::new(SecurityPolicy {
473 autonomy: AutonomyLevel::Supervised,
474 workspace_dir: std::env::temp_dir(),
475 allowed_commands: vec!["env".into()],
476 shell_env_passthrough: vars.iter().map(|v| (*v).to_string()).collect(),
477 ..SecurityPolicy::default()
478 })
479 }
480
481 struct EnvGuard {
484 key: &'static str,
485 original: Option<String>,
486 }
487
488 impl EnvGuard {
489 fn set(key: &'static str, value: &str) -> Self {
490 let original = std::env::var(key).ok();
491 unsafe { std::env::set_var(key, value) };
493 Self { key, original }
494 }
495 }
496
497 impl Drop for EnvGuard {
498 fn drop(&mut self) {
499 match &self.original {
500 Some(val) => unsafe { std::env::set_var(self.key, val) },
502 None => unsafe { std::env::remove_var(self.key) },
504 }
505 }
506 }
507
508 #[tokio::test(flavor = "current_thread")]
509 async fn shell_does_not_leak_api_key() {
510 let _g1 = EnvGuard::set("API_KEY", "sk-test-secret-12345");
511 let _g2 = EnvGuard::set("CONSTRUCT_API_KEY", "sk-test-secret-67890");
512
513 let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
514 let result = tool
515 .execute(json!({"command": "env"}))
516 .await
517 .expect("env command execution should succeed");
518 assert!(result.success);
519 assert!(
520 !result.output.contains("sk-test-secret-12345"),
521 "API_KEY leaked to shell command output"
522 );
523 assert!(
524 !result.output.contains("sk-test-secret-67890"),
525 "CONSTRUCT_API_KEY leaked to shell command output"
526 );
527 }
528
529 #[tokio::test]
530 async fn shell_preserves_path_and_home_for_env_command() {
531 let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
532
533 let result = tool
534 .execute(json!({"command": "env"}))
535 .await
536 .expect("env command should succeed");
537 assert!(result.success);
538 assert!(
539 result.output.contains("HOME="),
540 "HOME should be available in shell environment"
541 );
542 assert!(
543 result.output.contains("PATH="),
544 "PATH should be available in shell environment"
545 );
546 }
547
548 #[tokio::test]
549 async fn shell_blocks_plain_variable_expansion() {
550 let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
551 let result = tool
552 .execute(json!({"command": "echo $HOME"}))
553 .await
554 .expect("plain variable expansion should be blocked");
555 assert!(!result.success);
556 assert!(
557 result
558 .error
559 .as_deref()
560 .unwrap_or("")
561 .contains("not allowed")
562 );
563 }
564
565 #[tokio::test(flavor = "current_thread")]
566 async fn shell_allows_configured_env_passthrough() {
567 let _guard = EnvGuard::set("CONSTRUCT_TEST_PASSTHROUGH", "db://unit-test");
568 let tool = ShellTool::new(
569 test_security_with_env_passthrough(&["CONSTRUCT_TEST_PASSTHROUGH"]),
570 test_runtime(),
571 );
572
573 let result = tool
574 .execute(json!({"command": "env"}))
575 .await
576 .expect("env command execution should succeed");
577 assert!(result.success);
578 assert!(
579 result
580 .output
581 .contains("CONSTRUCT_TEST_PASSTHROUGH=db://unit-test")
582 );
583 }
584
585 #[test]
586 fn invalid_shell_env_passthrough_names_are_filtered() {
587 let security = SecurityPolicy {
588 shell_env_passthrough: vec![
589 "VALID_NAME".into(),
590 "BAD-NAME".into(),
591 "1NOPE".into(),
592 "ALSO_VALID".into(),
593 ],
594 ..SecurityPolicy::default()
595 };
596 let vars = collect_allowed_shell_env_vars(&security);
597 assert!(vars.contains(&"VALID_NAME".to_string()));
598 assert!(vars.contains(&"ALSO_VALID".to_string()));
599 assert!(!vars.contains(&"BAD-NAME".to_string()));
600 assert!(!vars.contains(&"1NOPE".to_string()));
601 }
602
603 #[tokio::test]
604 async fn shell_requires_approval_for_medium_risk_command() {
605 let security = Arc::new(SecurityPolicy {
606 autonomy: AutonomyLevel::Supervised,
607 allowed_commands: vec!["touch".into()],
608 workspace_dir: std::env::temp_dir(),
609 ..SecurityPolicy::default()
610 });
611
612 let tool = ShellTool::new(security.clone(), test_runtime());
613 let denied = tool
614 .execute(json!({"command": "touch construct_shell_approval_test"}))
615 .await
616 .expect("unapproved command should return a result");
617 assert!(!denied.success);
618 assert!(
619 denied
620 .error
621 .as_deref()
622 .unwrap_or("")
623 .contains("explicit approval")
624 );
625
626 let allowed = tool
627 .execute(json!({
628 "command": "touch construct_shell_approval_test",
629 "approved": true
630 }))
631 .await
632 .expect("approved command execution should succeed");
633 assert!(allowed.success);
634
635 let _ = tokio::fs::remove_file(std::env::temp_dir().join("construct_shell_approval_test"))
636 .await;
637 }
638
639 #[test]
642 fn shell_timeout_default_is_reasonable() {
643 assert_eq!(
644 DEFAULT_SHELL_TIMEOUT_SECS, 60,
645 "default shell timeout must be 60 seconds"
646 );
647 }
648
649 #[test]
650 fn shell_timeout_can_be_overridden() {
651 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime())
652 .with_timeout_secs(120);
653 assert_eq!(tool.timeout_secs, 120);
654 }
655
656 #[test]
657 fn shell_output_limit_is_1mb() {
658 assert_eq!(
659 MAX_OUTPUT_BYTES, 1_048_576,
660 "max output must be 1 MB to prevent OOM"
661 );
662 }
663
664 #[test]
667 fn shell_safe_env_vars_excludes_secrets() {
668 for var in SAFE_ENV_VARS {
669 let lower = var.to_lowercase();
670 assert!(
671 !lower.contains("key") && !lower.contains("secret") && !lower.contains("token"),
672 "SAFE_ENV_VARS must not include sensitive variable: {var}"
673 );
674 }
675 }
676
677 #[test]
678 fn shell_safe_env_vars_includes_essentials() {
679 assert!(
680 SAFE_ENV_VARS.contains(&"PATH"),
681 "PATH must be in safe env vars"
682 );
683 assert!(
684 SAFE_ENV_VARS.contains(&"HOME") || SAFE_ENV_VARS.contains(&"USERPROFILE"),
685 "HOME or USERPROFILE must be in safe env vars"
686 );
687 assert!(
688 SAFE_ENV_VARS.contains(&"TERM"),
689 "TERM must be in safe env vars"
690 );
691 }
692
693 #[tokio::test]
694 async fn shell_blocks_rate_limited() {
695 let security = Arc::new(SecurityPolicy {
696 autonomy: AutonomyLevel::Supervised,
697 max_actions_per_hour: 0,
698 workspace_dir: std::env::temp_dir(),
699 ..SecurityPolicy::default()
700 });
701 let tool = ShellTool::new(security, test_runtime());
702 let result = tool
703 .execute(json!({"command": "echo test"}))
704 .await
705 .expect("rate-limited command should return a result");
706 assert!(!result.success);
707 assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
708 }
709
710 #[tokio::test]
711 async fn shell_handles_nonexistent_command() {
712 let security = Arc::new(SecurityPolicy {
713 autonomy: AutonomyLevel::Full,
714 workspace_dir: std::env::temp_dir(),
715 ..SecurityPolicy::default()
716 });
717 let tool = ShellTool::new(security, test_runtime());
718 let result = tool
719 .execute(json!({"command": "nonexistent_binary_xyz_12345"}))
720 .await
721 .unwrap();
722 assert!(!result.success);
723 }
724
725 #[tokio::test]
726 async fn shell_captures_stderr_output() {
727 let tool = ShellTool::new(test_security(AutonomyLevel::Full), test_runtime());
728 let result = tool
729 .execute(json!({"command": "echo error_msg >&2"}))
730 .await
731 .unwrap();
732 assert!(result.error.as_deref().unwrap_or("").contains("error_msg"));
733 }
734
735 #[tokio::test]
736 async fn shell_record_action_budget_exhaustion() {
737 let security = Arc::new(SecurityPolicy {
738 autonomy: AutonomyLevel::Full,
739 max_actions_per_hour: 1,
740 workspace_dir: std::env::temp_dir(),
741 ..SecurityPolicy::default()
742 });
743 let tool = ShellTool::new(security, test_runtime());
744
745 let r1 = tool
746 .execute(json!({"command": "echo first"}))
747 .await
748 .unwrap();
749 assert!(r1.success);
750
751 let r2 = tool
752 .execute(json!({"command": "echo second"}))
753 .await
754 .unwrap();
755 assert!(!r2.success);
756 assert!(
757 r2.error.as_deref().unwrap_or("").contains("Rate limit")
758 || r2.error.as_deref().unwrap_or("").contains("budget")
759 );
760 }
761
762 #[test]
765 fn shell_tool_can_be_constructed_with_sandbox() {
766 use crate::security::NoopSandbox;
767
768 let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
769 let tool = ShellTool::new_with_sandbox(
770 test_security(AutonomyLevel::Supervised),
771 test_runtime(),
772 sandbox,
773 );
774 assert_eq!(tool.name(), "shell");
775 }
776
777 #[test]
778 fn noop_sandbox_does_not_modify_command() {
779 use crate::security::NoopSandbox;
780
781 let sandbox = NoopSandbox;
782 let mut cmd = std::process::Command::new("echo");
783 cmd.arg("hello");
784
785 let program_before = cmd.get_program().to_os_string();
786 let args_before: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect();
787
788 sandbox
789 .wrap_command(&mut cmd)
790 .expect("wrap_command should succeed");
791
792 assert_eq!(cmd.get_program(), program_before);
793 assert_eq!(
794 cmd.get_args().map(|a| a.to_os_string()).collect::<Vec<_>>(),
795 args_before
796 );
797 }
798
799 #[tokio::test]
800 async fn shell_executes_with_sandbox() {
801 use crate::security::NoopSandbox;
802
803 let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
804 let tool = ShellTool::new_with_sandbox(
805 test_security(AutonomyLevel::Supervised),
806 test_runtime(),
807 sandbox,
808 );
809 let result = tool
810 .execute(json!({"command": "echo sandbox_test"}))
811 .await
812 .expect("command with sandbox should succeed");
813 assert!(result.success);
814 assert!(result.output.contains("sandbox_test"));
815 }
816}