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
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
82#[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#[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 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 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 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 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 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 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 Some(val) => unsafe { std::env::set_var(self.key, val) },
563 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 #[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 #[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 let input = b"hello\xFF world";
737 let result = super::decode_output(input);
738 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 let gbk_bytes: &[u8] = &[0xC4, 0xE3, 0xBA, 0xC3];
778 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 #[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}