Skip to main content

microclaw_engine/tools/
bash.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use tracing::info;
6
7use crate::config::{RtkConfig, WorkingDirIsolation};
8use crate::internal::tool_runtime::sandbox::{SandboxExecOptions, SandboxMode, SandboxRouter};
9use microclaw_core::llm_types::ToolDefinition;
10use microclaw_core::text::floor_char_boundary;
11
12use super::{schema_object, Tool, ToolResult};
13
14pub struct BashTool {
15    working_dir: PathBuf,
16    working_dir_isolation: WorkingDirIsolation,
17    default_timeout_secs: u64,
18    sandbox_router: Option<Arc<SandboxRouter>>,
19    /// Compiled command-content patterns that always force operator approval,
20    /// independent of `tool_risk` chat-level gating. Empty = no inspection.
21    dangerous_patterns: Vec<(String, regex::Regex)>,
22    /// `Some` only when RTK rewriting is enabled in config.
23    rtk: Option<RtkConfig>,
24}
25
26impl BashTool {
27    pub fn new(working_dir: &str) -> Self {
28        Self::new_with_isolation(working_dir, WorkingDirIsolation::Shared)
29    }
30
31    pub fn new_with_isolation(
32        working_dir: &str,
33        working_dir_isolation: WorkingDirIsolation,
34    ) -> Self {
35        Self {
36            working_dir: PathBuf::from(working_dir),
37            working_dir_isolation,
38            default_timeout_secs: 120,
39            sandbox_router: None,
40            dangerous_patterns: Vec::new(),
41            rtk: None,
42        }
43    }
44
45    pub fn with_default_timeout_secs(mut self, timeout_secs: u64) -> Self {
46        self.default_timeout_secs = timeout_secs;
47        self
48    }
49
50    pub fn with_sandbox_router(mut self, router: Arc<SandboxRouter>) -> Self {
51        self.sandbox_router = Some(router);
52        self
53    }
54
55    pub fn with_rtk(mut self, rtk: &RtkConfig) -> Self {
56        if rtk.enabled {
57            self.rtk = Some(rtk.clone());
58        }
59        self
60    }
61
62    /// Compile and store dangerous-command patterns. Invalid regexes are
63    /// logged and skipped — operator typos must not break the bash tool.
64    pub fn with_dangerous_patterns(mut self, patterns: &[String]) -> Self {
65        for raw in patterns {
66            let with_flag = if raw.starts_with("(?i)") {
67                raw.clone()
68            } else {
69                format!("(?i){raw}")
70            };
71            match regex::Regex::new(&with_flag) {
72                Ok(re) => self.dangerous_patterns.push((raw.clone(), re)),
73                Err(e) => {
74                    tracing::warn!("ignoring invalid bash_dangerous_patterns entry {raw:?}: {e}")
75                }
76            }
77        }
78        self
79    }
80}
81
82use crate::internal::tool_runtime::runtime::HIGH_RISK_APPROVED_KEY as BASH_HIGH_RISK_APPROVED_KEY;
83
84fn extract_env_files(input: &serde_json::Value) -> Vec<PathBuf> {
85    super::auth_context_from_input(input)
86        .map(|auth| auth.env_files.iter().map(PathBuf::from).collect())
87        .unwrap_or_default()
88}
89
90const REDACT_MIN_VALUE_LEN: usize = 8;
91
92fn redact_env_secrets(output: &str, env_files: &[PathBuf]) -> String {
93    let mut secrets: Vec<(String, String)> = Vec::new();
94    for env_file in env_files {
95        if let Ok(content) = std::fs::read_to_string(env_file) {
96            for (key, value) in crate::internal::tool_runtime::env_file::parse_dotenv(&content) {
97                if value.len() >= REDACT_MIN_VALUE_LEN {
98                    secrets.push((key, value));
99                }
100            }
101        }
102    }
103    if secrets.is_empty() {
104        return output.to_string();
105    }
106    secrets.sort_by_key(|secret| std::cmp::Reverse(secret.1.len()));
107    let mut redacted = output.to_string();
108    for (key, value) in &secrets {
109        redacted = redacted.replace(value, &format!("[REDACTED:{key}]"));
110    }
111    redacted
112}
113
114fn contains_explicit_tmp_absolute_path(command: &str) -> bool {
115    let mut start = 0usize;
116    while let Some(offset) = command[start..].find("/tmp/") {
117        let idx = start + offset;
118        let prev = if idx == 0 {
119            None
120        } else {
121            command[..idx].chars().next_back()
122        };
123        if prev.is_none()
124            || matches!(
125                prev,
126                Some(' ' | '\t' | '\n' | '\'' | '"' | '=' | '(' | ':' | ';' | '|')
127            )
128        {
129            return true;
130        }
131        start = idx + 5;
132    }
133    false
134}
135
136fn extract_explicit_tmp_absolute_path(command: &str) -> Option<String> {
137    let mut start = 0usize;
138    while let Some(offset) = command[start..].find("/tmp/") {
139        let idx = start + offset;
140        let prev = if idx == 0 {
141            None
142        } else {
143            command[..idx].chars().next_back()
144        };
145        if prev.is_none()
146            || matches!(
147                prev,
148                Some(' ' | '\t' | '\n' | '\'' | '"' | '=' | '(' | ':' | ';' | '|')
149            )
150        {
151            let suffix = &command[idx + 5..];
152            let end = suffix
153                .find(|c: char| c.is_whitespace() || matches!(c, '\'' | '"' | ')' | ';' | '|'))
154                .unwrap_or(suffix.len());
155            return Some(command[idx..idx + 5 + end].to_string());
156        }
157        start = idx + 5;
158    }
159    None
160}
161
162fn suggested_tmp_working_dir_path(working_dir: &Path, command: &str) -> Option<PathBuf> {
163    let tmp_path = extract_explicit_tmp_absolute_path(command)?;
164    let relative = tmp_path.trim_start_matches("/tmp/").trim();
165    if relative.is_empty() {
166        return Some(working_dir.to_path_buf());
167    }
168    Some(working_dir.join(relative))
169}
170
171fn command_accesses_dotenv(command: &str) -> bool {
172    let patterns = [".env", "dotenv", "env_file"];
173    let lower = command.to_ascii_lowercase();
174    patterns.iter().any(|p| lower.contains(p))
175}
176
177/// Ceiling on the rewrite subprocess; rtk classifies in <10ms, so hitting
178/// this means a hung binary, not a slow rewrite.
179const RTK_REWRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
180
181/// Ask the rtk binary for a token-efficient rewrite of `command`.
182///
183/// `rtk rewrite` exit-code protocol (rtk's hooks/claude/rtk-rewrite.sh is the
184/// reference consumer): 0 = rewritten (stdout), 1 = no RTK equivalent,
185/// 2 = deny rule, 3 = ask rule. Only exit 0 is accepted here — deny/ask
186/// express RTK-side permission rules that MicroClaw's own approval gates
187/// (`dangerous_patterns`, `tool_risk`) are responsible for, so those run the
188/// original command. Missing binary, timeout, or any other failure also fall
189/// back to the original command: RTK must never break the bash tool.
190async fn rtk_rewrite(binary_path: &str, command: &str) -> Option<String> {
191    let output = tokio::time::timeout(
192        RTK_REWRITE_TIMEOUT,
193        tokio::process::Command::new(binary_path)
194            .arg("rewrite")
195            .arg(command)
196            .stdin(std::process::Stdio::null())
197            .output(),
198    )
199    .await;
200    let output = match output {
201        Ok(Ok(output)) => output,
202        Ok(Err(e)) => {
203            tracing::warn!(
204                "rtk rewrite unavailable ({binary_path}): {e}; running original command"
205            );
206            return None;
207        }
208        Err(_) => {
209            tracing::warn!("rtk rewrite timed out; running original command");
210            return None;
211        }
212    };
213    if output.status.code() != Some(0) {
214        return None;
215    }
216    let rewritten = String::from_utf8_lossy(&output.stdout).trim().to_string();
217    if rewritten.is_empty() || rewritten == command {
218        return None;
219    }
220    Some(rewritten)
221}
222
223fn command_not_found_hint(router: Option<&Arc<SandboxRouter>>) -> &'static str {
224    match router {
225        Some(router) if router.mode() == SandboxMode::All => {
226            "Command was not found in the current execution environment. Install it on the host, or ensure the configured sandbox image contains it."
227        }
228        _ => {
229            "Command was not found on the host. Install it, or enable sandbox mode with an image that already contains the dependency."
230        }
231    }
232}
233
234#[async_trait]
235impl Tool for BashTool {
236    fn name(&self) -> &str {
237        "bash"
238    }
239
240    fn definition(&self) -> ToolDefinition {
241        ToolDefinition {
242            name: "bash".into(),
243            description: "Execute a bash command and return the output. IMPORTANT: You must CALL this tool (not write it as text) to run a command. Use for running shell commands, scripts, or system operations. Prefer relative paths rooted in the current chat working directory, and use its tmp/ subdirectory instead of absolute /tmp paths. Do not invent machine-specific absolute paths like /home/... or /Users/... unless the user or a tool already provided them.".into(),
244            input_schema: schema_object(
245                json!({
246                    "command": {
247                        "type": "string",
248                        "description": "The bash command to execute"
249                    },
250                    "timeout_secs": {
251                        "type": "integer",
252                        "description": "Timeout in seconds (defaults to configured tool timeout budget)"
253                    }
254                }),
255                &["command"],
256            ),
257        }
258    }
259
260    async fn execute(&self, input: serde_json::Value) -> ToolResult {
261        let command = match input.get("command").and_then(|v| v.as_str()) {
262            Some(c) => c,
263            None => return ToolResult::error("Missing 'command' parameter".into()),
264        };
265
266        // Command-content gate: certain shell snippets are dangerous regardless
267        // of which chat invoked the tool. Force operator approval before
268        // proceeding. The auto-retry path in tool_executor sees the
269        // `approval_required` error type and either retries with the marker
270        // (after explicit user approval) or pauses the agent.
271        let already_approved = input
272            .get(BASH_HIGH_RISK_APPROVED_KEY)
273            .and_then(|v| v.as_bool())
274            .unwrap_or(false);
275        if !already_approved {
276            for (raw, re) in &self.dangerous_patterns {
277                if re.is_match(command) {
278                    return ToolResult::error(format!(
279                        "Approval required: command matches dangerous pattern `{raw}`. Operator must explicitly approve before this runs."
280                    ))
281                    .with_error_type("approval_required");
282                }
283            }
284        }
285
286        let timeout_secs = input
287            .get("timeout_secs")
288            .and_then(|v| v.as_u64())
289            .unwrap_or(self.default_timeout_secs);
290        let working_dir =
291            super::resolve_tool_working_dir(&self.working_dir, self.working_dir_isolation, &input)
292                .join("tmp");
293        if let Err(e) = tokio::fs::create_dir_all(&working_dir).await {
294            return ToolResult::error(format!(
295                "Failed to create working directory {}: {e}",
296                working_dir.display()
297            ));
298        }
299
300        if contains_explicit_tmp_absolute_path(command) {
301            let suggested_path = suggested_tmp_working_dir_path(&working_dir, command)
302                .map(|p| p.display().to_string())
303                .unwrap_or_else(|| working_dir.display().to_string());
304            return ToolResult::error(format!(
305                "Command contains an absolute /tmp path. Use the current chat working directory instead: {}. For example, replace the /tmp path with {}.",
306                working_dir.display(),
307                suggested_path
308            ))
309            .with_error_type("path_policy_blocked");
310        }
311
312        let env_files = extract_env_files(&input);
313        if !env_files.is_empty() && command_accesses_dotenv(command) {
314            return ToolResult::error(
315                "Command appears to access .env files, which is blocked for security. Skill environment variables are already injected automatically.".into(),
316            )
317            .with_error_type("env_access_blocked");
318        }
319
320        // RTK rewrite runs after every gate above so the gates always judge
321        // the operator-visible command, never the rtk-prefixed variant.
322        let mut exec_command = command.to_string();
323        let mut rtk_rewritten = false;
324        if let Some(rtk) = &self.rtk {
325            if let Some(rewritten) = rtk_rewrite(&rtk.binary_path, command).await {
326                info!("rtk rewrite: {command} -> {rewritten}");
327                exec_command = rewritten;
328                rtk_rewritten = true;
329            }
330        }
331
332        info!(
333            "Executing bash in {}: {}",
334            working_dir.display(),
335            exec_command
336        );
337
338        let session_key = super::auth_context_from_input(&input)
339            .map(|auth| format!("{}-{}", auth.caller_channel, auth.caller_chat_id))
340            .unwrap_or_else(|| "shared".to_string());
341        let env_files_for_redact = env_files.clone();
342        let exec_opts = SandboxExecOptions {
343            timeout: std::time::Duration::from_secs(timeout_secs),
344            working_dir: Some(working_dir.clone()),
345            envs: std::collections::HashMap::new(),
346            env_files,
347        };
348        let mut result = if let Some(router) = &self.sandbox_router {
349            router.exec(&session_key, &exec_command, &exec_opts).await
350        } else {
351            crate::internal::tool_runtime::sandbox::exec_host_command(&exec_command, &exec_opts)
352                .await
353        };
354
355        // The rewrite decision is made on the host, but execution may land in
356        // a sandbox image that lacks the rtk binary (exit 127). Retry the
357        // original command so enabling RTK never makes a command fail that
358        // would have succeeded without it.
359        if rtk_rewritten && matches!(&result, Ok(output) if output.exit_code == 127) {
360            info!("rtk not available in execution environment; retrying original command");
361            result = if let Some(router) = &self.sandbox_router {
362                router.exec(&session_key, command, &exec_opts).await
363            } else {
364                crate::internal::tool_runtime::sandbox::exec_host_command(command, &exec_opts).await
365            };
366        }
367
368        match result {
369            Ok(output) => {
370                let stdout = output.stdout;
371                let stderr = output.stderr;
372                let exit_code = output.exit_code;
373
374                let mut result_text = String::new();
375                if !stdout.is_empty() {
376                    result_text.push_str(stdout.as_str());
377                }
378                if !stderr.is_empty() {
379                    if !result_text.is_empty() {
380                        result_text.push('\n');
381                    }
382                    result_text.push_str("STDERR:\n");
383                    result_text.push_str(stderr.as_str());
384                }
385                if result_text.is_empty() {
386                    result_text = format!("Command completed with exit code {exit_code}");
387                }
388
389                result_text = redact_env_secrets(&result_text, &env_files_for_redact);
390
391                if exit_code == 127 && stderr.to_ascii_lowercase().contains("command not found") {
392                    if !result_text.ends_with('\n') {
393                        result_text.push('\n');
394                    }
395                    result_text.push_str(command_not_found_hint(self.sandbox_router.as_ref()));
396                }
397
398                // Truncate very long output
399                if result_text.len() > 30000 {
400                    let cutoff = floor_char_boundary(&result_text, 30000);
401                    result_text.truncate(cutoff);
402                    result_text.push_str("\n... (output truncated)");
403                }
404
405                if exit_code == 0 {
406                    ToolResult::success(result_text).with_status_code(exit_code)
407                } else {
408                    ToolResult::error(format!("Exit code {exit_code}\n{result_text}"))
409                        .with_status_code(exit_code)
410                        .with_error_type("process_exit")
411                }
412            }
413            Err(e) => {
414                let msg = e.to_string();
415                if msg.contains("timed out after") {
416                    ToolResult::error(format!("Command timed out after {timeout_secs} seconds"))
417                        .with_error_type("timeout")
418                } else {
419                    ToolResult::error(format!("Failed to execute command: {e}"))
420                        .with_error_type("spawn_error")
421                }
422            }
423        }
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use serde_json::json;
431
432    fn sleep_command(seconds: u64) -> String {
433        if cfg!(target_os = "windows") {
434            format!("Start-Sleep -Seconds {seconds}")
435        } else {
436            format!("sleep {seconds}")
437        }
438    }
439
440    fn stderr_command() -> &'static str {
441        if cfg!(target_os = "windows") {
442            "[Console]::Error.WriteLine('err')"
443        } else {
444            "echo err >&2"
445        }
446    }
447
448    fn write_marker_command(file_name: &str) -> String {
449        if cfg!(target_os = "windows") {
450            format!("New-Item -ItemType File -Path '{file_name}' -Force | Out-Null")
451        } else {
452            format!("touch '{file_name}'")
453        }
454    }
455
456    fn echo_env_command(var_name: &str) -> String {
457        if cfg!(target_os = "windows") {
458            format!("$env:{var_name}")
459        } else {
460            format!("echo ${var_name}")
461        }
462    }
463
464    #[test]
465    fn test_contains_explicit_tmp_absolute_path_detection() {
466        assert!(contains_explicit_tmp_absolute_path("ls /tmp/x"));
467        assert!(contains_explicit_tmp_absolute_path("A=\"/tmp/x\"; echo $A"));
468        assert!(!contains_explicit_tmp_absolute_path(
469            "ls /Users/eevv/work/project/tmp/x"
470        ));
471    }
472
473    #[test]
474    fn test_extract_explicit_tmp_absolute_path() {
475        assert_eq!(
476            extract_explicit_tmp_absolute_path("git clone repo /tmp/lootbox"),
477            Some("/tmp/lootbox".to_string())
478        );
479        assert_eq!(
480            extract_explicit_tmp_absolute_path("A=\"/tmp/lootbox\"; echo $A"),
481            Some("/tmp/lootbox".to_string())
482        );
483    }
484
485    #[test]
486    fn test_suggested_tmp_working_dir_path() {
487        let work = Path::new("/workspace/chat/tmp");
488        assert_eq!(
489            suggested_tmp_working_dir_path(work, "git clone repo /tmp/lootbox"),
490            Some(PathBuf::from("/workspace/chat/tmp/lootbox"))
491        );
492    }
493
494    #[tokio::test]
495    async fn test_bash_echo() {
496        let tool = BashTool::new(".");
497        let result = tool.execute(json!({"command": "echo hello"})).await;
498        assert!(!result.is_error);
499        assert!(result.content.contains("hello"));
500    }
501
502    #[tokio::test]
503    async fn test_dangerous_pattern_returns_approval_required() {
504        let patterns = vec![r"\bsudo\b".to_string()];
505        let tool = BashTool::new(".").with_dangerous_patterns(&patterns);
506        let result = tool.execute(json!({"command": "sudo ls"})).await;
507        assert!(result.is_error, "expected approval gate, got success");
508        assert_eq!(result.error_type.as_deref(), Some("approval_required"));
509        assert!(result.content.contains("dangerous pattern"));
510    }
511
512    #[tokio::test]
513    async fn test_dangerous_pattern_skipped_after_approval_marker() {
514        let patterns = vec![r"\bsudo\b".to_string()];
515        let tool = BashTool::new(".").with_dangerous_patterns(&patterns);
516        // Marker present -> pattern check skipped. The actual sudo will likely
517        // fail on the test host, but we only want to verify the gate yielded.
518        let result = tool
519            .execute(json!({
520                "command": "echo not-actually-sudo",
521                "__microclaw_high_risk_approved": true,
522            }))
523            .await;
524        assert!(!result.is_error);
525        assert!(result.content.contains("not-actually-sudo"));
526    }
527
528    #[tokio::test]
529    async fn test_invalid_dangerous_pattern_is_skipped_gracefully() {
530        let patterns = vec!["[unclosed".to_string(), r"\brm\s+-rf\s+/".to_string()];
531        let tool = BashTool::new(".").with_dangerous_patterns(&patterns);
532        // The valid pattern still trips on the dangerous command.
533        let result = tool.execute(json!({"command": "rm -rf /tmp"})).await;
534        // Should fail on the path-policy gate instead of pattern match (since
535        // /tmp absolute path is a separate gate), so just assert we didn't panic.
536        let _ = result;
537    }
538
539    #[tokio::test]
540    async fn test_bash_exit_code_nonzero() {
541        let tool = BashTool::new(".");
542        let result = tool.execute(json!({"command": "exit 1"})).await;
543        assert!(result.is_error);
544        assert!(result.content.contains("Exit code 1"));
545    }
546
547    #[tokio::test]
548    async fn test_bash_stderr() {
549        let tool = BashTool::new(".");
550        let result = tool.execute(json!({"command": stderr_command()})).await;
551        assert!(!result.is_error); // exit code is 0
552        assert!(result.content.contains("STDERR"));
553        assert!(result.content.contains("err"));
554    }
555
556    #[tokio::test]
557    async fn test_bash_timeout() {
558        let tool = BashTool::new(".");
559        let result = tool
560            .execute(json!({"command": sleep_command(10), "timeout_secs": 1}))
561            .await;
562        assert!(result.is_error);
563        assert!(result.content.contains("timed out"));
564    }
565
566    #[tokio::test]
567    async fn test_bash_blocks_tmp_absolute_path() {
568        let tool = BashTool::new(".");
569        let result = tool.execute(json!({"command": "ls /tmp/x"})).await;
570        assert!(result.is_error);
571        assert_eq!(result.error_type.as_deref(), Some("path_policy_blocked"));
572        assert!(result.content.contains("current chat working directory"));
573        assert!(result.content.contains("replace the /tmp path"));
574    }
575
576    #[tokio::test]
577    async fn test_bash_missing_command() {
578        let tool = BashTool::new(".");
579        let result = tool.execute(json!({})).await;
580        assert!(result.is_error);
581        assert!(result.content.contains("Missing 'command'"));
582    }
583
584    #[test]
585    fn test_bash_tool_name_and_definition() {
586        let tool = BashTool::new(".");
587        assert_eq!(tool.name(), "bash");
588        let def = tool.definition();
589        assert_eq!(def.name, "bash");
590        assert!(def
591            .description
592            .contains("Prefer relative paths rooted in the current chat working directory"));
593        assert!(def.description.contains("/home/... or /Users/..."));
594        assert!(def.input_schema["properties"]["command"].is_object());
595    }
596
597    #[tokio::test]
598    async fn test_bash_uses_working_dir() {
599        let root = std::env::temp_dir().join(format!("microclaw_bash_{}", uuid::Uuid::new_v4()));
600        let work = root.join("workspace");
601        std::fs::create_dir_all(&work).unwrap();
602
603        let tool = BashTool::new(work.to_str().unwrap());
604        let marker = "cwd_marker.txt";
605        let result = tool
606            .execute(json!({"command": write_marker_command(marker)}))
607            .await;
608        assert!(!result.is_error);
609
610        let expected_marker = work.join("shared").join("tmp").join(marker);
611        assert!(expected_marker.exists());
612
613        let _ = std::fs::remove_dir_all(&root);
614    }
615
616    #[tokio::test]
617    async fn test_bash_chat_isolation_uses_chat_working_dir() {
618        let root = std::env::temp_dir().join(format!("microclaw_bash_{}", uuid::Uuid::new_v4()));
619        let work = root.join("workspace");
620        std::fs::create_dir_all(&work).unwrap();
621
622        let tool = BashTool::new_with_isolation(work.to_str().unwrap(), WorkingDirIsolation::Chat);
623        let marker = "chat_marker.txt";
624        let result = tool
625            .execute(json!({
626                "command": write_marker_command(marker),
627                "__microclaw_auth": {
628                    "caller_channel": "telegram",
629                    "caller_chat_id": -100123,
630                    "control_chat_ids": []
631                }
632            }))
633            .await;
634        assert!(!result.is_error);
635
636        let expected_marker = work
637            .join("chat")
638            .join("telegram")
639            .join("neg100123")
640            .join("tmp")
641            .join(marker);
642        assert!(expected_marker.exists());
643
644        let _ = std::fs::remove_dir_all(&root);
645    }
646
647    #[test]
648    fn test_extract_env_files_from_input() {
649        let input = json!({
650            "command": "echo hi",
651            "__microclaw_auth": {
652                "caller_channel": "telegram",
653                "caller_chat_id": 1,
654                "control_chat_ids": [],
655                "env_files": [
656                    "/home/user/.microclaw/skills/outline/.env",
657                    "/home/user/.microclaw/skills/weather/.env"
658                ]
659            }
660        });
661        let files = extract_env_files(&input);
662        assert_eq!(files.len(), 2);
663        assert_eq!(
664            files[0],
665            PathBuf::from("/home/user/.microclaw/skills/outline/.env")
666        );
667    }
668
669    #[test]
670    fn test_extract_env_files_empty_when_absent() {
671        let input = json!({"command": "echo hi"});
672        let files = extract_env_files(&input);
673        assert!(files.is_empty());
674    }
675
676    #[tokio::test]
677    async fn test_bash_injects_env_files_into_execution() {
678        let root =
679            std::env::temp_dir().join(format!("microclaw_bash_env_{}", uuid::Uuid::new_v4()));
680        let work = root.join("workspace");
681        std::fs::create_dir_all(&work).unwrap();
682
683        let env_dir = root.join("skill_env");
684        std::fs::create_dir_all(&env_dir).unwrap();
685        let env_file = env_dir.join(".env");
686        std::fs::write(&env_file, "TEST_SKILL_VAR=skill_value_42\n").unwrap();
687
688        let tool = BashTool::new(work.to_str().unwrap());
689        let result = tool
690            .execute(json!({
691                "command": echo_env_command("TEST_SKILL_VAR"),
692                "__microclaw_auth": {
693                    "caller_channel": "telegram",
694                    "caller_chat_id": 1,
695                    "control_chat_ids": [],
696                    "env_files": [env_file.to_string_lossy()]
697                }
698            }))
699            .await;
700        assert!(!result.is_error);
701        assert!(
702            result.content.contains("[REDACTED:TEST_SKILL_VAR]"),
703            "expected redacted output, got: {}",
704            result.content
705        );
706        assert!(!result.content.contains("skill_value_42"));
707
708        let _ = std::fs::remove_dir_all(&root);
709    }
710
711    #[test]
712    fn test_redact_env_secrets_replaces_values() {
713        let dir = std::env::temp_dir().join(format!("microclaw_redact_{}", uuid::Uuid::new_v4()));
714        std::fs::create_dir_all(&dir).unwrap();
715        let env_file = dir.join(".env");
716        std::fs::write(&env_file, "API_KEY=supersecretkey123\nSHORT=ab\n").unwrap();
717
718        let output = "Response: supersecretkey123 is the key";
719        let redacted = redact_env_secrets(output, &[env_file]);
720        assert!(redacted.contains("[REDACTED:API_KEY]"));
721        assert!(!redacted.contains("supersecretkey123"));
722        assert!(!redacted.contains("[REDACTED:SHORT]"));
723
724        let _ = std::fs::remove_dir_all(&dir);
725    }
726
727    #[test]
728    fn test_redact_env_secrets_no_env_files() {
729        let output = "some output text";
730        let redacted = redact_env_secrets(output, &[]);
731        assert_eq!(redacted, output);
732    }
733
734    /// Writes a fake `rtk` binary that speaks the `rtk rewrite` exit-code
735    /// protocol: exit 0 + rewritten command for `echo original`, exit 3 (ask)
736    /// for `echo askme`, exit 0 + nonexistent binary for `echo fallback`,
737    /// exit 1 (no equivalent) for everything else.
738    #[cfg(unix)]
739    fn write_fake_rtk(dir: &Path) -> PathBuf {
740        use std::os::unix::fs::PermissionsExt;
741        let path = dir.join("rtk");
742        std::fs::write(
743            &path,
744            r#"#!/bin/sh
745[ "$1" = "rewrite" ] || exit 1
746case "$2" in
747  "echo original") echo "echo rewritten-by-rtk"; exit 0 ;;
748  "echo askme") echo "echo SHOULD-NOT-RUN"; exit 3 ;;
749  "echo fallback") echo "microclaw_test_no_such_binary_xyz"; exit 0 ;;
750  *) exit 1 ;;
751esac
752"#,
753        )
754        .unwrap();
755        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
756        path
757    }
758
759    #[cfg(unix)]
760    fn rtk_test_tool() -> (BashTool, PathBuf) {
761        let root = std::env::temp_dir().join(format!("microclaw_rtk_{}", uuid::Uuid::new_v4()));
762        std::fs::create_dir_all(&root).unwrap();
763        let fake_rtk = write_fake_rtk(&root);
764        let tool = BashTool::new(".").with_rtk(&RtkConfig {
765            enabled: true,
766            binary_path: fake_rtk.to_string_lossy().into_owned(),
767        });
768        (tool, root)
769    }
770
771    #[cfg(unix)]
772    #[tokio::test]
773    async fn test_rtk_rewrite_applied_when_enabled() {
774        let (tool, root) = rtk_test_tool();
775        let result = tool.execute(json!({"command": "echo original"})).await;
776        assert!(!result.is_error);
777        assert!(result.content.contains("rewritten-by-rtk"));
778        let _ = std::fs::remove_dir_all(&root);
779    }
780
781    #[cfg(unix)]
782    #[tokio::test]
783    async fn test_rtk_no_equivalent_runs_original() {
784        let (tool, root) = rtk_test_tool();
785        let result = tool.execute(json!({"command": "echo untouched"})).await;
786        assert!(!result.is_error);
787        assert!(result.content.contains("untouched"));
788        let _ = std::fs::remove_dir_all(&root);
789    }
790
791    #[cfg(unix)]
792    #[tokio::test]
793    async fn test_rtk_ask_rule_runs_original() {
794        let (tool, root) = rtk_test_tool();
795        let result = tool.execute(json!({"command": "echo askme"})).await;
796        assert!(!result.is_error);
797        assert!(result.content.contains("askme"));
798        assert!(!result.content.contains("SHOULD-NOT-RUN"));
799        let _ = std::fs::remove_dir_all(&root);
800    }
801
802    #[cfg(unix)]
803    #[tokio::test]
804    async fn test_rtk_falls_back_to_original_on_exit_127() {
805        let (tool, root) = rtk_test_tool();
806        let result = tool.execute(json!({"command": "echo fallback"})).await;
807        assert!(
808            !result.is_error,
809            "expected fallback retry, got: {}",
810            result.content
811        );
812        assert!(result.content.contains("fallback"));
813        let _ = std::fs::remove_dir_all(&root);
814    }
815
816    #[cfg(unix)]
817    #[tokio::test]
818    async fn test_rtk_missing_binary_runs_original() {
819        let tool = BashTool::new(".").with_rtk(&RtkConfig {
820            enabled: true,
821            binary_path: "/nonexistent/path/to/rtk".to_string(),
822        });
823        let result = tool.execute(json!({"command": "echo hello"})).await;
824        assert!(!result.is_error);
825        assert!(result.content.contains("hello"));
826    }
827
828    #[tokio::test]
829    async fn test_rtk_disabled_config_is_ignored() {
830        let tool = BashTool::new(".").with_rtk(&RtkConfig {
831            enabled: false,
832            binary_path: "/nonexistent/path/to/rtk".to_string(),
833        });
834        assert!(tool.rtk.is_none());
835    }
836
837    #[test]
838    fn test_command_accesses_dotenv_detection() {
839        assert!(command_accesses_dotenv("cat .env"));
840        assert!(command_accesses_dotenv("cat /path/to/.env.local"));
841        assert!(command_accesses_dotenv("source dotenv"));
842        assert!(!command_accesses_dotenv("echo hello"));
843        assert!(!command_accesses_dotenv("ls -la"));
844    }
845
846    #[tokio::test]
847    async fn test_bash_blocks_dotenv_access_when_env_files_active() {
848        let tool = BashTool::new(".");
849        let result = tool
850            .execute(json!({
851                "command": "cat .env",
852                "__microclaw_auth": {
853                    "caller_channel": "telegram",
854                    "caller_chat_id": 1,
855                    "control_chat_ids": [],
856                    "env_files": ["/some/skill/.env"]
857                }
858            }))
859            .await;
860        assert!(result.is_error);
861        assert_eq!(result.error_type.as_deref(), Some("env_access_blocked"));
862    }
863
864    #[tokio::test]
865    async fn test_bash_allows_dotenv_mention_without_env_files() {
866        let tool = BashTool::new(".");
867        let result = tool
868            .execute(json!({
869                "command": "echo .env is a file"
870            }))
871            .await;
872        assert!(!result.is_error);
873    }
874}