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::{HashMap, HashSet};
7use std::sync::Arc;
8use std::time::Duration;
9use zeroclaw_api::tool::{Tool, ToolResult, with_ephemeral_workspace_warning};
10
11/// Maximum output size in bytes (1MB).
12const MAX_OUTPUT_BYTES: usize = 1_048_576;
13const POST_EXIT_DRAIN: Duration = Duration::from_millis(250);
14
15/// Drop guard that SIGKILLs the child's process group on cancel/timeout paths.
16/// Disarmed after `child.wait()` returns so it never signals a recycled PID.
17#[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/// Environment variables safe to pass to shell commands.
57/// Only functional variables are included — never API keys or secrets.
58#[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/// Environment variables safe to pass to shell commands on Windows.
64/// Includes Windows-specific variables needed for cmd.exe and program resolution.
65#[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
84/// Shell command execution tool with sandboxing
85pub struct ShellTool {
86    security: Arc<SecurityPolicy>,
87    runtime: Arc<dyn RuntimeAdapter>,
88    sandbox: Arc<dyn Sandbox>,
89    timeout_secs: u64,
90    /// Environment forwarded from the connected TUI client. When set, these
91    /// vars are overlaid on top of the safe-env snapshot, letting the user's
92    /// real shell environment (PATH, credentials, etc.) reach subprocesses
93    /// even though the daemon itself may have a stripped-down env.
94    tui_env: Option<HashMap<String, String>>,
95    /// Whether workspace writes performed by the command persist on the host.
96    /// `false` when the runtime uses an ephemeral sandbox (e.g. Docker without
97    /// a workspace volume mount), in which case files written via shell succeed
98    /// inside the container but are invisible on the host and discarded at
99    /// session end. The shell tool can't tell a read from a write, so rather
100    /// than refusing (like `file_write`) it attaches a loud warning to every
101    /// executed command's result. See issue #4627.
102    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    /// Mark whether the active runtime persists workspace writes to the host.
135    ///
136    /// Pass `false` for an ephemeral runtime (Docker tmpfs / no volume mount)
137    /// to attach a loud ephemeral-workspace warning to every executed command,
138    /// so silent data loss is visible (issue #4627). Defaults to `true`,
139    /// preserving existing behaviour on native runtimes and in tests.
140    pub fn with_persistent_writes(mut self, persistent: bool) -> Self {
141        self.persistent_writes = persistent;
142        self
143    }
144
145    /// Override the command execution timeout (in seconds).
146    pub fn with_timeout_secs(mut self, secs: u64) -> Self {
147        self.timeout_secs = secs;
148        self
149    }
150
151    /// Overlay the TUI client's environment on top of the safe-env snapshot.
152    ///
153    /// Pass `Some(env)` to enable forwarding; `None` is a no-op (same as not
154    /// calling this method at all).
155    pub fn with_tui_env(mut self, env: Option<HashMap<String, String>>) -> Self {
156        self.tui_env = env;
157        self
158    }
159}
160
161/// Decode raw process output bytes to a UTF-8 String.
162///
163/// On Windows, cmd.exe emits bytes in the active console output code page
164/// (e.g. CP936/GBK on Simplified Chinese systems). We query the code page at
165/// runtime and transcode via `encoding_rs` so non-ASCII characters survive
166/// intact instead of being replaced by U+FFFD.
167///
168/// On all other platforms the shell runs under the user's locale (usually
169/// UTF-8 already), so `from_utf8_lossy` is sufficient.
170#[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/// Map a Windows code page identifier to an `encoding_rs` `Encoding`.
188/// Falls back to UTF-8 (lossy) for unknown code pages.
189#[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        // Execute with timeout to prevent hanging commands.
303        // Clear the environment to prevent leaking API keys and other secrets
304        // (CWE-200), then re-add only safe, functional variables.
305        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        // Apply sandbox wrapping before execution.
320        // The Sandbox trait operates on std::process::Command, so use as_std_mut()
321        // to get a mutable reference to the underlying command.
322        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        // Overlay TUI env on top of the safe-env snapshot. TUI vars win on
342        // conflict — the user's real PATH etc. should take precedence over
343        // whatever the daemon process inherited.
344        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        // Run in own process group so `ChildGroupGuard` can reap the
352        // whole subtree (backgrounded jobs, subshells) on any exit path.
353        #[cfg(unix)]
354        cmd.process_group(0);
355        cmd.kill_on_drop(true);
356        // `output()` pipes stdio implicitly; `spawn()` does not.
357        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        // The command ran inside an ephemeral workspace: any files it wrote are
439        // invisible on the host and discarded at session end (issue #4627).
440        // Inject the warning into whichever field the dispatcher surfaces to the
441        // model — `output` on success, `error` on failure — so it is never lost.
442        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    /// Returns the fully-wrapped shell tool as it is composed in production:
499    /// RateLimited(PathGuarded(ShellTool)).  Tests that verify path-blocking or
500    /// rate-limiting behaviour must use this helper so they exercise the wrappers.
501    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    // ── Ephemeral-workspace warning (issue #4627) ────────────────
622
623    /// On an ephemeral runtime the shell tool stays usable but every executed
624    /// command's output carries a loud warning so writes that won't persist are
625    /// visible. The original command output must be preserved below the banner.
626    #[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    /// A failed command surfaces `error`, not `output`, to the model. The
653    /// ephemeral warning must be injected into the error field too so it is
654    /// never lost on the failure path.
655    #[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    /// A command that exits 0 but also writes to stderr yields
676    /// `{ success: true, output, error: Some }`. The dispatcher shows `output`
677    /// on success, but the banner must land in BOTH fields so it survives
678    /// regardless of which the model reads. Exercises the dual-field branch.
679    #[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    /// On a persistent runtime (the default) no warning is attached.
705    #[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    /// RAII guard that restores an environment variable to its original state on drop,
825    /// ensuring cleanup even if the test panics.
826    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            // SAFETY: test-only, single-threaded test runner.
835            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                // SAFETY: test-only, single-threaded test runner.
844                Some(val) => unsafe { std::env::set_var(self.key, val) },
845                // SAFETY: test-only, single-threaded test runner.
846                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    // ── shell timeout enforcement tests ─────────────────
983
984    #[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    // ── Non-UTF8 binary output tests ────────────────────
1000
1001    #[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        // 0xFF is not valid UTF-8
1010        let input = b"hello\xFF world";
1011        let result = super::decode_output(input);
1012        // Must not panic; non-UTF-8 bytes become replacement characters on non-Windows
1013        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        // GBK encoding of "你好" is [0xC4, 0xE3, 0xBA, 0xC3]
1051        let gbk_bytes: &[u8] = &[0xC4, 0xE3, 0xBA, 0xC3];
1052        // When the console code page is GBK (936), windows_code_page_to_encoding
1053        // returns GBK and decodes correctly.  We test the transcoding function
1054        // directly since we cannot control GetConsoleOutputCP in unit tests.
1055        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    // ── Sandbox integration tests ────────────────────────
1156
1157    #[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    // ── TUI env overlay tests ─────────────────────────────────────
1211
1212    #[tokio::test(flavor = "current_thread")]
1213    async fn shell_tui_env_is_passed_to_subprocess() {
1214        // A var that is NOT in SAFE_ENV_VARS and NOT in passthrough —
1215        // it should only appear if tui_env injects it.
1216        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        // Without tui_env, a non-safe var must NOT appear.
1239        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        // tui_env wins over the process-level value for a var that is also in SAFE_ENV_VARS.
1256        // This lets the TUI's PATH (e.g. with nix/brew) win over the daemon's PATH.
1257        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        // with_tui_env(None) must be identical to no tui_env at all —
1291        // only SAFE_ENV_VARS + passthrough reach the subprocess.
1292        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        // The whole point: secrets from the TUI env (e.g. SSH_AUTH_SOCK)
1309        // DO reach the subprocess via tui_env even though they are not
1310        // in SAFE_ENV_VARS.
1311        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        // Confirm SSH_AUTH_SOCK is not in the safe list (would be a bug if it were)
1319        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}