Skip to main content

construct/tools/
shell.rs

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
11/// Default maximum shell command execution time before kill.
12const DEFAULT_SHELL_TIMEOUT_SECS: u64 = 60;
13/// Maximum output size in bytes (1MB).
14const MAX_OUTPUT_BYTES: usize = 1_048_576;
15
16/// Environment variables safe to pass to shell commands.
17/// Only functional variables are included — never API keys or secrets.
18#[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/// Environment variables safe to pass to shell commands on Windows.
24/// Includes Windows-specific variables needed for cmd.exe and program resolution.
25#[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
44/// Shell command execution tool with sandboxing
45pub 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    /// Override the command execution timeout (in seconds).
76    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        // Execute with timeout to prevent hanging commands.
184        // Clear the environment to prevent leaking API keys and other secrets
185        // (CWE-200), then re-add only safe, functional variables.
186        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        // Apply sandbox wrapping before execution.
201        // The Sandbox trait operates on std::process::Command, so use as_std_mut()
202        // to get a mutable reference to the underlying command.
203        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                // Truncate output to prevent OOM
224                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    /// RAII guard that restores an environment variable to its original state on drop,
482    /// ensuring cleanup even if the test panics.
483    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            // SAFETY: test-only, single-threaded test runner.
492            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                // SAFETY: test-only, single-threaded test runner.
501                Some(val) => unsafe { std::env::set_var(self.key, val) },
502                // SAFETY: test-only, single-threaded test runner.
503                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    // ── shell timeout enforcement tests ─────────────────
640
641    #[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    // ── Non-UTF8 binary output tests ────────────────────
665
666    #[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    // ── Sandbox integration tests ────────────────────────
763
764    #[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}