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::{HashMap, HashSet};
7use std::sync::Arc;
8use std::time::Duration;
9use zeroclaw_api::tool::{Tool, ToolResult, with_ephemeral_workspace_warning};
10
11const MAX_OUTPUT_BYTES: usize = 1_048_576;
13const POST_EXIT_DRAIN: Duration = Duration::from_millis(250);
14
15#[cfg(unix)]
18struct ChildGroupGuard {
19 pgid: std::sync::atomic::AtomicI32,
20}
21
22#[cfg(unix)]
23impl ChildGroupGuard {
24 fn new(child_pid: Option<u32>) -> Self {
25 let pgid = child_pid.and_then(|p| i32::try_from(p).ok()).unwrap_or(0);
26 Self {
27 pgid: std::sync::atomic::AtomicI32::new(pgid),
28 }
29 }
30
31 fn disarm(&self) {
32 self.pgid.store(0, std::sync::atomic::Ordering::Release);
33 }
34}
35
36#[cfg(unix)]
37impl Drop for ChildGroupGuard {
38 fn drop(&mut self) {
39 let pgid = self.pgid.load(std::sync::atomic::Ordering::Acquire);
40 if pgid <= 0 {
41 return;
42 }
43 ::zeroclaw_log::record!(
44 WARN,
45 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Kill)
46 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
47 .with_attrs(::serde_json::json!({ "pgid": pgid, "signal": "SIGKILL" })),
48 "shell tool reaping child process group"
49 );
50 unsafe {
51 libc::kill(-pgid, libc::SIGKILL);
52 }
53 }
54}
55
56#[cfg(not(target_os = "windows"))]
59const SAFE_ENV_VARS: &[&str] = &[
60 "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
61];
62
63#[cfg(target_os = "windows")]
66const SAFE_ENV_VARS: &[&str] = &[
67 "PATH",
68 "PATHEXT",
69 "HOME",
70 "USERPROFILE",
71 "HOMEDRIVE",
72 "HOMEPATH",
73 "SYSTEMROOT",
74 "SYSTEMDRIVE",
75 "WINDIR",
76 "COMSPEC",
77 "TEMP",
78 "TMP",
79 "TERM",
80 "LANG",
81 "USERNAME",
82];
83
84pub struct ShellTool {
86 security: Arc<SecurityPolicy>,
87 runtime: Arc<dyn RuntimeAdapter>,
88 sandbox: Arc<dyn Sandbox>,
89 timeout_secs: u64,
90 tui_env: Option<HashMap<String, String>>,
95 persistent_writes: bool,
103}
104
105impl ShellTool {
106 pub fn new(security: Arc<SecurityPolicy>, runtime: Arc<dyn RuntimeAdapter>) -> Self {
107 let timeout_secs = security.shell_timeout_secs;
108 Self {
109 security,
110 runtime,
111 sandbox: Arc::new(crate::security::NoopSandbox),
112 timeout_secs,
113 tui_env: None,
114 persistent_writes: true,
115 }
116 }
117
118 pub fn new_with_sandbox(
119 security: Arc<SecurityPolicy>,
120 runtime: Arc<dyn RuntimeAdapter>,
121 sandbox: Arc<dyn Sandbox>,
122 ) -> Self {
123 let timeout_secs = security.shell_timeout_secs;
124 Self {
125 security,
126 runtime,
127 sandbox,
128 timeout_secs,
129 tui_env: None,
130 persistent_writes: true,
131 }
132 }
133
134 pub fn with_persistent_writes(mut self, persistent: bool) -> Self {
141 self.persistent_writes = persistent;
142 self
143 }
144
145 pub fn with_timeout_secs(mut self, secs: u64) -> Self {
147 self.timeout_secs = secs;
148 self
149 }
150
151 pub fn with_tui_env(mut self, env: Option<HashMap<String, String>>) -> Self {
156 self.tui_env = env;
157 self
158 }
159}
160
161#[cfg(target_os = "windows")]
171fn decode_output(bytes: &[u8]) -> String {
172 use windows::Win32::Globalization::GetACP;
173 use windows::Win32::System::Console::GetConsoleOutputCP;
174
175 let cp = unsafe { GetConsoleOutputCP() };
176 let cp = if cp == 0 { unsafe { GetACP() } } else { cp };
177
178 let encoding = windows_code_page_to_encoding(cp);
179 if std::ptr::eq(encoding, encoding_rs::UTF_8) {
180 String::from_utf8_lossy(bytes).into_owned()
181 } else {
182 let (cow, _enc_used, _had_errors) = encoding.decode(bytes);
183 cow.into_owned()
184 }
185}
186
187#[cfg(target_os = "windows")]
190fn windows_code_page_to_encoding(cp: u32) -> &'static encoding_rs::Encoding {
191 match cp {
192 932 => encoding_rs::SHIFT_JIS,
193 936 | 54936 => encoding_rs::GBK,
194 949 => encoding_rs::EUC_KR,
195 950 => encoding_rs::BIG5,
196 1250 => encoding_rs::WINDOWS_1250,
197 1251 => encoding_rs::WINDOWS_1251,
198 1252 => encoding_rs::WINDOWS_1252,
199 1253 => encoding_rs::WINDOWS_1253,
200 1254 => encoding_rs::WINDOWS_1254,
201 1255 => encoding_rs::WINDOWS_1255,
202 1256 => encoding_rs::WINDOWS_1256,
203 1257 => encoding_rs::WINDOWS_1257,
204 1258 => encoding_rs::WINDOWS_1258,
205 20127 | 65001 => encoding_rs::UTF_8,
206 _ => encoding_rs::UTF_8,
207 }
208}
209
210#[cfg(not(target_os = "windows"))]
211fn decode_output(bytes: &[u8]) -> String {
212 String::from_utf8_lossy(bytes).into_owned()
213}
214
215fn is_valid_env_var_name(name: &str) -> bool {
216 let mut chars = name.chars();
217 match chars.next() {
218 Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
219 _ => return false,
220 }
221 chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
222}
223
224fn collect_allowed_shell_env_vars(security: &SecurityPolicy) -> Vec<String> {
225 let mut out = Vec::new();
226 let mut seen = HashSet::new();
227 for key in SAFE_ENV_VARS
228 .iter()
229 .copied()
230 .chain(security.shell_env_passthrough.iter().map(|s| s.as_str()))
231 {
232 let candidate = key.trim();
233 if candidate.is_empty() || !is_valid_env_var_name(candidate) {
234 continue;
235 }
236 if seen.insert(candidate.to_string()) {
237 out.push(candidate.to_string());
238 }
239 }
240 out
241}
242
243#[async_trait]
244impl Tool for ShellTool {
245 fn name(&self) -> &str {
246 "shell"
247 }
248
249 fn description(&self) -> &str {
250 "Execute a shell command in the workspace directory"
251 }
252
253 fn parameters_schema(&self) -> serde_json::Value {
254 json!({
255 "type": "object",
256 "properties": {
257 "command": {
258 "type": "string",
259 "description": "The shell command to execute"
260 },
261 "approved": {
262 "type": "boolean",
263 "description": "Set true to explicitly approve medium/high-risk commands in supervised mode",
264 "default": false
265 }
266 },
267 "required": ["command"]
268 })
269 }
270
271 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
272 let command = args
273 .get("command")
274 .and_then(|v| v.as_str())
275 .ok_or_else(|| {
276 ::zeroclaw_log::record!(
277 WARN,
278 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
279 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
280 .with_attrs(::serde_json::json!({"param": "command"})),
281 "tool argument validation failed"
282 );
283
284 anyhow::Error::msg("Missing 'command' parameter")
285 })?;
286 let approved = args
287 .get("approved")
288 .and_then(|v| v.as_bool())
289 .unwrap_or(false);
290
291 match self.security.validate_command_execution(command, approved) {
292 Ok(_) => {}
293 Err(reason) => {
294 return Ok(ToolResult {
295 success: false,
296 output: String::new(),
297 error: Some(reason),
298 });
299 }
300 }
301
302 let mut cmd = match self
306 .runtime
307 .build_shell_command(command, &self.security.workspace_dir)
308 {
309 Ok(cmd) => cmd,
310 Err(e) => {
311 return Ok(ToolResult {
312 success: false,
313 output: String::new(),
314 error: Some(format!("Failed to build runtime command: {e}")),
315 });
316 }
317 };
318
319 self.sandbox.wrap_command(cmd.as_std_mut()).map_err(|e| {
323 ::zeroclaw_log::record!(
324 ERROR,
325 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
326 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
327 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
328 "shell tool: sandbox wrap_command failed"
329 );
330 anyhow::Error::msg(format!("Sandbox error: {e}"))
331 })?;
332
333 cmd.env_clear();
334
335 for var in collect_allowed_shell_env_vars(&self.security) {
336 if let Ok(val) = std::env::var(&var) {
337 cmd.env(&var, val);
338 }
339 }
340
341 if let Some(ref tui_env) = self.tui_env {
345 for (k, v) in tui_env {
346 cmd.env(k, v);
347 }
348 }
349
350 let timeout_secs = self.timeout_secs;
351 #[cfg(unix)]
354 cmd.process_group(0);
355 cmd.kill_on_drop(true);
356 cmd.stdout(std::process::Stdio::piped());
358 cmd.stderr(std::process::Stdio::piped());
359 cmd.stdin(std::process::Stdio::null());
360
361 let mut child = match cmd.spawn() {
362 Ok(c) => c,
363 Err(e) => {
364 return Ok(ToolResult {
365 success: false,
366 output: String::new(),
367 error: Some(format!("Failed to spawn command: {e}")),
368 });
369 }
370 };
371
372 #[cfg(unix)]
373 let group_guard = ChildGroupGuard::new(child.id());
374
375 let stdout_handle = child.stdout.take();
376 let stderr_handle = child.stderr.take();
377
378 let drain_stdout = drain_capped(stdout_handle, MAX_OUTPUT_BYTES);
379 let drain_stderr = drain_capped(stderr_handle, MAX_OUTPUT_BYTES);
380 let wait_fut = async {
381 let status = child.wait().await?;
382 #[cfg(unix)]
383 group_guard.disarm();
384 let (out, err) = tokio::join!(
385 tokio::time::timeout(POST_EXIT_DRAIN, drain_stdout),
386 tokio::time::timeout(POST_EXIT_DRAIN, drain_stderr),
387 );
388 Ok::<_, std::io::Error>((status, out.unwrap_or_default(), err.unwrap_or_default()))
389 };
390
391 let mut result =
392 match tokio::time::timeout(Duration::from_secs(timeout_secs), wait_fut).await {
393 Ok(Ok((status, stdout_bytes, stderr_bytes))) => {
394 let mut stdout = decode_output(&stdout_bytes);
395 let mut stderr = decode_output(&stderr_bytes);
396
397 if stdout.len() > MAX_OUTPUT_BYTES {
398 let mut b = MAX_OUTPUT_BYTES.min(stdout.len());
399 while b > 0 && !stdout.is_char_boundary(b) {
400 b -= 1;
401 }
402 stdout.truncate(b);
403 stdout.push_str("\n... [output truncated at 1MB]");
404 }
405 if stderr.len() > MAX_OUTPUT_BYTES {
406 let mut b = MAX_OUTPUT_BYTES.min(stderr.len());
407 while b > 0 && !stderr.is_char_boundary(b) {
408 b -= 1;
409 }
410 stderr.truncate(b);
411 stderr.push_str("\n... [stderr truncated at 1MB]");
412 }
413
414 ToolResult {
415 success: status.success(),
416 output: stdout,
417 error: if stderr.is_empty() {
418 None
419 } else {
420 Some(stderr)
421 },
422 }
423 }
424 Ok(Err(e)) => ToolResult {
425 success: false,
426 output: String::new(),
427 error: Some(format!("Failed to execute command: {e}")),
428 },
429 Err(_) => ToolResult {
430 success: false,
431 output: String::new(),
432 error: Some(format!(
433 "Command timed out after {timeout_secs}s and was killed"
434 )),
435 },
436 };
437
438 if !self.persistent_writes {
443 result.output = with_ephemeral_workspace_warning(&result.output);
444 if let Some(err) = result.error.take() {
445 result.error = Some(with_ephemeral_workspace_warning(&err));
446 }
447 }
448
449 Ok(result)
450 }
451}
452
453async fn drain_capped<R>(reader: Option<R>, cap: usize) -> Vec<u8>
454where
455 R: tokio::io::AsyncRead + Unpin,
456{
457 use tokio::io::AsyncReadExt;
458 let Some(mut reader) = reader else {
459 return Vec::new();
460 };
461 let mut buf = Vec::new();
462 let mut chunk = [0u8; 8192];
463 loop {
464 match reader.read(&mut chunk).await {
465 Ok(0) => break,
466 Ok(n) => {
467 let take = n.min(cap.saturating_sub(buf.len()).max(1));
468 buf.extend_from_slice(&chunk[..take]);
469 if buf.len() >= cap {
470 break;
471 }
472 }
473 Err(_) => break,
474 }
475 }
476 buf
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use crate::platform::{NativeRuntime, RuntimeAdapter};
483 use crate::security::{AutonomyLevel, SecurityPolicy};
484 use zeroclaw_tools::wrappers::{PathGuardedTool, RateLimitedTool};
485
486 fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
487 Arc::new(SecurityPolicy {
488 autonomy,
489 workspace_dir: std::env::temp_dir(),
490 ..SecurityPolicy::default()
491 })
492 }
493
494 fn test_runtime() -> Arc<dyn RuntimeAdapter> {
495 Arc::new(NativeRuntime::new())
496 }
497
498 fn wrapped_shell(security: Arc<SecurityPolicy>) -> RateLimitedTool<PathGuardedTool<ShellTool>> {
502 RateLimitedTool::new(
503 PathGuardedTool::new(
504 ShellTool::new(security.clone(), test_runtime()),
505 security.clone(),
506 ),
507 security,
508 )
509 }
510
511 #[test]
512 fn shell_tool_name() {
513 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
514 assert_eq!(tool.name(), "shell");
515 }
516
517 #[test]
518 fn shell_tool_description() {
519 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
520 assert!(!tool.description().is_empty());
521 }
522
523 #[test]
524 fn shell_tool_schema_has_command() {
525 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
526 let schema = tool.parameters_schema();
527 assert!(schema["properties"]["command"].is_object());
528 assert!(
529 schema["required"]
530 .as_array()
531 .expect("schema required field should be an array")
532 .contains(&json!("command"))
533 );
534 assert!(schema["properties"]["approved"].is_object());
535 }
536
537 #[tokio::test]
538 async fn shell_stdin_is_eof_not_the_terminal() {
539 let security = Arc::new(SecurityPolicy {
540 autonomy: AutonomyLevel::Supervised,
541 workspace_dir: std::env::temp_dir(),
542 allowed_commands: vec!["cat".into()],
543 ..SecurityPolicy::default()
544 });
545 let tool = ShellTool::new(security, test_runtime());
546 let fut = tool.execute(json!({"command": "cat"}));
547 let res = tokio::time::timeout(std::time::Duration::from_secs(10), fut).await;
548 assert!(
549 res.is_ok(),
550 "a stdin-reading command hung — stdin is not null and may reach the terminal"
551 );
552 assert!(res.unwrap().expect("cat should return a result").success);
553 }
554
555 #[tokio::test]
556 async fn shell_executes_allowed_command() {
557 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
558 let result = tool
559 .execute(json!({"command": "echo hello"}))
560 .await
561 .expect("echo command execution should succeed");
562 assert!(result.success);
563 assert!(result.output.trim().contains("hello"));
564 assert!(result.error.is_none());
565 }
566
567 #[tokio::test]
568 async fn shell_blocks_disallowed_command() {
569 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
570 let result = tool
571 .execute(json!({"command": "rm -rf /"}))
572 .await
573 .expect("disallowed command execution should return a result");
574 assert!(!result.success);
575 let error = result.error.as_deref().unwrap_or("");
576 assert!(error.contains("not allowed") || error.contains("high-risk"));
577 }
578
579 #[tokio::test]
580 async fn shell_blocks_readonly() {
581 let tool = ShellTool::new(test_security(AutonomyLevel::ReadOnly), test_runtime());
582 let result = tool
583 .execute(json!({"command": "ls"}))
584 .await
585 .expect("readonly command execution should return a result");
586 assert!(!result.success);
587 assert!(
588 result
589 .error
590 .as_ref()
591 .expect("error field should be present for blocked command")
592 .contains("not allowed")
593 );
594 }
595
596 #[tokio::test]
597 async fn shell_missing_command_param() {
598 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
599 let result = tool.execute(json!({})).await;
600 assert!(result.is_err());
601 assert!(result.unwrap_err().to_string().contains("command"));
602 }
603
604 #[tokio::test]
605 async fn shell_wrong_type_param() {
606 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
607 let result = tool.execute(json!({"command": 123})).await;
608 assert!(result.is_err());
609 }
610
611 #[tokio::test]
612 async fn shell_captures_exit_code() {
613 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
614 let result = tool
615 .execute(json!({"command": "ls /nonexistent_dir_xyz"}))
616 .await
617 .expect("command with nonexistent path should return a result");
618 assert!(!result.success);
619 }
620
621 #[tokio::test]
627 async fn shell_warns_on_ephemeral_workspace() {
628 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime())
629 .with_persistent_writes(false);
630 let result = tool
631 .execute(json!({"command": "echo hello"}))
632 .await
633 .expect("echo command should run");
634 assert!(result.success);
635 assert!(
636 result.output.contains("EPHEMERAL WORKSPACE"),
637 "ephemeral warning must be present in output, got: {}",
638 result.output
639 );
640 assert!(
641 result.output.contains("mount_workspace"),
642 "warning must name the config key to fix it, got: {}",
643 result.output
644 );
645 assert!(
646 result.output.contains("hello"),
647 "original command output must be preserved, got: {}",
648 result.output
649 );
650 }
651
652 #[tokio::test]
656 async fn shell_warns_on_ephemeral_workspace_failure_path() {
657 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime())
658 .with_persistent_writes(false);
659 let result = tool
660 .execute(json!({"command": "ls /nonexistent_dir_xyz_4627"}))
661 .await
662 .expect("command should return a result");
663 assert!(!result.success);
664 assert!(
665 result
666 .error
667 .as_deref()
668 .unwrap_or("")
669 .contains("EPHEMERAL WORKSPACE"),
670 "ephemeral warning must reach the error field on failures, got: {:?}",
671 result.error
672 );
673 }
674
675 #[tokio::test]
680 async fn shell_warns_on_ephemeral_success_with_stderr() {
681 let tool = ShellTool::new(test_security(AutonomyLevel::Full), test_runtime())
682 .with_persistent_writes(false);
683 let result = tool
684 .execute(json!({"command": "echo out; echo warn >&2"}))
685 .await
686 .expect("command should run");
687 assert!(
688 result.success,
689 "command should exit 0, got error: {:?}",
690 result.error
691 );
692 assert!(
693 result.output.contains("EPHEMERAL WORKSPACE") && result.output.contains("out"),
694 "output must carry banner and preserve stdout, got: {}",
695 result.output
696 );
697 let err = result.error.as_deref().unwrap_or("");
698 assert!(
699 err.contains("EPHEMERAL WORKSPACE") && err.contains("warn"),
700 "error must carry banner and preserve stderr, got: {err:?}"
701 );
702 }
703
704 #[tokio::test]
706 async fn shell_no_warning_when_persistent() {
707 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
708 let result = tool
709 .execute(json!({"command": "echo hello"}))
710 .await
711 .expect("echo command should run");
712 assert!(result.success);
713 assert!(
714 !result.output.contains("EPHEMERAL WORKSPACE"),
715 "no ephemeral warning expected on a persistent runtime, got: {}",
716 result.output
717 );
718 }
719
720 #[tokio::test]
721 async fn shell_blocks_absolute_path_argument() {
722 let tool = wrapped_shell(test_security(AutonomyLevel::Supervised));
723 let result = tool
724 .execute(json!({"command": "cat /etc/passwd"}))
725 .await
726 .expect("absolute path argument should be blocked");
727 assert!(!result.success);
728 assert!(
729 result
730 .error
731 .as_deref()
732 .unwrap_or("")
733 .contains("Path blocked")
734 );
735 }
736
737 #[tokio::test]
738 async fn shell_blocks_option_assignment_path_argument() {
739 let tool = wrapped_shell(test_security(AutonomyLevel::Supervised));
740 let result = tool
741 .execute(json!({"command": "grep --file=/etc/passwd root ./src"}))
742 .await
743 .expect("option-assigned forbidden path should be blocked");
744 assert!(!result.success);
745 assert!(
746 result
747 .error
748 .as_deref()
749 .unwrap_or("")
750 .contains("Path blocked")
751 );
752 }
753
754 #[tokio::test]
755 async fn shell_blocks_short_option_attached_path_argument() {
756 let tool = wrapped_shell(test_security(AutonomyLevel::Supervised));
757 let result = tool
758 .execute(json!({"command": "grep -f/etc/passwd root ./src"}))
759 .await
760 .expect("short option attached forbidden path should be blocked");
761 assert!(!result.success);
762 assert!(
763 result
764 .error
765 .as_deref()
766 .unwrap_or("")
767 .contains("Path blocked")
768 );
769 }
770
771 #[tokio::test]
772 async fn shell_blocks_tilde_user_path_argument() {
773 let tool = wrapped_shell(test_security(AutonomyLevel::Supervised));
774 let result = tool
775 .execute(json!({"command": "cat ~root/.ssh/id_rsa"}))
776 .await
777 .expect("tilde-user path should be blocked");
778 assert!(!result.success);
779 assert!(
780 result
781 .error
782 .as_deref()
783 .unwrap_or("")
784 .contains("Path blocked")
785 );
786 }
787
788 #[tokio::test]
789 async fn shell_blocks_input_redirection_path_bypass() {
790 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
791 let result = tool
792 .execute(json!({"command": "cat </etc/passwd"}))
793 .await
794 .expect("input redirection bypass should be blocked");
795 assert!(!result.success);
796 assert!(
797 result
798 .error
799 .as_deref()
800 .unwrap_or("")
801 .contains("not allowed")
802 );
803 }
804
805 fn test_security_with_env_cmd() -> Arc<SecurityPolicy> {
806 Arc::new(SecurityPolicy {
807 autonomy: AutonomyLevel::Supervised,
808 workspace_dir: std::env::temp_dir(),
809 allowed_commands: vec!["env".into(), "echo".into()],
810 ..SecurityPolicy::default()
811 })
812 }
813
814 fn test_security_with_env_passthrough(vars: &[&str]) -> Arc<SecurityPolicy> {
815 Arc::new(SecurityPolicy {
816 autonomy: AutonomyLevel::Supervised,
817 workspace_dir: std::env::temp_dir(),
818 allowed_commands: vec!["env".into()],
819 shell_env_passthrough: vars.iter().map(|v| (*v).to_string()).collect(),
820 ..SecurityPolicy::default()
821 })
822 }
823
824 struct EnvGuard {
827 key: &'static str,
828 original: Option<String>,
829 }
830
831 impl EnvGuard {
832 fn set(key: &'static str, value: &str) -> Self {
833 let original = std::env::var(key).ok();
834 unsafe { std::env::set_var(key, value) };
836 Self { key, original }
837 }
838 }
839
840 impl Drop for EnvGuard {
841 fn drop(&mut self) {
842 match &self.original {
843 Some(val) => unsafe { std::env::set_var(self.key, val) },
845 None => unsafe { std::env::remove_var(self.key) },
847 }
848 }
849 }
850
851 #[tokio::test(flavor = "current_thread")]
852 async fn shell_does_not_leak_api_key() {
853 let _g1 = EnvGuard::set("API_KEY", "sk-test-secret-12345");
854 let _g2 = EnvGuard::set("ZEROCLAW_API_KEY", "sk-test-secret-67890");
855
856 let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
857 let result = tool
858 .execute(json!({"command": "env"}))
859 .await
860 .expect("env command execution should succeed");
861 assert!(result.success);
862 assert!(
863 !result.output.contains("sk-test-secret-12345"),
864 "API_KEY leaked to shell command output"
865 );
866 assert!(
867 !result.output.contains("sk-test-secret-67890"),
868 "ZEROCLAW_API_KEY leaked to shell command output"
869 );
870 }
871
872 #[tokio::test]
873 async fn shell_preserves_path_and_home_for_env_command() {
874 let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
875
876 let result = tool
877 .execute(json!({"command": "env"}))
878 .await
879 .expect("env command should succeed");
880 assert!(result.success);
881 assert!(
882 result.output.contains("HOME="),
883 "HOME should be available in shell environment"
884 );
885 assert!(
886 result.output.contains("PATH="),
887 "PATH should be available in shell environment"
888 );
889 }
890
891 #[tokio::test]
892 async fn shell_blocks_plain_variable_expansion() {
893 let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
894 let result = tool
895 .execute(json!({"command": "echo $HOME"}))
896 .await
897 .expect("plain variable expansion should be blocked");
898 assert!(!result.success);
899 assert!(
900 result
901 .error
902 .as_deref()
903 .unwrap_or("")
904 .contains("not allowed")
905 );
906 }
907
908 #[tokio::test(flavor = "current_thread")]
909 async fn shell_allows_configured_env_passthrough() {
910 let _guard = EnvGuard::set("ZEROCLAW_TEST_PASSTHROUGH", "db://unit-test");
911 let tool = ShellTool::new(
912 test_security_with_env_passthrough(&["ZEROCLAW_TEST_PASSTHROUGH"]),
913 test_runtime(),
914 );
915
916 let result = tool
917 .execute(json!({"command": "env"}))
918 .await
919 .expect("env command execution should succeed");
920 assert!(result.success);
921 assert!(
922 result
923 .output
924 .contains("ZEROCLAW_TEST_PASSTHROUGH=db://unit-test")
925 );
926 }
927
928 #[test]
929 fn invalid_shell_env_passthrough_names_are_filtered() {
930 let security = SecurityPolicy {
931 shell_env_passthrough: vec![
932 "VALID_NAME".into(),
933 "BAD-NAME".into(),
934 "1NOPE".into(),
935 "ALSO_VALID".into(),
936 ],
937 ..SecurityPolicy::default()
938 };
939 let vars = collect_allowed_shell_env_vars(&security);
940 assert!(vars.contains(&"VALID_NAME".to_string()));
941 assert!(vars.contains(&"ALSO_VALID".to_string()));
942 assert!(!vars.contains(&"BAD-NAME".to_string()));
943 assert!(!vars.contains(&"1NOPE".to_string()));
944 }
945
946 #[tokio::test]
947 async fn shell_requires_approval_for_medium_risk_command() {
948 let security = Arc::new(SecurityPolicy {
949 autonomy: AutonomyLevel::Supervised,
950 allowed_commands: vec!["touch".into()],
951 workspace_dir: std::env::temp_dir(),
952 ..SecurityPolicy::default()
953 });
954
955 let tool = ShellTool::new(security.clone(), test_runtime());
956 let denied = tool
957 .execute(json!({"command": "touch zeroclaw_shell_approval_test"}))
958 .await
959 .expect("unapproved command should return a result");
960 assert!(!denied.success);
961 assert!(
962 denied
963 .error
964 .as_deref()
965 .unwrap_or("")
966 .contains("explicit approval")
967 );
968
969 let allowed = tool
970 .execute(json!({
971 "command": "touch zeroclaw_shell_approval_test",
972 "approved": true
973 }))
974 .await
975 .expect("approved command execution should succeed");
976 assert!(allowed.success);
977
978 let _ =
979 tokio::fs::remove_file(std::env::temp_dir().join("zeroclaw_shell_approval_test")).await;
980 }
981
982 #[test]
985 fn shell_timeout_can_be_overridden() {
986 let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime())
987 .with_timeout_secs(120);
988 assert_eq!(tool.timeout_secs, 120);
989 }
990
991 #[test]
992 fn shell_output_limit_is_1mb() {
993 assert_eq!(
994 MAX_OUTPUT_BYTES, 1_048_576,
995 "max output must be 1 MB to prevent OOM"
996 );
997 }
998
999 #[test]
1002 fn decode_output_valid_utf8_roundtrips() {
1003 let input = "hello 世界 🌍".as_bytes();
1004 assert_eq!(super::decode_output(input), "hello 世界 🌍");
1005 }
1006
1007 #[test]
1008 fn decode_output_invalid_utf8_uses_replacement_chars() {
1009 let input = b"hello\xFF world";
1011 let result = super::decode_output(input);
1012 assert!(result.contains("hello"));
1014 assert!(result.contains("world"));
1015 }
1016
1017 #[test]
1018 fn decode_output_empty_bytes_returns_empty_string() {
1019 assert_eq!(super::decode_output(b""), "");
1020 }
1021
1022 #[cfg(target_os = "windows")]
1023 #[test]
1024 fn windows_code_page_mapping_covers_cjk() {
1025 use super::windows_code_page_to_encoding;
1026 assert_eq!(windows_code_page_to_encoding(936), encoding_rs::GBK);
1027 assert_eq!(windows_code_page_to_encoding(932), encoding_rs::SHIFT_JIS);
1028 assert_eq!(windows_code_page_to_encoding(949), encoding_rs::EUC_KR);
1029 assert_eq!(windows_code_page_to_encoding(950), encoding_rs::BIG5);
1030 }
1031
1032 #[cfg(target_os = "windows")]
1033 #[test]
1034 fn windows_code_page_mapping_utf8_variants() {
1035 use super::windows_code_page_to_encoding;
1036 assert_eq!(windows_code_page_to_encoding(65001), encoding_rs::UTF_8);
1037 assert_eq!(windows_code_page_to_encoding(20127), encoding_rs::UTF_8);
1038 }
1039
1040 #[cfg(target_os = "windows")]
1041 #[test]
1042 fn windows_code_page_mapping_unknown_falls_back_to_utf8() {
1043 use super::windows_code_page_to_encoding;
1044 assert_eq!(windows_code_page_to_encoding(99999), encoding_rs::UTF_8);
1045 }
1046
1047 #[cfg(target_os = "windows")]
1048 #[test]
1049 fn decode_output_gbk_bytes_transcode_to_utf8() {
1050 let gbk_bytes: &[u8] = &[0xC4, 0xE3, 0xBA, 0xC3];
1052 let (cow, _enc, _errors) = encoding_rs::GBK.decode(gbk_bytes);
1056 assert_eq!(cow.as_ref(), "你好");
1057 }
1058
1059 #[test]
1060 fn shell_safe_env_vars_excludes_secrets() {
1061 for var in SAFE_ENV_VARS {
1062 let lower = var.to_lowercase();
1063 assert!(
1064 !lower.contains("key") && !lower.contains("secret") && !lower.contains("token"),
1065 "SAFE_ENV_VARS must not include sensitive variable: {var}"
1066 );
1067 }
1068 }
1069
1070 #[test]
1071 fn shell_safe_env_vars_includes_essentials() {
1072 assert!(
1073 SAFE_ENV_VARS.contains(&"PATH"),
1074 "PATH must be in safe env vars"
1075 );
1076 assert!(
1077 SAFE_ENV_VARS.contains(&"HOME") || SAFE_ENV_VARS.contains(&"USERPROFILE"),
1078 "HOME or USERPROFILE must be in safe env vars"
1079 );
1080 assert!(
1081 SAFE_ENV_VARS.contains(&"TERM"),
1082 "TERM must be in safe env vars"
1083 );
1084 }
1085
1086 #[tokio::test]
1087 async fn shell_blocks_rate_limited() {
1088 let security = Arc::new(SecurityPolicy {
1089 autonomy: AutonomyLevel::Supervised,
1090 max_actions_per_hour: 0,
1091 workspace_dir: std::env::temp_dir(),
1092 ..SecurityPolicy::default()
1093 });
1094 let tool = wrapped_shell(security);
1095 let result = tool
1096 .execute(json!({"command": "echo test"}))
1097 .await
1098 .expect("rate-limited command should return a result");
1099 assert!(!result.success);
1100 assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
1101 }
1102
1103 #[tokio::test]
1104 async fn shell_handles_nonexistent_command() {
1105 let security = Arc::new(SecurityPolicy {
1106 autonomy: AutonomyLevel::Full,
1107 workspace_dir: std::env::temp_dir(),
1108 ..SecurityPolicy::default()
1109 });
1110 let tool = ShellTool::new(security, test_runtime());
1111 let result = tool
1112 .execute(json!({"command": "nonexistent_binary_xyz_12345"}))
1113 .await
1114 .unwrap();
1115 assert!(!result.success);
1116 }
1117
1118 #[tokio::test]
1119 async fn shell_captures_stderr_output() {
1120 let tool = ShellTool::new(test_security(AutonomyLevel::Full), test_runtime());
1121 let result = tool
1122 .execute(json!({"command": "echo error_msg >&2"}))
1123 .await
1124 .unwrap();
1125 assert!(result.error.as_deref().unwrap_or("").contains("error_msg"));
1126 }
1127
1128 #[tokio::test]
1129 async fn shell_record_action_budget_exhaustion() {
1130 let security = Arc::new(SecurityPolicy {
1131 autonomy: AutonomyLevel::Full,
1132 max_actions_per_hour: 1,
1133 workspace_dir: std::env::temp_dir(),
1134 ..SecurityPolicy::default()
1135 });
1136 let tool = wrapped_shell(security);
1137
1138 let r1 = tool
1139 .execute(json!({"command": "echo first"}))
1140 .await
1141 .unwrap();
1142 assert!(r1.success);
1143
1144 let r2 = tool
1145 .execute(json!({"command": "echo second"}))
1146 .await
1147 .unwrap();
1148 assert!(!r2.success);
1149 assert!(
1150 r2.error.as_deref().unwrap_or("").contains("Rate limit")
1151 || r2.error.as_deref().unwrap_or("").contains("budget")
1152 );
1153 }
1154
1155 #[test]
1158 fn shell_tool_can_be_constructed_with_sandbox() {
1159 use crate::security::NoopSandbox;
1160
1161 let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
1162 let tool = ShellTool::new_with_sandbox(
1163 test_security(AutonomyLevel::Supervised),
1164 test_runtime(),
1165 sandbox,
1166 );
1167 assert_eq!(tool.name(), "shell");
1168 }
1169
1170 #[test]
1171 fn noop_sandbox_does_not_modify_command() {
1172 use crate::security::NoopSandbox;
1173
1174 let sandbox = NoopSandbox;
1175 let mut cmd = std::process::Command::new("echo");
1176 cmd.arg("hello");
1177
1178 let program_before = cmd.get_program().to_os_string();
1179 let args_before: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect();
1180
1181 sandbox
1182 .wrap_command(&mut cmd)
1183 .expect("wrap_command should succeed");
1184
1185 assert_eq!(cmd.get_program(), program_before);
1186 assert_eq!(
1187 cmd.get_args().map(|a| a.to_os_string()).collect::<Vec<_>>(),
1188 args_before
1189 );
1190 }
1191
1192 #[tokio::test]
1193 async fn shell_executes_with_sandbox() {
1194 use crate::security::NoopSandbox;
1195
1196 let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
1197 let tool = ShellTool::new_with_sandbox(
1198 test_security(AutonomyLevel::Supervised),
1199 test_runtime(),
1200 sandbox,
1201 );
1202 let result = tool
1203 .execute(json!({"command": "echo sandbox_test"}))
1204 .await
1205 .expect("command with sandbox should succeed");
1206 assert!(result.success);
1207 assert!(result.output.contains("sandbox_test"));
1208 }
1209
1210 #[tokio::test(flavor = "current_thread")]
1213 async fn shell_tui_env_is_passed_to_subprocess() {
1214 let tool =
1217 ShellTool::new(test_security_with_env_cmd(), test_runtime()).with_tui_env(Some({
1218 let mut m = std::collections::HashMap::new();
1219 m.insert("ZC_TUI_TEST_VAR".to_string(), "tui_injected".to_string());
1220 m
1221 }));
1222
1223 let result = tool
1224 .execute(json!({"command": "env"}))
1225 .await
1226 .expect("env command should succeed");
1227
1228 assert!(result.success);
1229 assert!(
1230 result.output.contains("ZC_TUI_TEST_VAR=tui_injected"),
1231 "tui_env var should appear in subprocess env, got:\n{}",
1232 result.output
1233 );
1234 }
1235
1236 #[tokio::test(flavor = "current_thread")]
1237 async fn shell_without_tui_env_does_not_inject_extra_vars() {
1238 let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
1240
1241 let result = tool
1242 .execute(json!({"command": "env"}))
1243 .await
1244 .expect("env command should succeed");
1245
1246 assert!(result.success);
1247 assert!(
1248 !result.output.contains("ZC_TUI_TEST_VAR"),
1249 "non-safe var must not leak without tui_env"
1250 );
1251 }
1252
1253 #[tokio::test(flavor = "current_thread")]
1254 async fn shell_tui_env_overrides_safe_var() {
1255 let _guard = EnvGuard::set("HOME", "/daemon-home");
1258
1259 let tool =
1260 ShellTool::new(test_security_with_env_cmd(), test_runtime()).with_tui_env(Some({
1261 let mut m = std::collections::HashMap::new();
1262 m.insert("HOME".to_string(), "/tui-home".to_string());
1263 m
1264 }));
1265
1266 let result = tool
1267 .execute(json!({"command": "env"}))
1268 .await
1269 .expect("env command should succeed");
1270
1271 assert!(
1272 result.success,
1273 "env should succeed, got output={:?} error={:?}",
1274 result.output, result.error
1275 );
1276 assert!(
1277 result.output.contains("HOME=/tui-home"),
1278 "tui_env HOME should override daemon HOME, got:\n{}",
1279 result.output
1280 );
1281 assert!(
1282 !result.output.contains("HOME=/daemon-home"),
1283 "daemon HOME must not leak through when tui_env overrides it, got:\n{}",
1284 result.output
1285 );
1286 }
1287
1288 #[tokio::test(flavor = "current_thread")]
1289 async fn shell_tui_env_none_behaves_like_existing() {
1290 let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime()).with_tui_env(None);
1293
1294 let result = tool
1295 .execute(json!({"command": "env"}))
1296 .await
1297 .expect("env command should succeed");
1298
1299 assert!(result.success);
1300 assert!(
1301 !result.output.contains("ZC_TUI_TEST_VAR"),
1302 "None tui_env must not inject anything extra"
1303 );
1304 }
1305
1306 #[tokio::test(flavor = "current_thread")]
1307 async fn shell_tui_env_secrets_reach_subprocess_but_not_safe_list() {
1308 let tool =
1312 ShellTool::new(test_security_with_env_cmd(), test_runtime()).with_tui_env(Some({
1313 let mut m = std::collections::HashMap::new();
1314 m.insert("SSH_AUTH_SOCK".to_string(), "/tmp/fake.sock".to_string());
1315 m
1316 }));
1317
1318 assert!(
1320 !SAFE_ENV_VARS.contains(&"SSH_AUTH_SOCK"),
1321 "SSH_AUTH_SOCK must not be in SAFE_ENV_VARS"
1322 );
1323
1324 let result = tool
1325 .execute(json!({"command": "env"}))
1326 .await
1327 .expect("env command should succeed");
1328
1329 assert!(result.success);
1330 assert!(
1331 result.output.contains("SSH_AUTH_SOCK=/tmp/fake.sock"),
1332 "SSH_AUTH_SOCK from tui_env must reach subprocess"
1333 );
1334 }
1335}