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 self.emit_completed(block, &out, false, None);
307 return Err(ToolError::Timeout {
308 timeout_secs: self.timeout.as_secs(),
309 });
310 }
311
312 if let Some(category) = classify_shell_exit(exit_code, &out) {
313 self.emit_completed(block, &out, false, None);
314 return Err(ToolError::Shell {
315 exit_code,
316 category,
317 message: out.lines().take(3).collect::<Vec<_>>().join("; "),
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 self.emit_completed(
351 block,
352 &out,
353 !out.contains("[error]"),
354 per_block_stats.clone(),
355 );
356
357 Ok((format!("$ {block}\n{filtered}"), per_block_stats))
358 }
359
360 fn emit_completed(
361 &self,
362 command: &str,
363 output: &str,
364 success: bool,
365 filter_stats: Option<FilterStats>,
366 ) {
367 if let Some(ref tx) = self.tool_event_tx {
368 let _ = tx.send(ToolEvent::Completed {
369 tool_name: "bash".to_owned(),
370 command: command.to_owned(),
371 output: output.to_owned(),
372 success,
373 filter_stats,
374 diff: None,
375 });
376 }
377 }
378
379 async fn check_permissions(&self, block: &str, skip_confirm: bool) -> Result<(), ToolError> {
381 if let Some(blocked) = self.find_blocked_command(block) {
384 self.log_audit(
385 block,
386 AuditResult::Blocked {
387 reason: format!("blocked command: {blocked}"),
388 },
389 0,
390 )
391 .await;
392 return Err(ToolError::Blocked {
393 command: blocked.to_owned(),
394 });
395 }
396
397 if let Some(ref policy) = self.permission_policy {
398 match policy.check("bash", block) {
399 PermissionAction::Deny => {
400 self.log_audit(
401 block,
402 AuditResult::Blocked {
403 reason: "denied by permission policy".to_owned(),
404 },
405 0,
406 )
407 .await;
408 return Err(ToolError::Blocked {
409 command: block.to_owned(),
410 });
411 }
412 PermissionAction::Ask if !skip_confirm => {
413 return Err(ToolError::ConfirmationRequired {
414 command: block.to_owned(),
415 });
416 }
417 _ => {}
418 }
419 } else if !skip_confirm && let Some(pattern) = self.find_confirm_command(block) {
420 return Err(ToolError::ConfirmationRequired {
421 command: pattern.to_owned(),
422 });
423 }
424
425 Ok(())
426 }
427
428 fn validate_sandbox(&self, code: &str) -> Result<(), ToolError> {
429 let cwd = std::env::current_dir().unwrap_or_default();
430
431 for token in extract_paths(code) {
432 if has_traversal(&token) {
433 return Err(ToolError::SandboxViolation { path: token });
434 }
435
436 let path = if token.starts_with('/') {
437 PathBuf::from(&token)
438 } else {
439 cwd.join(&token)
440 };
441 let canonical = path
442 .canonicalize()
443 .or_else(|_| std::path::absolute(&path))
444 .unwrap_or(path);
445 if !self
446 .allowed_paths
447 .iter()
448 .any(|allowed| canonical.starts_with(allowed))
449 {
450 return Err(ToolError::SandboxViolation {
451 path: canonical.display().to_string(),
452 });
453 }
454 }
455 Ok(())
456 }
457
458 fn find_blocked_command(&self, code: &str) -> Option<&str> {
493 let cleaned = strip_shell_escapes(&code.to_lowercase());
494 let commands = tokenize_commands(&cleaned);
495 for blocked in &self.blocked_commands {
496 for cmd_tokens in &commands {
497 if tokens_match_pattern(cmd_tokens, blocked) {
498 return Some(blocked.as_str());
499 }
500 }
501 }
502 for inner in extract_subshell_contents(&cleaned) {
504 let inner_commands = tokenize_commands(&inner);
505 for blocked in &self.blocked_commands {
506 for cmd_tokens in &inner_commands {
507 if tokens_match_pattern(cmd_tokens, blocked) {
508 return Some(blocked.as_str());
509 }
510 }
511 }
512 }
513 None
514 }
515
516 fn find_confirm_command(&self, code: &str) -> Option<&str> {
517 let normalized = code.to_lowercase();
518 for pattern in &self.confirm_patterns {
519 if normalized.contains(pattern.as_str()) {
520 return Some(pattern.as_str());
521 }
522 }
523 None
524 }
525
526 async fn log_audit(&self, command: &str, result: AuditResult, duration_ms: u64) {
527 if let Some(ref logger) = self.audit_logger {
528 let entry = AuditEntry {
529 timestamp: chrono_now(),
530 tool: "shell".into(),
531 command: command.into(),
532 result,
533 duration_ms,
534 error_category: None,
535 };
536 logger.log(&entry).await;
537 }
538 }
539}
540
541impl ToolExecutor for ShellExecutor {
542 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
543 self.execute_inner(response, false).await
544 }
545
546 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
547 use crate::registry::{InvocationHint, ToolDef};
548 vec![ToolDef {
549 id: "bash".into(),
550 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(),
551 schema: schemars::schema_for!(BashParams),
552 invocation: InvocationHint::FencedBlock("bash"),
553 }]
554 }
555
556 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
557 if call.tool_id != "bash" {
558 return Ok(None);
559 }
560 let params: BashParams = crate::executor::deserialize_params(&call.params)?;
561 if params.command.is_empty() {
562 return Ok(None);
563 }
564 let command = ¶ms.command;
565 let synthetic = format!("```bash\n{command}\n```");
567 self.execute_inner(&synthetic, false).await
568 }
569
570 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
571 ShellExecutor::set_skill_env(self, env);
572 }
573}
574
575pub(crate) fn strip_shell_escapes(input: &str) -> String {
579 let mut out = String::with_capacity(input.len());
580 let bytes = input.as_bytes();
581 let mut i = 0;
582 while i < bytes.len() {
583 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'\'' {
585 let mut j = i + 2; let mut decoded = String::new();
587 let mut valid = false;
588 while j < bytes.len() && bytes[j] != b'\'' {
589 if bytes[j] == b'\\' && j + 1 < bytes.len() {
590 let next = bytes[j + 1];
591 if next == b'x' && j + 3 < bytes.len() {
592 let hi = (bytes[j + 2] as char).to_digit(16);
594 let lo = (bytes[j + 3] as char).to_digit(16);
595 if let (Some(h), Some(l)) = (hi, lo) {
596 #[allow(clippy::cast_possible_truncation)]
597 let byte = ((h << 4) | l) as u8;
598 decoded.push(byte as char);
599 j += 4;
600 valid = true;
601 continue;
602 }
603 } else if next.is_ascii_digit() {
604 let mut val = u32::from(next - b'0');
606 let mut len = 2; if j + 2 < bytes.len() && bytes[j + 2].is_ascii_digit() {
608 val = val * 8 + u32::from(bytes[j + 2] - b'0');
609 len = 3;
610 if j + 3 < bytes.len() && bytes[j + 3].is_ascii_digit() {
611 val = val * 8 + u32::from(bytes[j + 3] - b'0');
612 len = 4;
613 }
614 }
615 #[allow(clippy::cast_possible_truncation)]
616 decoded.push((val & 0xFF) as u8 as char);
617 j += len;
618 valid = true;
619 continue;
620 }
621 decoded.push(next as char);
623 j += 2;
624 } else {
625 decoded.push(bytes[j] as char);
626 j += 1;
627 }
628 }
629 if j < bytes.len() && bytes[j] == b'\'' && valid {
630 out.push_str(&decoded);
631 i = j + 1;
632 continue;
633 }
634 }
636 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
638 i += 2;
639 continue;
640 }
641 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] != b'\n' {
643 i += 1;
644 out.push(bytes[i] as char);
645 i += 1;
646 continue;
647 }
648 if bytes[i] == b'"' || bytes[i] == b'\'' {
650 let quote = bytes[i];
651 i += 1;
652 while i < bytes.len() && bytes[i] != quote {
653 out.push(bytes[i] as char);
654 i += 1;
655 }
656 if i < bytes.len() {
657 i += 1; }
659 continue;
660 }
661 out.push(bytes[i] as char);
662 i += 1;
663 }
664 out
665}
666
667pub(crate) fn extract_subshell_contents(s: &str) -> Vec<String> {
677 let mut results = Vec::new();
678 let chars: Vec<char> = s.chars().collect();
679 let len = chars.len();
680 let mut i = 0;
681
682 while i < len {
683 if chars[i] == '`' {
685 let start = i + 1;
686 let mut j = start;
687 while j < len && chars[j] != '`' {
688 j += 1;
689 }
690 if j < len {
691 results.push(chars[start..j].iter().collect());
692 }
693 i = j + 1;
694 continue;
695 }
696
697 let next_is_open_paren = i + 1 < len && chars[i + 1] == '(';
699 let is_paren_subshell = next_is_open_paren && matches!(chars[i], '$' | '<' | '>');
700
701 if is_paren_subshell {
702 let start = i + 2;
703 let mut depth: usize = 1;
704 let mut j = start;
705 while j < len && depth > 0 {
706 match chars[j] {
707 '(' => depth += 1,
708 ')' => depth -= 1,
709 _ => {}
710 }
711 if depth > 0 {
712 j += 1;
713 } else {
714 break;
715 }
716 }
717 if depth == 0 {
718 results.push(chars[start..j].iter().collect());
719 }
720 i = j + 1;
721 continue;
722 }
723
724 i += 1;
725 }
726
727 results
728}
729
730pub(crate) fn tokenize_commands(normalized: &str) -> Vec<Vec<String>> {
733 let replaced = normalized.replace("||", "\n").replace("&&", "\n");
735 replaced
736 .split([';', '|', '\n'])
737 .map(|seg| {
738 seg.split_whitespace()
739 .map(str::to_owned)
740 .collect::<Vec<String>>()
741 })
742 .filter(|tokens| !tokens.is_empty())
743 .collect()
744}
745
746const TRANSPARENT_PREFIXES: &[&str] = &["env", "command", "exec", "nice", "nohup", "time", "xargs"];
749
750fn cmd_basename(tok: &str) -> &str {
752 tok.rsplit('/').next().unwrap_or(tok)
753}
754
755pub(crate) fn tokens_match_pattern(tokens: &[String], pattern: &str) -> bool {
762 if tokens.is_empty() || pattern.is_empty() {
763 return false;
764 }
765 let pattern = pattern.trim();
766 let pattern_tokens: Vec<&str> = pattern.split_whitespace().collect();
767 if pattern_tokens.is_empty() {
768 return false;
769 }
770
771 let start = tokens
773 .iter()
774 .position(|t| !TRANSPARENT_PREFIXES.contains(&cmd_basename(t)))
775 .unwrap_or(0);
776 let effective = &tokens[start..];
777 if effective.is_empty() {
778 return false;
779 }
780
781 if pattern_tokens.len() == 1 {
782 let pat = pattern_tokens[0];
783 let base = cmd_basename(&effective[0]);
784 base == pat || base.starts_with(&format!("{pat}."))
786 } else {
787 let n = pattern_tokens.len().min(effective.len());
789 let mut parts: Vec<&str> = vec![cmd_basename(&effective[0])];
790 parts.extend(effective[1..n].iter().map(String::as_str));
791 let joined = parts.join(" ");
792 if joined.starts_with(pattern) {
793 return true;
794 }
795 if effective.len() > n {
796 let mut parts2: Vec<&str> = vec![cmd_basename(&effective[0])];
797 parts2.extend(effective[1..=n].iter().map(String::as_str));
798 parts2.join(" ").starts_with(pattern)
799 } else {
800 false
801 }
802 }
803}
804
805fn extract_paths(code: &str) -> Vec<String> {
806 let mut result = Vec::new();
807
808 let mut tokens: Vec<String> = Vec::new();
810 let mut current = String::new();
811 let mut chars = code.chars().peekable();
812 while let Some(c) = chars.next() {
813 match c {
814 '"' | '\'' => {
815 let quote = c;
816 while let Some(&nc) = chars.peek() {
817 if nc == quote {
818 chars.next();
819 break;
820 }
821 current.push(chars.next().unwrap());
822 }
823 }
824 c if c.is_whitespace() || matches!(c, ';' | '|' | '&') => {
825 if !current.is_empty() {
826 tokens.push(std::mem::take(&mut current));
827 }
828 }
829 _ => current.push(c),
830 }
831 }
832 if !current.is_empty() {
833 tokens.push(current);
834 }
835
836 for token in tokens {
837 let trimmed = token.trim_end_matches([';', '&', '|']).to_owned();
838 if trimmed.is_empty() {
839 continue;
840 }
841 if trimmed.starts_with('/')
842 || trimmed.starts_with("./")
843 || trimmed.starts_with("../")
844 || trimmed == ".."
845 {
846 result.push(trimmed);
847 }
848 }
849 result
850}
851
852fn classify_shell_exit(
858 exit_code: i32,
859 output: &str,
860) -> Option<crate::error_taxonomy::ToolErrorCategory> {
861 use crate::error_taxonomy::ToolErrorCategory;
862 match exit_code {
863 126 => Some(ToolErrorCategory::PolicyBlocked),
865 127 => Some(ToolErrorCategory::PermanentFailure),
867 _ => {
868 let lower = output.to_lowercase();
869 if lower.contains("permission denied") {
870 Some(ToolErrorCategory::PolicyBlocked)
871 } else if lower.contains("no such file or directory") {
872 Some(ToolErrorCategory::PermanentFailure)
873 } else {
874 None
875 }
876 }
877 }
878}
879
880fn has_traversal(path: &str) -> bool {
881 path.split('/').any(|seg| seg == "..")
882}
883
884fn extract_bash_blocks(text: &str) -> Vec<&str> {
885 crate::executor::extract_fenced_blocks(text, "bash")
886}
887
888async fn kill_process_tree(child: &mut tokio::process::Child) {
892 #[cfg(unix)]
893 if let Some(pid) = child.id() {
894 let _ = Command::new("pkill")
895 .args(["-KILL", "-P", &pid.to_string()])
896 .status()
897 .await;
898 }
899 let _ = child.kill().await;
900}
901
902async fn execute_bash(
903 code: &str,
904 timeout: Duration,
905 event_tx: Option<&ToolEventTx>,
906 cancel_token: Option<&CancellationToken>,
907 extra_env: Option<&std::collections::HashMap<String, String>>,
908) -> (String, i32) {
909 use std::process::Stdio;
910 use tokio::io::{AsyncBufReadExt, BufReader};
911
912 let timeout_secs = timeout.as_secs();
913
914 let mut cmd = Command::new("bash");
915 cmd.arg("-c")
916 .arg(code)
917 .stdout(Stdio::piped())
918 .stderr(Stdio::piped());
919 if let Some(env) = extra_env {
920 cmd.envs(env);
921 }
922 let child_result = cmd.spawn();
923
924 let mut child = match child_result {
925 Ok(c) => c,
926 Err(e) => return (format!("[error] {e}"), 1),
927 };
928
929 let stdout = child.stdout.take().expect("stdout piped");
930 let stderr = child.stderr.take().expect("stderr piped");
931
932 let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::<String>(64);
933
934 let stdout_tx = line_tx.clone();
935 tokio::spawn(async move {
936 let mut reader = BufReader::new(stdout);
937 let mut buf = String::new();
938 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
939 let _ = stdout_tx.send(buf.clone()).await;
940 buf.clear();
941 }
942 });
943
944 tokio::spawn(async move {
945 let mut reader = BufReader::new(stderr);
946 let mut buf = String::new();
947 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
948 let _ = line_tx.send(format!("[stderr] {buf}")).await;
949 buf.clear();
950 }
951 });
952
953 let mut combined = String::new();
954 let deadline = tokio::time::Instant::now() + timeout;
955
956 loop {
957 tokio::select! {
958 line = line_rx.recv() => {
959 match line {
960 Some(chunk) => {
961 if let Some(tx) = event_tx {
962 let _ = tx.send(ToolEvent::OutputChunk {
963 tool_name: "bash".to_owned(),
964 command: code.to_owned(),
965 chunk: chunk.clone(),
966 });
967 }
968 combined.push_str(&chunk);
969 }
970 None => break,
971 }
972 }
973 () = tokio::time::sleep_until(deadline) => {
974 kill_process_tree(&mut child).await;
975 return (format!("[error] command timed out after {timeout_secs}s"), 1);
976 }
977 () = async {
978 match cancel_token {
979 Some(t) => t.cancelled().await,
980 None => std::future::pending().await,
981 }
982 } => {
983 kill_process_tree(&mut child).await;
984 return ("[cancelled] operation aborted".to_string(), 130);
985 }
986 }
987 }
988
989 let status = child.wait().await;
990 let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(1);
991
992 if combined.is_empty() {
993 ("(no output)".to_string(), exit_code)
994 } else {
995 (combined, exit_code)
996 }
997}
998
999#[cfg(test)]
1000mod tests;