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 dangerous_patterns: Vec<(String, regex::Regex)>,
22 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 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
177const RTK_REWRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
180
181async 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 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 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 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 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 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 let result = tool.execute(json!({"command": "rm -rf /tmp"})).await;
534 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); 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 #[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}