Skip to main content

zeroclaw_runtime/tools/
shell.rs

1use crate::platform::RuntimeAdapter;
2use crate::security::SecurityPolicy;
3use crate::security::traits::Sandbox;
4use async_trait::async_trait;
5use serde_json::json;
6use std::collections::HashSet;
7use std::sync::Arc;
8use std::time::Duration;
9use zeroclaw_api::tool::{Tool, ToolResult};
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
82/// Decode raw process output bytes to a UTF-8 String.
83///
84/// On Windows, cmd.exe emits bytes in the active console output code page
85/// (e.g. CP936/GBK on Simplified Chinese systems). We query the code page at
86/// runtime and transcode via `encoding_rs` so non-ASCII characters survive
87/// intact instead of being replaced by U+FFFD.
88///
89/// On all other platforms the shell runs under the user's locale (usually
90/// UTF-8 already), so `from_utf8_lossy` is sufficient.
91#[cfg(target_os = "windows")]
92fn decode_output(bytes: &[u8]) -> String {
93    use windows::Win32::Globalization::GetACP;
94    use windows::Win32::System::Console::GetConsoleOutputCP;
95
96    let cp = unsafe { GetConsoleOutputCP() };
97    let cp = if cp == 0 { unsafe { GetACP() } } else { cp };
98
99    let encoding = windows_code_page_to_encoding(cp);
100    if std::ptr::eq(encoding, encoding_rs::UTF_8) {
101        String::from_utf8_lossy(bytes).into_owned()
102    } else {
103        let (cow, _enc_used, _had_errors) = encoding.decode(bytes);
104        cow.into_owned()
105    }
106}
107
108/// Map a Windows code page identifier to an `encoding_rs` `Encoding`.
109/// Falls back to UTF-8 (lossy) for unknown code pages.
110#[cfg(target_os = "windows")]
111fn windows_code_page_to_encoding(cp: u32) -> &'static encoding_rs::Encoding {
112    match cp {
113        932 => encoding_rs::SHIFT_JIS,
114        936 | 54936 => encoding_rs::GBK,
115        949 => encoding_rs::EUC_KR,
116        950 => encoding_rs::BIG5,
117        1250 => encoding_rs::WINDOWS_1250,
118        1251 => encoding_rs::WINDOWS_1251,
119        1252 => encoding_rs::WINDOWS_1252,
120        1253 => encoding_rs::WINDOWS_1253,
121        1254 => encoding_rs::WINDOWS_1254,
122        1255 => encoding_rs::WINDOWS_1255,
123        1256 => encoding_rs::WINDOWS_1256,
124        1257 => encoding_rs::WINDOWS_1257,
125        1258 => encoding_rs::WINDOWS_1258,
126        20127 | 65001 => encoding_rs::UTF_8,
127        _ => encoding_rs::UTF_8,
128    }
129}
130
131#[cfg(not(target_os = "windows"))]
132fn decode_output(bytes: &[u8]) -> String {
133    String::from_utf8_lossy(bytes).into_owned()
134}
135
136fn is_valid_env_var_name(name: &str) -> bool {
137    let mut chars = name.chars();
138    match chars.next() {
139        Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
140        _ => return false,
141    }
142    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
143}
144
145fn collect_allowed_shell_env_vars(security: &SecurityPolicy) -> Vec<String> {
146    let mut out = Vec::new();
147    let mut seen = HashSet::new();
148    for key in SAFE_ENV_VARS
149        .iter()
150        .copied()
151        .chain(security.shell_env_passthrough.iter().map(|s| s.as_str()))
152    {
153        let candidate = key.trim();
154        if candidate.is_empty() || !is_valid_env_var_name(candidate) {
155            continue;
156        }
157        if seen.insert(candidate.to_string()) {
158            out.push(candidate.to_string());
159        }
160    }
161    out
162}
163
164#[async_trait]
165impl Tool for ShellTool {
166    fn name(&self) -> &str {
167        "shell"
168    }
169
170    fn description(&self) -> &str {
171        "Execute a shell command in the workspace directory"
172    }
173
174    fn parameters_schema(&self) -> serde_json::Value {
175        json!({
176            "type": "object",
177            "properties": {
178                "command": {
179                    "type": "string",
180                    "description": "The shell command to execute"
181                },
182                "approved": {
183                    "type": "boolean",
184                    "description": "Set true to explicitly approve medium/high-risk commands in supervised mode",
185                    "default": false
186                }
187            },
188            "required": ["command"]
189        })
190    }
191
192    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
193        let command = args
194            .get("command")
195            .and_then(|v| v.as_str())
196            .ok_or_else(|| {
197                ::zeroclaw_log::record!(
198                    WARN,
199                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
200                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
201                        .with_attrs(::serde_json::json!({"param": "command"})),
202                    "tool argument validation failed"
203                );
204
205                anyhow::Error::msg("Missing 'command' parameter")
206            })?;
207        let approved = args
208            .get("approved")
209            .and_then(|v| v.as_bool())
210            .unwrap_or(false);
211
212        match self.security.validate_command_execution(command, approved) {
213            Ok(_) => {}
214            Err(reason) => {
215                return Ok(ToolResult {
216                    success: false,
217                    output: String::new(),
218                    error: Some(reason),
219                });
220            }
221        }
222
223        // Execute with timeout to prevent hanging commands.
224        // Clear the environment to prevent leaking API keys and other secrets
225        // (CWE-200), then re-add only safe, functional variables.
226        let mut cmd = match self
227            .runtime
228            .build_shell_command(command, &self.security.workspace_dir)
229        {
230            Ok(cmd) => cmd,
231            Err(e) => {
232                return Ok(ToolResult {
233                    success: false,
234                    output: String::new(),
235                    error: Some(format!("Failed to build runtime command: {e}")),
236                });
237            }
238        };
239
240        // Apply sandbox wrapping before execution.
241        // The Sandbox trait operates on std::process::Command, so use as_std_mut()
242        // to get a mutable reference to the underlying command.
243        self.sandbox.wrap_command(cmd.as_std_mut()).map_err(|e| {
244            ::zeroclaw_log::record!(
245                ERROR,
246                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
247                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
248                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
249                "shell tool: sandbox wrap_command failed"
250            );
251            anyhow::Error::msg(format!("Sandbox error: {e}"))
252        })?;
253
254        cmd.env_clear();
255
256        for var in collect_allowed_shell_env_vars(&self.security) {
257            if let Ok(val) = std::env::var(&var) {
258                cmd.env(&var, val);
259            }
260        }
261
262        let timeout_secs = self.timeout_secs;
263        let result = tokio::time::timeout(Duration::from_secs(timeout_secs), cmd.output()).await;
264
265        match result {
266            Ok(Ok(output)) => {
267                let mut stdout = decode_output(&output.stdout);
268                let mut stderr = decode_output(&output.stderr);
269
270                // Truncate output to prevent OOM
271                if stdout.len() > MAX_OUTPUT_BYTES {
272                    let mut b = MAX_OUTPUT_BYTES.min(stdout.len());
273                    while b > 0 && !stdout.is_char_boundary(b) {
274                        b -= 1;
275                    }
276                    stdout.truncate(b);
277                    stdout.push_str("\n... [output truncated at 1MB]");
278                }
279                if stderr.len() > MAX_OUTPUT_BYTES {
280                    let mut b = MAX_OUTPUT_BYTES.min(stderr.len());
281                    while b > 0 && !stderr.is_char_boundary(b) {
282                        b -= 1;
283                    }
284                    stderr.truncate(b);
285                    stderr.push_str("\n... [stderr truncated at 1MB]");
286                }
287
288                Ok(ToolResult {
289                    success: output.status.success(),
290                    output: stdout,
291                    error: if stderr.is_empty() {
292                        None
293                    } else {
294                        Some(stderr)
295                    },
296                })
297            }
298            Ok(Err(e)) => Ok(ToolResult {
299                success: false,
300                output: String::new(),
301                error: Some(format!("Failed to execute command: {e}")),
302            }),
303            Err(_) => Ok(ToolResult {
304                success: false,
305                output: String::new(),
306                error: Some(format!(
307                    "Command timed out after {timeout_secs}s and was killed"
308                )),
309            }),
310        }
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::platform::{NativeRuntime, RuntimeAdapter};
318    use crate::security::{AutonomyLevel, SecurityPolicy};
319    use zeroclaw_tools::wrappers::{PathGuardedTool, RateLimitedTool};
320
321    fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
322        Arc::new(SecurityPolicy {
323            autonomy,
324            workspace_dir: std::env::temp_dir(),
325            ..SecurityPolicy::default()
326        })
327    }
328
329    fn test_runtime() -> Arc<dyn RuntimeAdapter> {
330        Arc::new(NativeRuntime::new())
331    }
332
333    /// Returns the fully-wrapped shell tool as it is composed in production:
334    /// RateLimited(PathGuarded(ShellTool)).  Tests that verify path-blocking or
335    /// rate-limiting behaviour must use this helper so they exercise the wrappers.
336    fn wrapped_shell(security: Arc<SecurityPolicy>) -> RateLimitedTool<PathGuardedTool<ShellTool>> {
337        RateLimitedTool::new(
338            PathGuardedTool::new(
339                ShellTool::new(security.clone(), test_runtime()),
340                security.clone(),
341            ),
342            security,
343        )
344    }
345
346    #[test]
347    fn shell_tool_name() {
348        let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
349        assert_eq!(tool.name(), "shell");
350    }
351
352    #[test]
353    fn shell_tool_description() {
354        let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
355        assert!(!tool.description().is_empty());
356    }
357
358    #[test]
359    fn shell_tool_schema_has_command() {
360        let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
361        let schema = tool.parameters_schema();
362        assert!(schema["properties"]["command"].is_object());
363        assert!(
364            schema["required"]
365                .as_array()
366                .expect("schema required field should be an array")
367                .contains(&json!("command"))
368        );
369        assert!(schema["properties"]["approved"].is_object());
370    }
371
372    #[tokio::test]
373    async fn shell_executes_allowed_command() {
374        let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
375        let result = tool
376            .execute(json!({"command": "echo hello"}))
377            .await
378            .expect("echo command execution should succeed");
379        assert!(result.success);
380        assert!(result.output.trim().contains("hello"));
381        assert!(result.error.is_none());
382    }
383
384    #[tokio::test]
385    async fn shell_blocks_disallowed_command() {
386        let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
387        let result = tool
388            .execute(json!({"command": "rm -rf /"}))
389            .await
390            .expect("disallowed command execution should return a result");
391        assert!(!result.success);
392        let error = result.error.as_deref().unwrap_or("");
393        assert!(error.contains("not allowed") || error.contains("high-risk"));
394    }
395
396    #[tokio::test]
397    async fn shell_blocks_readonly() {
398        let tool = ShellTool::new(test_security(AutonomyLevel::ReadOnly), test_runtime());
399        let result = tool
400            .execute(json!({"command": "ls"}))
401            .await
402            .expect("readonly command execution should return a result");
403        assert!(!result.success);
404        assert!(
405            result
406                .error
407                .as_ref()
408                .expect("error field should be present for blocked command")
409                .contains("not allowed")
410        );
411    }
412
413    #[tokio::test]
414    async fn shell_missing_command_param() {
415        let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
416        let result = tool.execute(json!({})).await;
417        assert!(result.is_err());
418        assert!(result.unwrap_err().to_string().contains("command"));
419    }
420
421    #[tokio::test]
422    async fn shell_wrong_type_param() {
423        let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
424        let result = tool.execute(json!({"command": 123})).await;
425        assert!(result.is_err());
426    }
427
428    #[tokio::test]
429    async fn shell_captures_exit_code() {
430        let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
431        let result = tool
432            .execute(json!({"command": "ls /nonexistent_dir_xyz"}))
433            .await
434            .expect("command with nonexistent path should return a result");
435        assert!(!result.success);
436    }
437
438    #[tokio::test]
439    async fn shell_blocks_absolute_path_argument() {
440        let tool = wrapped_shell(test_security(AutonomyLevel::Supervised));
441        let result = tool
442            .execute(json!({"command": "cat /etc/passwd"}))
443            .await
444            .expect("absolute path argument should be blocked");
445        assert!(!result.success);
446        assert!(
447            result
448                .error
449                .as_deref()
450                .unwrap_or("")
451                .contains("Path blocked")
452        );
453    }
454
455    #[tokio::test]
456    async fn shell_blocks_option_assignment_path_argument() {
457        let tool = wrapped_shell(test_security(AutonomyLevel::Supervised));
458        let result = tool
459            .execute(json!({"command": "grep --file=/etc/passwd root ./src"}))
460            .await
461            .expect("option-assigned forbidden path should be blocked");
462        assert!(!result.success);
463        assert!(
464            result
465                .error
466                .as_deref()
467                .unwrap_or("")
468                .contains("Path blocked")
469        );
470    }
471
472    #[tokio::test]
473    async fn shell_blocks_short_option_attached_path_argument() {
474        let tool = wrapped_shell(test_security(AutonomyLevel::Supervised));
475        let result = tool
476            .execute(json!({"command": "grep -f/etc/passwd root ./src"}))
477            .await
478            .expect("short option attached forbidden path should be blocked");
479        assert!(!result.success);
480        assert!(
481            result
482                .error
483                .as_deref()
484                .unwrap_or("")
485                .contains("Path blocked")
486        );
487    }
488
489    #[tokio::test]
490    async fn shell_blocks_tilde_user_path_argument() {
491        let tool = wrapped_shell(test_security(AutonomyLevel::Supervised));
492        let result = tool
493            .execute(json!({"command": "cat ~root/.ssh/id_rsa"}))
494            .await
495            .expect("tilde-user path should be blocked");
496        assert!(!result.success);
497        assert!(
498            result
499                .error
500                .as_deref()
501                .unwrap_or("")
502                .contains("Path blocked")
503        );
504    }
505
506    #[tokio::test]
507    async fn shell_blocks_input_redirection_path_bypass() {
508        let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
509        let result = tool
510            .execute(json!({"command": "cat </etc/passwd"}))
511            .await
512            .expect("input redirection bypass should be blocked");
513        assert!(!result.success);
514        assert!(
515            result
516                .error
517                .as_deref()
518                .unwrap_or("")
519                .contains("not allowed")
520        );
521    }
522
523    fn test_security_with_env_cmd() -> Arc<SecurityPolicy> {
524        Arc::new(SecurityPolicy {
525            autonomy: AutonomyLevel::Supervised,
526            workspace_dir: std::env::temp_dir(),
527            allowed_commands: vec!["env".into(), "echo".into()],
528            ..SecurityPolicy::default()
529        })
530    }
531
532    fn test_security_with_env_passthrough(vars: &[&str]) -> Arc<SecurityPolicy> {
533        Arc::new(SecurityPolicy {
534            autonomy: AutonomyLevel::Supervised,
535            workspace_dir: std::env::temp_dir(),
536            allowed_commands: vec!["env".into()],
537            shell_env_passthrough: vars.iter().map(|v| (*v).to_string()).collect(),
538            ..SecurityPolicy::default()
539        })
540    }
541
542    /// RAII guard that restores an environment variable to its original state on drop,
543    /// ensuring cleanup even if the test panics.
544    struct EnvGuard {
545        key: &'static str,
546        original: Option<String>,
547    }
548
549    impl EnvGuard {
550        fn set(key: &'static str, value: &str) -> Self {
551            let original = std::env::var(key).ok();
552            // SAFETY: test-only, single-threaded test runner.
553            unsafe { std::env::set_var(key, value) };
554            Self { key, original }
555        }
556    }
557
558    impl Drop for EnvGuard {
559        fn drop(&mut self) {
560            match &self.original {
561                // SAFETY: test-only, single-threaded test runner.
562                Some(val) => unsafe { std::env::set_var(self.key, val) },
563                // SAFETY: test-only, single-threaded test runner.
564                None => unsafe { std::env::remove_var(self.key) },
565            }
566        }
567    }
568
569    #[tokio::test(flavor = "current_thread")]
570    async fn shell_does_not_leak_api_key() {
571        let _g1 = EnvGuard::set("API_KEY", "sk-test-secret-12345");
572        let _g2 = EnvGuard::set("ZEROCLAW_API_KEY", "sk-test-secret-67890");
573
574        let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
575        let result = tool
576            .execute(json!({"command": "env"}))
577            .await
578            .expect("env command execution should succeed");
579        assert!(result.success);
580        assert!(
581            !result.output.contains("sk-test-secret-12345"),
582            "API_KEY leaked to shell command output"
583        );
584        assert!(
585            !result.output.contains("sk-test-secret-67890"),
586            "ZEROCLAW_API_KEY leaked to shell command output"
587        );
588    }
589
590    #[tokio::test]
591    async fn shell_preserves_path_and_home_for_env_command() {
592        let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
593
594        let result = tool
595            .execute(json!({"command": "env"}))
596            .await
597            .expect("env command should succeed");
598        assert!(result.success);
599        assert!(
600            result.output.contains("HOME="),
601            "HOME should be available in shell environment"
602        );
603        assert!(
604            result.output.contains("PATH="),
605            "PATH should be available in shell environment"
606        );
607    }
608
609    #[tokio::test]
610    async fn shell_blocks_plain_variable_expansion() {
611        let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
612        let result = tool
613            .execute(json!({"command": "echo $HOME"}))
614            .await
615            .expect("plain variable expansion should be blocked");
616        assert!(!result.success);
617        assert!(
618            result
619                .error
620                .as_deref()
621                .unwrap_or("")
622                .contains("not allowed")
623        );
624    }
625
626    #[tokio::test(flavor = "current_thread")]
627    async fn shell_allows_configured_env_passthrough() {
628        let _guard = EnvGuard::set("ZEROCLAW_TEST_PASSTHROUGH", "db://unit-test");
629        let tool = ShellTool::new(
630            test_security_with_env_passthrough(&["ZEROCLAW_TEST_PASSTHROUGH"]),
631            test_runtime(),
632        );
633
634        let result = tool
635            .execute(json!({"command": "env"}))
636            .await
637            .expect("env command execution should succeed");
638        assert!(result.success);
639        assert!(
640            result
641                .output
642                .contains("ZEROCLAW_TEST_PASSTHROUGH=db://unit-test")
643        );
644    }
645
646    #[test]
647    fn invalid_shell_env_passthrough_names_are_filtered() {
648        let security = SecurityPolicy {
649            shell_env_passthrough: vec![
650                "VALID_NAME".into(),
651                "BAD-NAME".into(),
652                "1NOPE".into(),
653                "ALSO_VALID".into(),
654            ],
655            ..SecurityPolicy::default()
656        };
657        let vars = collect_allowed_shell_env_vars(&security);
658        assert!(vars.contains(&"VALID_NAME".to_string()));
659        assert!(vars.contains(&"ALSO_VALID".to_string()));
660        assert!(!vars.contains(&"BAD-NAME".to_string()));
661        assert!(!vars.contains(&"1NOPE".to_string()));
662    }
663
664    #[tokio::test]
665    async fn shell_requires_approval_for_medium_risk_command() {
666        let security = Arc::new(SecurityPolicy {
667            autonomy: AutonomyLevel::Supervised,
668            allowed_commands: vec!["touch".into()],
669            workspace_dir: std::env::temp_dir(),
670            ..SecurityPolicy::default()
671        });
672
673        let tool = ShellTool::new(security.clone(), test_runtime());
674        let denied = tool
675            .execute(json!({"command": "touch zeroclaw_shell_approval_test"}))
676            .await
677            .expect("unapproved command should return a result");
678        assert!(!denied.success);
679        assert!(
680            denied
681                .error
682                .as_deref()
683                .unwrap_or("")
684                .contains("explicit approval")
685        );
686
687        let allowed = tool
688            .execute(json!({
689                "command": "touch zeroclaw_shell_approval_test",
690                "approved": true
691            }))
692            .await
693            .expect("approved command execution should succeed");
694        assert!(allowed.success);
695
696        let _ =
697            tokio::fs::remove_file(std::env::temp_dir().join("zeroclaw_shell_approval_test")).await;
698    }
699
700    // ── shell timeout enforcement tests ─────────────────
701
702    #[test]
703    fn shell_timeout_default_is_reasonable() {
704        assert_eq!(
705            DEFAULT_SHELL_TIMEOUT_SECS, 60,
706            "default shell timeout must be 60 seconds"
707        );
708    }
709
710    #[test]
711    fn shell_timeout_can_be_overridden() {
712        let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime())
713            .with_timeout_secs(120);
714        assert_eq!(tool.timeout_secs, 120);
715    }
716
717    #[test]
718    fn shell_output_limit_is_1mb() {
719        assert_eq!(
720            MAX_OUTPUT_BYTES, 1_048_576,
721            "max output must be 1 MB to prevent OOM"
722        );
723    }
724
725    // ── Non-UTF8 binary output tests ────────────────────
726
727    #[test]
728    fn decode_output_valid_utf8_roundtrips() {
729        let input = "hello 世界 🌍".as_bytes();
730        assert_eq!(super::decode_output(input), "hello 世界 🌍");
731    }
732
733    #[test]
734    fn decode_output_invalid_utf8_uses_replacement_chars() {
735        // 0xFF is not valid UTF-8
736        let input = b"hello\xFF world";
737        let result = super::decode_output(input);
738        // Must not panic; non-UTF-8 bytes become replacement characters on non-Windows
739        assert!(result.contains("hello"));
740        assert!(result.contains("world"));
741    }
742
743    #[test]
744    fn decode_output_empty_bytes_returns_empty_string() {
745        assert_eq!(super::decode_output(b""), "");
746    }
747
748    #[cfg(target_os = "windows")]
749    #[test]
750    fn windows_code_page_mapping_covers_cjk() {
751        use super::windows_code_page_to_encoding;
752        assert_eq!(windows_code_page_to_encoding(936), encoding_rs::GBK);
753        assert_eq!(windows_code_page_to_encoding(932), encoding_rs::SHIFT_JIS);
754        assert_eq!(windows_code_page_to_encoding(949), encoding_rs::EUC_KR);
755        assert_eq!(windows_code_page_to_encoding(950), encoding_rs::BIG5);
756    }
757
758    #[cfg(target_os = "windows")]
759    #[test]
760    fn windows_code_page_mapping_utf8_variants() {
761        use super::windows_code_page_to_encoding;
762        assert_eq!(windows_code_page_to_encoding(65001), encoding_rs::UTF_8);
763        assert_eq!(windows_code_page_to_encoding(20127), encoding_rs::UTF_8);
764    }
765
766    #[cfg(target_os = "windows")]
767    #[test]
768    fn windows_code_page_mapping_unknown_falls_back_to_utf8() {
769        use super::windows_code_page_to_encoding;
770        assert_eq!(windows_code_page_to_encoding(99999), encoding_rs::UTF_8);
771    }
772
773    #[cfg(target_os = "windows")]
774    #[test]
775    fn decode_output_gbk_bytes_transcode_to_utf8() {
776        // GBK encoding of "你好" is [0xC4, 0xE3, 0xBA, 0xC3]
777        let gbk_bytes: &[u8] = &[0xC4, 0xE3, 0xBA, 0xC3];
778        // When the console code page is GBK (936), windows_code_page_to_encoding
779        // returns GBK and decodes correctly.  We test the transcoding function
780        // directly since we cannot control GetConsoleOutputCP in unit tests.
781        let (cow, _enc, _errors) = encoding_rs::GBK.decode(gbk_bytes);
782        assert_eq!(cow.as_ref(), "你好");
783    }
784
785    #[test]
786    fn shell_safe_env_vars_excludes_secrets() {
787        for var in SAFE_ENV_VARS {
788            let lower = var.to_lowercase();
789            assert!(
790                !lower.contains("key") && !lower.contains("secret") && !lower.contains("token"),
791                "SAFE_ENV_VARS must not include sensitive variable: {var}"
792            );
793        }
794    }
795
796    #[test]
797    fn shell_safe_env_vars_includes_essentials() {
798        assert!(
799            SAFE_ENV_VARS.contains(&"PATH"),
800            "PATH must be in safe env vars"
801        );
802        assert!(
803            SAFE_ENV_VARS.contains(&"HOME") || SAFE_ENV_VARS.contains(&"USERPROFILE"),
804            "HOME or USERPROFILE must be in safe env vars"
805        );
806        assert!(
807            SAFE_ENV_VARS.contains(&"TERM"),
808            "TERM must be in safe env vars"
809        );
810    }
811
812    #[tokio::test]
813    async fn shell_blocks_rate_limited() {
814        let security = Arc::new(SecurityPolicy {
815            autonomy: AutonomyLevel::Supervised,
816            max_actions_per_hour: 0,
817            workspace_dir: std::env::temp_dir(),
818            ..SecurityPolicy::default()
819        });
820        let tool = wrapped_shell(security);
821        let result = tool
822            .execute(json!({"command": "echo test"}))
823            .await
824            .expect("rate-limited command should return a result");
825        assert!(!result.success);
826        assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
827    }
828
829    #[tokio::test]
830    async fn shell_handles_nonexistent_command() {
831        let security = Arc::new(SecurityPolicy {
832            autonomy: AutonomyLevel::Full,
833            workspace_dir: std::env::temp_dir(),
834            ..SecurityPolicy::default()
835        });
836        let tool = ShellTool::new(security, test_runtime());
837        let result = tool
838            .execute(json!({"command": "nonexistent_binary_xyz_12345"}))
839            .await
840            .unwrap();
841        assert!(!result.success);
842    }
843
844    #[tokio::test]
845    async fn shell_captures_stderr_output() {
846        let tool = ShellTool::new(test_security(AutonomyLevel::Full), test_runtime());
847        let result = tool
848            .execute(json!({"command": "echo error_msg >&2"}))
849            .await
850            .unwrap();
851        assert!(result.error.as_deref().unwrap_or("").contains("error_msg"));
852    }
853
854    #[tokio::test]
855    async fn shell_record_action_budget_exhaustion() {
856        let security = Arc::new(SecurityPolicy {
857            autonomy: AutonomyLevel::Full,
858            max_actions_per_hour: 1,
859            workspace_dir: std::env::temp_dir(),
860            ..SecurityPolicy::default()
861        });
862        let tool = wrapped_shell(security);
863
864        let r1 = tool
865            .execute(json!({"command": "echo first"}))
866            .await
867            .unwrap();
868        assert!(r1.success);
869
870        let r2 = tool
871            .execute(json!({"command": "echo second"}))
872            .await
873            .unwrap();
874        assert!(!r2.success);
875        assert!(
876            r2.error.as_deref().unwrap_or("").contains("Rate limit")
877                || r2.error.as_deref().unwrap_or("").contains("budget")
878        );
879    }
880
881    // ── Sandbox integration tests ────────────────────────
882
883    #[test]
884    fn shell_tool_can_be_constructed_with_sandbox() {
885        use crate::security::NoopSandbox;
886
887        let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
888        let tool = ShellTool::new_with_sandbox(
889            test_security(AutonomyLevel::Supervised),
890            test_runtime(),
891            sandbox,
892        );
893        assert_eq!(tool.name(), "shell");
894    }
895
896    #[test]
897    fn noop_sandbox_does_not_modify_command() {
898        use crate::security::NoopSandbox;
899
900        let sandbox = NoopSandbox;
901        let mut cmd = std::process::Command::new("echo");
902        cmd.arg("hello");
903
904        let program_before = cmd.get_program().to_os_string();
905        let args_before: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect();
906
907        sandbox
908            .wrap_command(&mut cmd)
909            .expect("wrap_command should succeed");
910
911        assert_eq!(cmd.get_program(), program_before);
912        assert_eq!(
913            cmd.get_args().map(|a| a.to_os_string()).collect::<Vec<_>>(),
914            args_before
915        );
916    }
917
918    #[tokio::test]
919    async fn shell_executes_with_sandbox() {
920        use crate::security::NoopSandbox;
921
922        let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
923        let tool = ShellTool::new_with_sandbox(
924            test_security(AutonomyLevel::Supervised),
925            test_runtime(),
926            sandbox,
927        );
928        let result = tool
929            .execute(json!({"command": "echo sandbox_test"}))
930            .await
931            .expect("command with sandbox should succeed");
932        assert!(result.success);
933        assert!(result.output.contains("sandbox_test"));
934    }
935}