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 ClaimSource, 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 claim_source: Some(ClaimSource::Shell),
254 }))
255 }
256
257 async fn execute_block(
258 &self,
259 block: &str,
260 skip_confirm: bool,
261 ) -> Result<(String, Option<FilterStats>), ToolError> {
262 self.check_permissions(block, skip_confirm).await?;
263 self.validate_sandbox(block)?;
264
265 if let Some(ref tx) = self.tool_event_tx {
266 let _ = tx.send(ToolEvent::Started {
267 tool_name: "bash".to_owned(),
268 command: block.to_owned(),
269 });
270 }
271
272 let start = Instant::now();
273 let skill_env_snapshot: Option<std::collections::HashMap<String, String>> =
274 self.skill_env.read().ok().and_then(|g| g.clone());
275 let (out, exit_code) = execute_bash(
276 block,
277 self.timeout,
278 self.tool_event_tx.as_ref(),
279 self.cancel_token.as_ref(),
280 skill_env_snapshot.as_ref(),
281 )
282 .await;
283 if exit_code == 130
284 && self
285 .cancel_token
286 .as_ref()
287 .is_some_and(CancellationToken::is_cancelled)
288 {
289 return Err(ToolError::Cancelled);
290 }
291 #[allow(clippy::cast_possible_truncation)]
292 let duration_ms = start.elapsed().as_millis() as u64;
293
294 let is_timeout = out.contains("[error] command timed out");
295 let audit_result = if is_timeout {
296 AuditResult::Timeout
297 } else if out.contains("[error]") || out.contains("[stderr]") {
298 AuditResult::Error {
299 message: out.clone(),
300 }
301 } else {
302 AuditResult::Success
303 };
304 self.log_audit(block, audit_result, duration_ms, None).await;
305
306 if is_timeout {
307 self.emit_completed(block, &out, false, None);
308 return Err(ToolError::Timeout {
309 timeout_secs: self.timeout.as_secs(),
310 });
311 }
312
313 if let Some(category) = classify_shell_exit(exit_code, &out) {
314 self.emit_completed(block, &out, false, None);
315 return Err(ToolError::Shell {
316 exit_code,
317 category,
318 message: out.lines().take(3).collect::<Vec<_>>().join("; "),
319 });
320 }
321
322 let sanitized = sanitize_output(&out);
323 let mut per_block_stats: Option<FilterStats> = None;
324 let filtered = if let Some(ref registry) = self.output_filter_registry {
325 match registry.apply(block, &sanitized, exit_code) {
326 Some(fr) => {
327 tracing::debug!(
328 command = block,
329 raw = fr.raw_chars,
330 filtered = fr.filtered_chars,
331 savings_pct = fr.savings_pct(),
332 "output filter applied"
333 );
334 per_block_stats = Some(FilterStats {
335 raw_chars: fr.raw_chars,
336 filtered_chars: fr.filtered_chars,
337 raw_lines: fr.raw_lines,
338 filtered_lines: fr.filtered_lines,
339 confidence: Some(fr.confidence),
340 command: Some(block.to_owned()),
341 kept_lines: fr.kept_lines.clone(),
342 });
343 fr.output
344 }
345 None => sanitized,
346 }
347 } else {
348 sanitized
349 };
350
351 self.emit_completed(
352 block,
353 &out,
354 !out.contains("[error]"),
355 per_block_stats.clone(),
356 );
357
358 Ok((format!("$ {block}\n{filtered}"), per_block_stats))
359 }
360
361 fn emit_completed(
362 &self,
363 command: &str,
364 output: &str,
365 success: bool,
366 filter_stats: Option<FilterStats>,
367 ) {
368 if let Some(ref tx) = self.tool_event_tx {
369 let _ = tx.send(ToolEvent::Completed {
370 tool_name: "bash".to_owned(),
371 command: command.to_owned(),
372 output: output.to_owned(),
373 success,
374 filter_stats,
375 diff: None,
376 });
377 }
378 }
379
380 async fn check_permissions(&self, block: &str, skip_confirm: bool) -> Result<(), ToolError> {
382 if let Some(blocked) = self.find_blocked_command(block) {
385 let err = ToolError::Blocked {
386 command: blocked.to_owned(),
387 };
388 self.log_audit(
389 block,
390 AuditResult::Blocked {
391 reason: format!("blocked command: {blocked}"),
392 },
393 0,
394 Some(&err),
395 )
396 .await;
397 return Err(err);
398 }
399
400 if let Some(ref policy) = self.permission_policy {
401 match policy.check("bash", block) {
402 PermissionAction::Deny => {
403 let err = ToolError::Blocked {
404 command: block.to_owned(),
405 };
406 self.log_audit(
407 block,
408 AuditResult::Blocked {
409 reason: "denied by permission policy".to_owned(),
410 },
411 0,
412 Some(&err),
413 )
414 .await;
415 return Err(err);
416 }
417 PermissionAction::Ask if !skip_confirm => {
418 return Err(ToolError::ConfirmationRequired {
419 command: block.to_owned(),
420 });
421 }
422 _ => {}
423 }
424 } else if !skip_confirm && let Some(pattern) = self.find_confirm_command(block) {
425 return Err(ToolError::ConfirmationRequired {
426 command: pattern.to_owned(),
427 });
428 }
429
430 Ok(())
431 }
432
433 fn validate_sandbox(&self, code: &str) -> Result<(), ToolError> {
434 let cwd = std::env::current_dir().unwrap_or_default();
435
436 for token in extract_paths(code) {
437 if has_traversal(&token) {
438 return Err(ToolError::SandboxViolation { path: token });
439 }
440
441 let path = if token.starts_with('/') {
442 PathBuf::from(&token)
443 } else {
444 cwd.join(&token)
445 };
446 let canonical = path
447 .canonicalize()
448 .or_else(|_| std::path::absolute(&path))
449 .unwrap_or(path);
450 if !self
451 .allowed_paths
452 .iter()
453 .any(|allowed| canonical.starts_with(allowed))
454 {
455 return Err(ToolError::SandboxViolation {
456 path: canonical.display().to_string(),
457 });
458 }
459 }
460 Ok(())
461 }
462
463 fn find_blocked_command(&self, code: &str) -> Option<&str> {
498 let cleaned = strip_shell_escapes(&code.to_lowercase());
499 let commands = tokenize_commands(&cleaned);
500 for blocked in &self.blocked_commands {
501 for cmd_tokens in &commands {
502 if tokens_match_pattern(cmd_tokens, blocked) {
503 return Some(blocked.as_str());
504 }
505 }
506 }
507 for inner in extract_subshell_contents(&cleaned) {
509 let inner_commands = tokenize_commands(&inner);
510 for blocked in &self.blocked_commands {
511 for cmd_tokens in &inner_commands {
512 if tokens_match_pattern(cmd_tokens, blocked) {
513 return Some(blocked.as_str());
514 }
515 }
516 }
517 }
518 None
519 }
520
521 fn find_confirm_command(&self, code: &str) -> Option<&str> {
522 let normalized = code.to_lowercase();
523 for pattern in &self.confirm_patterns {
524 if normalized.contains(pattern.as_str()) {
525 return Some(pattern.as_str());
526 }
527 }
528 None
529 }
530
531 async fn log_audit(
532 &self,
533 command: &str,
534 result: AuditResult,
535 duration_ms: u64,
536 error: Option<&ToolError>,
537 ) {
538 if let Some(ref logger) = self.audit_logger {
539 let (error_category, error_domain, error_phase) =
540 error.map_or((None, None, None), |e| {
541 let cat = e.category();
542 (
543 Some(cat.label().to_owned()),
544 Some(cat.domain().label().to_owned()),
545 Some(cat.phase().label().to_owned()),
546 )
547 });
548 let entry = AuditEntry {
549 timestamp: chrono_now(),
550 tool: "shell".into(),
551 command: command.into(),
552 result,
553 duration_ms,
554 error_category,
555 error_domain,
556 error_phase,
557 claim_source: Some(ClaimSource::Shell),
558 mcp_server_id: None,
559 injection_flagged: false,
560 embedding_anomalous: false,
561 };
562 logger.log(&entry).await;
563 }
564 }
565}
566
567impl ToolExecutor for ShellExecutor {
568 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
569 self.execute_inner(response, false).await
570 }
571
572 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
573 use crate::registry::{InvocationHint, ToolDef};
574 vec![ToolDef {
575 id: "bash".into(),
576 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(),
577 schema: schemars::schema_for!(BashParams),
578 invocation: InvocationHint::FencedBlock("bash"),
579 }]
580 }
581
582 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
583 if call.tool_id != "bash" {
584 return Ok(None);
585 }
586 let params: BashParams = crate::executor::deserialize_params(&call.params)?;
587 if params.command.is_empty() {
588 return Ok(None);
589 }
590 let command = ¶ms.command;
591 let synthetic = format!("```bash\n{command}\n```");
593 self.execute_inner(&synthetic, false).await
594 }
595
596 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
597 ShellExecutor::set_skill_env(self, env);
598 }
599}
600
601pub(crate) fn strip_shell_escapes(input: &str) -> String {
605 let mut out = String::with_capacity(input.len());
606 let bytes = input.as_bytes();
607 let mut i = 0;
608 while i < bytes.len() {
609 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'\'' {
611 let mut j = i + 2; let mut decoded = String::new();
613 let mut valid = false;
614 while j < bytes.len() && bytes[j] != b'\'' {
615 if bytes[j] == b'\\' && j + 1 < bytes.len() {
616 let next = bytes[j + 1];
617 if next == b'x' && j + 3 < bytes.len() {
618 let hi = (bytes[j + 2] as char).to_digit(16);
620 let lo = (bytes[j + 3] as char).to_digit(16);
621 if let (Some(h), Some(l)) = (hi, lo) {
622 #[allow(clippy::cast_possible_truncation)]
623 let byte = ((h << 4) | l) as u8;
624 decoded.push(byte as char);
625 j += 4;
626 valid = true;
627 continue;
628 }
629 } else if next.is_ascii_digit() {
630 let mut val = u32::from(next - b'0');
632 let mut len = 2; if j + 2 < bytes.len() && bytes[j + 2].is_ascii_digit() {
634 val = val * 8 + u32::from(bytes[j + 2] - b'0');
635 len = 3;
636 if j + 3 < bytes.len() && bytes[j + 3].is_ascii_digit() {
637 val = val * 8 + u32::from(bytes[j + 3] - b'0');
638 len = 4;
639 }
640 }
641 #[allow(clippy::cast_possible_truncation)]
642 decoded.push((val & 0xFF) as u8 as char);
643 j += len;
644 valid = true;
645 continue;
646 }
647 decoded.push(next as char);
649 j += 2;
650 } else {
651 decoded.push(bytes[j] as char);
652 j += 1;
653 }
654 }
655 if j < bytes.len() && bytes[j] == b'\'' && valid {
656 out.push_str(&decoded);
657 i = j + 1;
658 continue;
659 }
660 }
662 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
664 i += 2;
665 continue;
666 }
667 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] != b'\n' {
669 i += 1;
670 out.push(bytes[i] as char);
671 i += 1;
672 continue;
673 }
674 if bytes[i] == b'"' || bytes[i] == b'\'' {
676 let quote = bytes[i];
677 i += 1;
678 while i < bytes.len() && bytes[i] != quote {
679 out.push(bytes[i] as char);
680 i += 1;
681 }
682 if i < bytes.len() {
683 i += 1; }
685 continue;
686 }
687 out.push(bytes[i] as char);
688 i += 1;
689 }
690 out
691}
692
693pub(crate) fn extract_subshell_contents(s: &str) -> Vec<String> {
703 let mut results = Vec::new();
704 let chars: Vec<char> = s.chars().collect();
705 let len = chars.len();
706 let mut i = 0;
707
708 while i < len {
709 if chars[i] == '`' {
711 let start = i + 1;
712 let mut j = start;
713 while j < len && chars[j] != '`' {
714 j += 1;
715 }
716 if j < len {
717 results.push(chars[start..j].iter().collect());
718 }
719 i = j + 1;
720 continue;
721 }
722
723 let next_is_open_paren = i + 1 < len && chars[i + 1] == '(';
725 let is_paren_subshell = next_is_open_paren && matches!(chars[i], '$' | '<' | '>');
726
727 if is_paren_subshell {
728 let start = i + 2;
729 let mut depth: usize = 1;
730 let mut j = start;
731 while j < len && depth > 0 {
732 match chars[j] {
733 '(' => depth += 1,
734 ')' => depth -= 1,
735 _ => {}
736 }
737 if depth > 0 {
738 j += 1;
739 } else {
740 break;
741 }
742 }
743 if depth == 0 {
744 results.push(chars[start..j].iter().collect());
745 }
746 i = j + 1;
747 continue;
748 }
749
750 i += 1;
751 }
752
753 results
754}
755
756pub(crate) fn tokenize_commands(normalized: &str) -> Vec<Vec<String>> {
759 let replaced = normalized.replace("||", "\n").replace("&&", "\n");
761 replaced
762 .split([';', '|', '\n'])
763 .map(|seg| {
764 seg.split_whitespace()
765 .map(str::to_owned)
766 .collect::<Vec<String>>()
767 })
768 .filter(|tokens| !tokens.is_empty())
769 .collect()
770}
771
772const TRANSPARENT_PREFIXES: &[&str] = &["env", "command", "exec", "nice", "nohup", "time", "xargs"];
775
776fn cmd_basename(tok: &str) -> &str {
778 tok.rsplit('/').next().unwrap_or(tok)
779}
780
781pub(crate) fn tokens_match_pattern(tokens: &[String], pattern: &str) -> bool {
788 if tokens.is_empty() || pattern.is_empty() {
789 return false;
790 }
791 let pattern = pattern.trim();
792 let pattern_tokens: Vec<&str> = pattern.split_whitespace().collect();
793 if pattern_tokens.is_empty() {
794 return false;
795 }
796
797 let start = tokens
799 .iter()
800 .position(|t| !TRANSPARENT_PREFIXES.contains(&cmd_basename(t)))
801 .unwrap_or(0);
802 let effective = &tokens[start..];
803 if effective.is_empty() {
804 return false;
805 }
806
807 if pattern_tokens.len() == 1 {
808 let pat = pattern_tokens[0];
809 let base = cmd_basename(&effective[0]);
810 base == pat || base.starts_with(&format!("{pat}."))
812 } else {
813 let n = pattern_tokens.len().min(effective.len());
815 let mut parts: Vec<&str> = vec![cmd_basename(&effective[0])];
816 parts.extend(effective[1..n].iter().map(String::as_str));
817 let joined = parts.join(" ");
818 if joined.starts_with(pattern) {
819 return true;
820 }
821 if effective.len() > n {
822 let mut parts2: Vec<&str> = vec![cmd_basename(&effective[0])];
823 parts2.extend(effective[1..=n].iter().map(String::as_str));
824 parts2.join(" ").starts_with(pattern)
825 } else {
826 false
827 }
828 }
829}
830
831fn extract_paths(code: &str) -> Vec<String> {
832 let mut result = Vec::new();
833
834 let mut tokens: Vec<String> = Vec::new();
836 let mut current = String::new();
837 let mut chars = code.chars().peekable();
838 while let Some(c) = chars.next() {
839 match c {
840 '"' | '\'' => {
841 let quote = c;
842 while let Some(&nc) = chars.peek() {
843 if nc == quote {
844 chars.next();
845 break;
846 }
847 current.push(chars.next().unwrap());
848 }
849 }
850 c if c.is_whitespace() || matches!(c, ';' | '|' | '&') => {
851 if !current.is_empty() {
852 tokens.push(std::mem::take(&mut current));
853 }
854 }
855 _ => current.push(c),
856 }
857 }
858 if !current.is_empty() {
859 tokens.push(current);
860 }
861
862 for token in tokens {
863 let trimmed = token.trim_end_matches([';', '&', '|']).to_owned();
864 if trimmed.is_empty() {
865 continue;
866 }
867 if trimmed.starts_with('/')
868 || trimmed.starts_with("./")
869 || trimmed.starts_with("../")
870 || trimmed == ".."
871 {
872 result.push(trimmed);
873 }
874 }
875 result
876}
877
878fn classify_shell_exit(
884 exit_code: i32,
885 output: &str,
886) -> Option<crate::error_taxonomy::ToolErrorCategory> {
887 use crate::error_taxonomy::ToolErrorCategory;
888 match exit_code {
889 126 => Some(ToolErrorCategory::PolicyBlocked),
891 127 => Some(ToolErrorCategory::PermanentFailure),
893 _ => {
894 let lower = output.to_lowercase();
895 if lower.contains("permission denied") {
896 Some(ToolErrorCategory::PolicyBlocked)
897 } else if lower.contains("no such file or directory") {
898 Some(ToolErrorCategory::PermanentFailure)
899 } else {
900 None
901 }
902 }
903 }
904}
905
906fn has_traversal(path: &str) -> bool {
907 path.split('/').any(|seg| seg == "..")
908}
909
910fn extract_bash_blocks(text: &str) -> Vec<&str> {
911 crate::executor::extract_fenced_blocks(text, "bash")
912}
913
914async fn kill_process_tree(child: &mut tokio::process::Child) {
918 #[cfg(unix)]
919 if let Some(pid) = child.id() {
920 let _ = Command::new("pkill")
921 .args(["-KILL", "-P", &pid.to_string()])
922 .status()
923 .await;
924 }
925 let _ = child.kill().await;
926}
927
928async fn execute_bash(
929 code: &str,
930 timeout: Duration,
931 event_tx: Option<&ToolEventTx>,
932 cancel_token: Option<&CancellationToken>,
933 extra_env: Option<&std::collections::HashMap<String, String>>,
934) -> (String, i32) {
935 use std::process::Stdio;
936 use tokio::io::{AsyncBufReadExt, BufReader};
937
938 let timeout_secs = timeout.as_secs();
939
940 let mut cmd = Command::new("bash");
941 cmd.arg("-c")
942 .arg(code)
943 .stdout(Stdio::piped())
944 .stderr(Stdio::piped());
945 if let Some(env) = extra_env {
946 cmd.envs(env);
947 }
948 let child_result = cmd.spawn();
949
950 let mut child = match child_result {
951 Ok(c) => c,
952 Err(e) => return (format!("[error] {e}"), 1),
953 };
954
955 let stdout = child.stdout.take().expect("stdout piped");
956 let stderr = child.stderr.take().expect("stderr piped");
957
958 let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::<String>(64);
959
960 let stdout_tx = line_tx.clone();
961 tokio::spawn(async move {
962 let mut reader = BufReader::new(stdout);
963 let mut buf = String::new();
964 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
965 let _ = stdout_tx.send(buf.clone()).await;
966 buf.clear();
967 }
968 });
969
970 tokio::spawn(async move {
971 let mut reader = BufReader::new(stderr);
972 let mut buf = String::new();
973 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
974 let _ = line_tx.send(format!("[stderr] {buf}")).await;
975 buf.clear();
976 }
977 });
978
979 let mut combined = String::new();
980 let deadline = tokio::time::Instant::now() + timeout;
981
982 loop {
983 tokio::select! {
984 line = line_rx.recv() => {
985 match line {
986 Some(chunk) => {
987 if let Some(tx) = event_tx {
988 let _ = tx.send(ToolEvent::OutputChunk {
989 tool_name: "bash".to_owned(),
990 command: code.to_owned(),
991 chunk: chunk.clone(),
992 });
993 }
994 combined.push_str(&chunk);
995 }
996 None => break,
997 }
998 }
999 () = tokio::time::sleep_until(deadline) => {
1000 kill_process_tree(&mut child).await;
1001 return (format!("[error] command timed out after {timeout_secs}s"), 1);
1002 }
1003 () = async {
1004 match cancel_token {
1005 Some(t) => t.cancelled().await,
1006 None => std::future::pending().await,
1007 }
1008 } => {
1009 kill_process_tree(&mut child).await;
1010 return ("[cancelled] operation aborted".to_string(), 130);
1011 }
1012 }
1013 }
1014
1015 let status = child.wait().await;
1016 let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(1);
1017
1018 if combined.is_empty() {
1019 ("(no output)".to_string(), exit_code)
1020 } else {
1021 (combined, exit_code)
1022 }
1023}
1024
1025#[cfg(test)]
1026mod tests;