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
23mod transaction;
24use transaction::{TransactionSnapshot, affected_paths, build_scope_matchers, is_write_command};
25
26const DEFAULT_BLOCKED: &[&str] = &[
27 "rm -rf /", "sudo", "mkfs", "dd if=", "curl", "wget", "nc ", "ncat", "netcat", "shutdown",
28 "reboot", "halt",
29];
30
31pub const DEFAULT_BLOCKED_COMMANDS: &[&str] = DEFAULT_BLOCKED;
36
37pub const SHELL_INTERPRETERS: &[&str] =
39 &["bash", "sh", "zsh", "fish", "dash", "ksh", "csh", "tcsh"];
40
41const SUBSHELL_METACHARS: &[&str] = &["$(", "`", "<(", ">("];
45
46#[must_use]
54pub fn check_blocklist(command: &str, blocklist: &[String]) -> Option<String> {
55 let lower = command.to_lowercase();
56 for meta in SUBSHELL_METACHARS {
58 if lower.contains(meta) {
59 return Some((*meta).to_owned());
60 }
61 }
62 let cleaned = strip_shell_escapes(&lower);
63 let commands = tokenize_commands(&cleaned);
64 for blocked in blocklist {
65 for cmd_tokens in &commands {
66 if tokens_match_pattern(cmd_tokens, blocked) {
67 return Some(blocked.clone());
68 }
69 }
70 }
71 None
72}
73
74#[must_use]
79pub fn effective_shell_command<'a>(binary: &str, args: &'a [String]) -> Option<&'a str> {
80 let base = binary.rsplit('/').next().unwrap_or(binary);
81 if !SHELL_INTERPRETERS.contains(&base) {
82 return None;
83 }
84 let pos = args.iter().position(|a| a == "-c")?;
86 args.get(pos + 1).map(String::as_str)
87}
88
89const NETWORK_COMMANDS: &[&str] = &["curl", "wget", "nc ", "ncat", "netcat"];
90
91#[derive(Deserialize, JsonSchema)]
92pub(crate) struct BashParams {
93 command: String,
95}
96
97#[derive(Debug)]
99pub struct ShellExecutor {
100 timeout: Duration,
101 blocked_commands: Vec<String>,
102 allowed_paths: Vec<PathBuf>,
103 confirm_patterns: Vec<String>,
104 env_blocklist: Vec<String>,
105 audit_logger: Option<Arc<AuditLogger>>,
106 tool_event_tx: Option<ToolEventTx>,
107 permission_policy: Option<PermissionPolicy>,
108 output_filter_registry: Option<OutputFilterRegistry>,
109 cancel_token: Option<CancellationToken>,
110 skill_env: std::sync::RwLock<Option<std::collections::HashMap<String, String>>>,
111 transactional: bool,
112 auto_rollback: bool,
113 auto_rollback_exit_codes: Vec<i32>,
114 snapshot_required: bool,
115 max_snapshot_bytes: u64,
116 transaction_scope_matchers: Vec<globset::GlobMatcher>,
117}
118
119impl ShellExecutor {
120 #[must_use]
121 pub fn new(config: &ShellConfig) -> Self {
122 let allowed: Vec<String> = config
123 .allowed_commands
124 .iter()
125 .map(|s| s.to_lowercase())
126 .collect();
127
128 let mut blocked: Vec<String> = DEFAULT_BLOCKED
129 .iter()
130 .filter(|s| !allowed.contains(&s.to_lowercase()))
131 .map(|s| (*s).to_owned())
132 .collect();
133 blocked.extend(config.blocked_commands.iter().map(|s| s.to_lowercase()));
134
135 if !config.allow_network {
136 for cmd in NETWORK_COMMANDS {
137 let lower = cmd.to_lowercase();
138 if !blocked.contains(&lower) {
139 blocked.push(lower);
140 }
141 }
142 }
143
144 blocked.sort();
145 blocked.dedup();
146
147 let allowed_paths = if config.allowed_paths.is_empty() {
148 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
149 } else {
150 config.allowed_paths.iter().map(PathBuf::from).collect()
151 };
152
153 Self {
154 timeout: Duration::from_secs(config.timeout),
155 blocked_commands: blocked,
156 allowed_paths,
157 confirm_patterns: config.confirm_patterns.clone(),
158 env_blocklist: config.env_blocklist.clone(),
159 audit_logger: None,
160 tool_event_tx: None,
161 permission_policy: None,
162 output_filter_registry: None,
163 cancel_token: None,
164 skill_env: std::sync::RwLock::new(None),
165 transactional: config.transactional,
166 auto_rollback: config.auto_rollback,
167 auto_rollback_exit_codes: config.auto_rollback_exit_codes.clone(),
168 snapshot_required: config.snapshot_required,
169 max_snapshot_bytes: config.max_snapshot_bytes,
170 transaction_scope_matchers: build_scope_matchers(&config.transaction_scope),
171 }
172 }
173
174 pub fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
176 match self.skill_env.write() {
177 Ok(mut guard) => *guard = env,
178 Err(e) => tracing::error!("skill_env RwLock poisoned: {e}"),
179 }
180 }
181
182 #[must_use]
183 pub fn with_audit(mut self, logger: Arc<AuditLogger>) -> Self {
184 self.audit_logger = Some(logger);
185 self
186 }
187
188 #[must_use]
189 pub fn with_tool_event_tx(mut self, tx: ToolEventTx) -> Self {
190 self.tool_event_tx = Some(tx);
191 self
192 }
193
194 #[must_use]
195 pub fn with_permissions(mut self, policy: PermissionPolicy) -> Self {
196 self.permission_policy = Some(policy);
197 self
198 }
199
200 #[must_use]
201 pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
202 self.cancel_token = Some(token);
203 self
204 }
205
206 #[must_use]
207 pub fn with_output_filters(mut self, registry: OutputFilterRegistry) -> Self {
208 self.output_filter_registry = Some(registry);
209 self
210 }
211
212 pub async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
218 self.execute_inner(response, true).await
219 }
220
221 async fn execute_inner(
222 &self,
223 response: &str,
224 skip_confirm: bool,
225 ) -> Result<Option<ToolOutput>, ToolError> {
226 let blocks = extract_bash_blocks(response);
227 if blocks.is_empty() {
228 return Ok(None);
229 }
230
231 let mut outputs = Vec::with_capacity(blocks.len());
232 let mut cumulative_filter_stats: Option<FilterStats> = None;
233 let mut last_envelope: Option<ShellOutputEnvelope> = None;
234 #[allow(clippy::cast_possible_truncation)]
235 let blocks_executed = blocks.len() as u32;
236
237 for block in &blocks {
238 let (output_line, per_block_stats, envelope) =
239 self.execute_block(block, skip_confirm).await?;
240 if let Some(fs) = per_block_stats {
241 let stats = cumulative_filter_stats.get_or_insert_with(FilterStats::default);
242 stats.raw_chars += fs.raw_chars;
243 stats.filtered_chars += fs.filtered_chars;
244 stats.raw_lines += fs.raw_lines;
245 stats.filtered_lines += fs.filtered_lines;
246 stats.confidence = Some(match (stats.confidence, fs.confidence) {
247 (Some(prev), Some(cur)) => crate::filter::worse_confidence(prev, cur),
248 (Some(prev), None) => prev,
249 (None, Some(cur)) => cur,
250 (None, None) => unreachable!(),
251 });
252 if stats.command.is_none() {
253 stats.command = fs.command;
254 }
255 if stats.kept_lines.is_empty() && !fs.kept_lines.is_empty() {
256 stats.kept_lines = fs.kept_lines;
257 }
258 }
259 last_envelope = Some(envelope);
260 outputs.push(output_line);
261 }
262
263 let raw_response = last_envelope
264 .as_ref()
265 .and_then(|e| serde_json::to_value(e).ok());
266
267 Ok(Some(ToolOutput {
268 tool_name: "bash".to_owned(),
269 summary: outputs.join("\n\n"),
270 blocks_executed,
271 filter_stats: cumulative_filter_stats,
272 diff: None,
273 streamed: self.tool_event_tx.is_some(),
274 terminal_id: None,
275 locations: None,
276 raw_response,
277 claim_source: Some(ClaimSource::Shell),
278 }))
279 }
280
281 #[allow(clippy::too_many_lines)]
282 async fn execute_block(
283 &self,
284 block: &str,
285 skip_confirm: bool,
286 ) -> Result<(String, Option<FilterStats>, ShellOutputEnvelope), ToolError> {
287 self.check_permissions(block, skip_confirm).await?;
288 self.validate_sandbox(block)?;
289
290 let mut snapshot_warning: Option<String> = None;
292 let snapshot = if self.transactional && is_write_command(block) {
293 let paths = affected_paths(block, &self.transaction_scope_matchers);
294 if paths.is_empty() {
295 None
296 } else {
297 match TransactionSnapshot::capture(&paths, self.max_snapshot_bytes) {
298 Ok(snap) => {
299 tracing::debug!(
300 files = snap.file_count(),
301 bytes = snap.total_bytes(),
302 "transaction snapshot captured"
303 );
304 Some(snap)
305 }
306 Err(e) if self.snapshot_required => {
307 return Err(ToolError::SnapshotFailed {
308 reason: e.to_string(),
309 });
310 }
311 Err(e) => {
312 tracing::warn!(err = %e, "transaction snapshot failed, proceeding without rollback");
313 snapshot_warning =
314 Some(format!("[warn] snapshot failed: {e}; rollback unavailable"));
315 None
316 }
317 }
318 }
319 } else {
320 None
321 };
322
323 if let Some(ref tx) = self.tool_event_tx {
324 let _ = tx.send(ToolEvent::Started {
325 tool_name: "bash".to_owned(),
326 command: block.to_owned(),
327 });
328 }
329
330 let start = Instant::now();
331 let skill_env_snapshot: Option<std::collections::HashMap<String, String>> =
332 self.skill_env.read().ok().and_then(|g| g.clone());
333 let (mut envelope, out) = execute_bash(
334 block,
335 self.timeout,
336 self.tool_event_tx.as_ref(),
337 self.cancel_token.as_ref(),
338 skill_env_snapshot.as_ref(),
339 &self.env_blocklist,
340 )
341 .await;
342 let exit_code = envelope.exit_code;
343 if exit_code == 130
344 && self
345 .cancel_token
346 .as_ref()
347 .is_some_and(CancellationToken::is_cancelled)
348 {
349 return Err(ToolError::Cancelled);
350 }
351 #[allow(clippy::cast_possible_truncation)]
352 let duration_ms = start.elapsed().as_millis() as u64;
353
354 if let Some(snap) = snapshot {
356 let should_rollback = self.auto_rollback
357 && if self.auto_rollback_exit_codes.is_empty() {
358 exit_code >= 2
359 } else {
360 self.auto_rollback_exit_codes.contains(&exit_code)
361 };
362 if should_rollback {
363 match snap.rollback() {
364 Ok(report) => {
365 tracing::info!(
366 restored = report.restored_count,
367 deleted = report.deleted_count,
368 "transaction rollback completed"
369 );
370 self.log_audit(
371 block,
372 AuditResult::Rollback {
373 restored: report.restored_count,
374 deleted: report.deleted_count,
375 },
376 duration_ms,
377 None,
378 Some(exit_code),
379 false,
380 )
381 .await;
382 if let Some(ref tx) = self.tool_event_tx {
383 let _ = tx.send(ToolEvent::Rollback {
384 tool_name: "bash".to_owned(),
385 command: block.to_owned(),
386 restored_count: report.restored_count,
387 deleted_count: report.deleted_count,
388 });
389 }
390 }
391 Err(e) => {
392 tracing::error!(err = %e, "transaction rollback failed");
393 }
394 }
395 }
396 }
398
399 let is_timeout = out.contains("[error] command timed out");
400 let audit_result = if is_timeout {
401 AuditResult::Timeout
402 } else if out.contains("[error]") || out.contains("[stderr]") {
403 AuditResult::Error {
404 message: out.clone(),
405 }
406 } else {
407 AuditResult::Success
408 };
409 if is_timeout {
410 self.log_audit(
411 block,
412 audit_result,
413 duration_ms,
414 None,
415 Some(exit_code),
416 false,
417 )
418 .await;
419 self.emit_completed(block, &out, false, None);
420 return Err(ToolError::Timeout {
421 timeout_secs: self.timeout.as_secs(),
422 });
423 }
424
425 if let Some(category) = classify_shell_exit(exit_code, &out) {
426 self.emit_completed(block, &out, false, None);
427 return Err(ToolError::Shell {
428 exit_code,
429 category,
430 message: out.lines().take(3).collect::<Vec<_>>().join("; "),
431 });
432 }
433
434 let sanitized = sanitize_output(&out);
435 let mut per_block_stats: Option<FilterStats> = None;
436 let filtered = if let Some(ref registry) = self.output_filter_registry {
437 match registry.apply(block, &sanitized, exit_code) {
438 Some(fr) => {
439 tracing::debug!(
440 command = block,
441 raw = fr.raw_chars,
442 filtered = fr.filtered_chars,
443 savings_pct = fr.savings_pct(),
444 "output filter applied"
445 );
446 per_block_stats = Some(FilterStats {
447 raw_chars: fr.raw_chars,
448 filtered_chars: fr.filtered_chars,
449 raw_lines: fr.raw_lines,
450 filtered_lines: fr.filtered_lines,
451 confidence: Some(fr.confidence),
452 command: Some(block.to_owned()),
453 kept_lines: fr.kept_lines.clone(),
454 });
455 fr.output
456 }
457 None => sanitized,
458 }
459 } else {
460 sanitized
461 };
462
463 self.emit_completed(
464 block,
465 &out,
466 !out.contains("[error]"),
467 per_block_stats.clone(),
468 );
469
470 envelope.truncated = filtered.len() < out.len();
472
473 self.log_audit(
474 block,
475 audit_result,
476 duration_ms,
477 None,
478 Some(exit_code),
479 envelope.truncated,
480 )
481 .await;
482
483 let output_line = if let Some(warn) = snapshot_warning {
484 format!("{warn}\n$ {block}\n{filtered}")
485 } else {
486 format!("$ {block}\n{filtered}")
487 };
488 Ok((output_line, per_block_stats, envelope))
489 }
490
491 fn emit_completed(
492 &self,
493 command: &str,
494 output: &str,
495 success: bool,
496 filter_stats: Option<FilterStats>,
497 ) {
498 if let Some(ref tx) = self.tool_event_tx {
499 let _ = tx.send(ToolEvent::Completed {
500 tool_name: "bash".to_owned(),
501 command: command.to_owned(),
502 output: output.to_owned(),
503 success,
504 filter_stats,
505 diff: None,
506 });
507 }
508 }
509
510 async fn check_permissions(&self, block: &str, skip_confirm: bool) -> Result<(), ToolError> {
512 if let Some(blocked) = self.find_blocked_command(block) {
515 let err = ToolError::Blocked {
516 command: blocked.to_owned(),
517 };
518 self.log_audit(
519 block,
520 AuditResult::Blocked {
521 reason: format!("blocked command: {blocked}"),
522 },
523 0,
524 Some(&err),
525 None,
526 false,
527 )
528 .await;
529 return Err(err);
530 }
531
532 if let Some(ref policy) = self.permission_policy {
533 match policy.check("bash", block) {
534 PermissionAction::Deny => {
535 let err = ToolError::Blocked {
536 command: block.to_owned(),
537 };
538 self.log_audit(
539 block,
540 AuditResult::Blocked {
541 reason: "denied by permission policy".to_owned(),
542 },
543 0,
544 Some(&err),
545 None,
546 false,
547 )
548 .await;
549 return Err(err);
550 }
551 PermissionAction::Ask if !skip_confirm => {
552 return Err(ToolError::ConfirmationRequired {
553 command: block.to_owned(),
554 });
555 }
556 _ => {}
557 }
558 } else if !skip_confirm && let Some(pattern) = self.find_confirm_command(block) {
559 return Err(ToolError::ConfirmationRequired {
560 command: pattern.to_owned(),
561 });
562 }
563
564 Ok(())
565 }
566
567 fn validate_sandbox(&self, code: &str) -> Result<(), ToolError> {
568 let cwd = std::env::current_dir().unwrap_or_default();
569
570 for token in extract_paths(code) {
571 if has_traversal(&token) {
572 return Err(ToolError::SandboxViolation { path: token });
573 }
574
575 let path = if token.starts_with('/') {
576 PathBuf::from(&token)
577 } else {
578 cwd.join(&token)
579 };
580 let canonical = path
581 .canonicalize()
582 .or_else(|_| std::path::absolute(&path))
583 .unwrap_or(path);
584 if !self
585 .allowed_paths
586 .iter()
587 .any(|allowed| canonical.starts_with(allowed))
588 {
589 return Err(ToolError::SandboxViolation {
590 path: canonical.display().to_string(),
591 });
592 }
593 }
594 Ok(())
595 }
596
597 fn find_blocked_command(&self, code: &str) -> Option<&str> {
632 let cleaned = strip_shell_escapes(&code.to_lowercase());
633 let commands = tokenize_commands(&cleaned);
634 for blocked in &self.blocked_commands {
635 for cmd_tokens in &commands {
636 if tokens_match_pattern(cmd_tokens, blocked) {
637 return Some(blocked.as_str());
638 }
639 }
640 }
641 for inner in extract_subshell_contents(&cleaned) {
643 let inner_commands = tokenize_commands(&inner);
644 for blocked in &self.blocked_commands {
645 for cmd_tokens in &inner_commands {
646 if tokens_match_pattern(cmd_tokens, blocked) {
647 return Some(blocked.as_str());
648 }
649 }
650 }
651 }
652 None
653 }
654
655 fn find_confirm_command(&self, code: &str) -> Option<&str> {
656 let normalized = code.to_lowercase();
657 for pattern in &self.confirm_patterns {
658 if normalized.contains(pattern.as_str()) {
659 return Some(pattern.as_str());
660 }
661 }
662 None
663 }
664
665 async fn log_audit(
666 &self,
667 command: &str,
668 result: AuditResult,
669 duration_ms: u64,
670 error: Option<&ToolError>,
671 exit_code: Option<i32>,
672 truncated: bool,
673 ) {
674 if let Some(ref logger) = self.audit_logger {
675 let (error_category, error_domain, error_phase) =
676 error.map_or((None, None, None), |e| {
677 let cat = e.category();
678 (
679 Some(cat.label().to_owned()),
680 Some(cat.domain().label().to_owned()),
681 Some(cat.phase().label().to_owned()),
682 )
683 });
684 let entry = AuditEntry {
685 timestamp: chrono_now(),
686 tool: "shell".into(),
687 command: command.into(),
688 result,
689 duration_ms,
690 error_category,
691 error_domain,
692 error_phase,
693 claim_source: Some(ClaimSource::Shell),
694 mcp_server_id: None,
695 injection_flagged: false,
696 embedding_anomalous: false,
697 cross_boundary_mcp_to_acp: false,
698 adversarial_policy_decision: None,
699 exit_code,
700 truncated,
701 caller_id: None,
702 policy_match: None,
703 };
704 logger.log(&entry).await;
705 }
706 }
707}
708
709impl ToolExecutor for ShellExecutor {
710 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
711 self.execute_inner(response, false).await
712 }
713
714 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
715 use crate::registry::{InvocationHint, ToolDef};
716 vec![ToolDef {
717 id: "bash".into(),
718 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(),
719 schema: schemars::schema_for!(BashParams),
720 invocation: InvocationHint::FencedBlock("bash"),
721 }]
722 }
723
724 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
725 if call.tool_id != "bash" {
726 return Ok(None);
727 }
728 let params: BashParams = crate::executor::deserialize_params(&call.params)?;
729 if params.command.is_empty() {
730 return Ok(None);
731 }
732 let command = ¶ms.command;
733 let synthetic = format!("```bash\n{command}\n```");
735 self.execute_inner(&synthetic, false).await
736 }
737
738 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
739 ShellExecutor::set_skill_env(self, env);
740 }
741}
742
743pub(crate) fn strip_shell_escapes(input: &str) -> String {
747 let mut out = String::with_capacity(input.len());
748 let bytes = input.as_bytes();
749 let mut i = 0;
750 while i < bytes.len() {
751 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'\'' {
753 let mut j = i + 2; let mut decoded = String::new();
755 let mut valid = false;
756 while j < bytes.len() && bytes[j] != b'\'' {
757 if bytes[j] == b'\\' && j + 1 < bytes.len() {
758 let next = bytes[j + 1];
759 if next == b'x' && j + 3 < bytes.len() {
760 let hi = (bytes[j + 2] as char).to_digit(16);
762 let lo = (bytes[j + 3] as char).to_digit(16);
763 if let (Some(h), Some(l)) = (hi, lo) {
764 #[allow(clippy::cast_possible_truncation)]
765 let byte = ((h << 4) | l) as u8;
766 decoded.push(byte as char);
767 j += 4;
768 valid = true;
769 continue;
770 }
771 } else if next.is_ascii_digit() {
772 let mut val = u32::from(next - b'0');
774 let mut len = 2; if j + 2 < bytes.len() && bytes[j + 2].is_ascii_digit() {
776 val = val * 8 + u32::from(bytes[j + 2] - b'0');
777 len = 3;
778 if j + 3 < bytes.len() && bytes[j + 3].is_ascii_digit() {
779 val = val * 8 + u32::from(bytes[j + 3] - b'0');
780 len = 4;
781 }
782 }
783 #[allow(clippy::cast_possible_truncation)]
784 decoded.push((val & 0xFF) as u8 as char);
785 j += len;
786 valid = true;
787 continue;
788 }
789 decoded.push(next as char);
791 j += 2;
792 } else {
793 decoded.push(bytes[j] as char);
794 j += 1;
795 }
796 }
797 if j < bytes.len() && bytes[j] == b'\'' && valid {
798 out.push_str(&decoded);
799 i = j + 1;
800 continue;
801 }
802 }
804 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
806 i += 2;
807 continue;
808 }
809 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] != b'\n' {
811 i += 1;
812 out.push(bytes[i] as char);
813 i += 1;
814 continue;
815 }
816 if bytes[i] == b'"' || bytes[i] == b'\'' {
818 let quote = bytes[i];
819 i += 1;
820 while i < bytes.len() && bytes[i] != quote {
821 out.push(bytes[i] as char);
822 i += 1;
823 }
824 if i < bytes.len() {
825 i += 1; }
827 continue;
828 }
829 out.push(bytes[i] as char);
830 i += 1;
831 }
832 out
833}
834
835pub(crate) fn extract_subshell_contents(s: &str) -> Vec<String> {
845 let mut results = Vec::new();
846 let chars: Vec<char> = s.chars().collect();
847 let len = chars.len();
848 let mut i = 0;
849
850 while i < len {
851 if chars[i] == '`' {
853 let start = i + 1;
854 let mut j = start;
855 while j < len && chars[j] != '`' {
856 j += 1;
857 }
858 if j < len {
859 results.push(chars[start..j].iter().collect());
860 }
861 i = j + 1;
862 continue;
863 }
864
865 let next_is_open_paren = i + 1 < len && chars[i + 1] == '(';
867 let is_paren_subshell = next_is_open_paren && matches!(chars[i], '$' | '<' | '>');
868
869 if is_paren_subshell {
870 let start = i + 2;
871 let mut depth: usize = 1;
872 let mut j = start;
873 while j < len && depth > 0 {
874 match chars[j] {
875 '(' => depth += 1,
876 ')' => depth -= 1,
877 _ => {}
878 }
879 if depth > 0 {
880 j += 1;
881 } else {
882 break;
883 }
884 }
885 if depth == 0 {
886 results.push(chars[start..j].iter().collect());
887 }
888 i = j + 1;
889 continue;
890 }
891
892 i += 1;
893 }
894
895 results
896}
897
898pub(crate) fn tokenize_commands(normalized: &str) -> Vec<Vec<String>> {
901 let replaced = normalized.replace("||", "\n").replace("&&", "\n");
903 replaced
904 .split([';', '|', '\n'])
905 .map(|seg| {
906 seg.split_whitespace()
907 .map(str::to_owned)
908 .collect::<Vec<String>>()
909 })
910 .filter(|tokens| !tokens.is_empty())
911 .collect()
912}
913
914const TRANSPARENT_PREFIXES: &[&str] = &["env", "command", "exec", "nice", "nohup", "time", "xargs"];
917
918fn cmd_basename(tok: &str) -> &str {
920 tok.rsplit('/').next().unwrap_or(tok)
921}
922
923pub(crate) fn tokens_match_pattern(tokens: &[String], pattern: &str) -> bool {
930 if tokens.is_empty() || pattern.is_empty() {
931 return false;
932 }
933 let pattern = pattern.trim();
934 let pattern_tokens: Vec<&str> = pattern.split_whitespace().collect();
935 if pattern_tokens.is_empty() {
936 return false;
937 }
938
939 let start = tokens
941 .iter()
942 .position(|t| !TRANSPARENT_PREFIXES.contains(&cmd_basename(t)))
943 .unwrap_or(0);
944 let effective = &tokens[start..];
945 if effective.is_empty() {
946 return false;
947 }
948
949 if pattern_tokens.len() == 1 {
950 let pat = pattern_tokens[0];
951 let base = cmd_basename(&effective[0]);
952 base == pat || base.starts_with(&format!("{pat}."))
954 } else {
955 let n = pattern_tokens.len().min(effective.len());
957 let mut parts: Vec<&str> = vec![cmd_basename(&effective[0])];
958 parts.extend(effective[1..n].iter().map(String::as_str));
959 let joined = parts.join(" ");
960 if joined.starts_with(pattern) {
961 return true;
962 }
963 if effective.len() > n {
964 let mut parts2: Vec<&str> = vec![cmd_basename(&effective[0])];
965 parts2.extend(effective[1..=n].iter().map(String::as_str));
966 parts2.join(" ").starts_with(pattern)
967 } else {
968 false
969 }
970 }
971}
972
973fn extract_paths(code: &str) -> Vec<String> {
974 let mut result = Vec::new();
975
976 let mut tokens: Vec<String> = Vec::new();
978 let mut current = String::new();
979 let mut chars = code.chars().peekable();
980 while let Some(c) = chars.next() {
981 match c {
982 '"' | '\'' => {
983 let quote = c;
984 while let Some(&nc) = chars.peek() {
985 if nc == quote {
986 chars.next();
987 break;
988 }
989 current.push(chars.next().unwrap());
990 }
991 }
992 c if c.is_whitespace() || matches!(c, ';' | '|' | '&') => {
993 if !current.is_empty() {
994 tokens.push(std::mem::take(&mut current));
995 }
996 }
997 _ => current.push(c),
998 }
999 }
1000 if !current.is_empty() {
1001 tokens.push(current);
1002 }
1003
1004 for token in tokens {
1005 let trimmed = token.trim_end_matches([';', '&', '|']).to_owned();
1006 if trimmed.is_empty() {
1007 continue;
1008 }
1009 if trimmed.starts_with('/')
1010 || trimmed.starts_with("./")
1011 || trimmed.starts_with("../")
1012 || trimmed == ".."
1013 || (trimmed.starts_with('.') && trimmed.contains('/'))
1014 || is_relative_path_token(&trimmed)
1015 {
1016 result.push(trimmed);
1017 }
1018 }
1019 result
1020}
1021
1022fn is_relative_path_token(token: &str) -> bool {
1029 if !token.contains('/') || token.starts_with('/') || token.starts_with('.') {
1031 return false;
1032 }
1033 if token.contains("://") {
1035 return false;
1036 }
1037 if let Some(eq_pos) = token.find('=') {
1039 let key = &token[..eq_pos];
1040 if key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1041 return false;
1042 }
1043 }
1044 token
1046 .chars()
1047 .next()
1048 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
1049}
1050
1051fn classify_shell_exit(
1057 exit_code: i32,
1058 output: &str,
1059) -> Option<crate::error_taxonomy::ToolErrorCategory> {
1060 use crate::error_taxonomy::ToolErrorCategory;
1061 match exit_code {
1062 126 => Some(ToolErrorCategory::PolicyBlocked),
1064 127 => Some(ToolErrorCategory::PermanentFailure),
1066 _ => {
1067 let lower = output.to_lowercase();
1068 if lower.contains("permission denied") {
1069 Some(ToolErrorCategory::PolicyBlocked)
1070 } else if lower.contains("no such file or directory") {
1071 Some(ToolErrorCategory::PermanentFailure)
1072 } else {
1073 None
1074 }
1075 }
1076 }
1077}
1078
1079fn has_traversal(path: &str) -> bool {
1080 path.split('/').any(|seg| seg == "..")
1081}
1082
1083fn extract_bash_blocks(text: &str) -> Vec<&str> {
1084 crate::executor::extract_fenced_blocks(text, "bash")
1085}
1086
1087async fn kill_process_tree(child: &mut tokio::process::Child) {
1091 #[cfg(unix)]
1092 if let Some(pid) = child.id() {
1093 let _ = Command::new("pkill")
1094 .args(["-KILL", "-P", &pid.to_string()])
1095 .status()
1096 .await;
1097 }
1098 let _ = child.kill().await;
1099}
1100
1101#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1103pub struct ShellOutputEnvelope {
1104 pub stdout: String,
1105 pub stderr: String,
1106 pub exit_code: i32,
1107 pub truncated: bool,
1108}
1109
1110#[allow(clippy::too_many_lines)]
1111async fn execute_bash(
1112 code: &str,
1113 timeout: Duration,
1114 event_tx: Option<&ToolEventTx>,
1115 cancel_token: Option<&CancellationToken>,
1116 extra_env: Option<&std::collections::HashMap<String, String>>,
1117 env_blocklist: &[String],
1118) -> (ShellOutputEnvelope, String) {
1119 use std::process::Stdio;
1120 use tokio::io::{AsyncBufReadExt, BufReader};
1121
1122 let timeout_secs = timeout.as_secs();
1123
1124 let mut cmd = Command::new("bash");
1125 cmd.arg("-c")
1126 .arg(code)
1127 .stdout(Stdio::piped())
1128 .stderr(Stdio::piped());
1129
1130 for (key, _) in std::env::vars() {
1131 if env_blocklist
1132 .iter()
1133 .any(|prefix| key.starts_with(prefix.as_str()))
1134 {
1135 cmd.env_remove(&key);
1136 }
1137 }
1138
1139 if let Some(env) = extra_env {
1140 cmd.envs(env);
1141 }
1142 let child_result = cmd.spawn();
1143
1144 let mut child = match child_result {
1145 Ok(c) => c,
1146 Err(e) => {
1147 let msg = format!("[error] {e}");
1148 return (
1149 ShellOutputEnvelope {
1150 stdout: String::new(),
1151 stderr: msg.clone(),
1152 exit_code: 1,
1153 truncated: false,
1154 },
1155 msg,
1156 );
1157 }
1158 };
1159
1160 let stdout = child.stdout.take().expect("stdout piped");
1161 let stderr = child.stderr.take().expect("stderr piped");
1162
1163 let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::<(bool, String)>(64);
1166
1167 let stdout_tx = line_tx.clone();
1168 tokio::spawn(async move {
1169 let mut reader = BufReader::new(stdout);
1170 let mut buf = String::new();
1171 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
1172 let _ = stdout_tx.send((false, buf.clone())).await;
1173 buf.clear();
1174 }
1175 });
1176
1177 tokio::spawn(async move {
1178 let mut reader = BufReader::new(stderr);
1179 let mut buf = String::new();
1180 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
1181 let _ = line_tx.send((true, buf.clone())).await;
1182 buf.clear();
1183 }
1184 });
1185
1186 let mut combined = String::new();
1187 let mut stdout_buf = String::new();
1188 let mut stderr_buf = String::new();
1189 let deadline = tokio::time::Instant::now() + timeout;
1190
1191 loop {
1192 tokio::select! {
1193 line = line_rx.recv() => {
1194 match line {
1195 Some((is_stderr, chunk)) => {
1196 let interleaved = if is_stderr {
1197 format!("[stderr] {chunk}")
1198 } else {
1199 chunk.clone()
1200 };
1201 if let Some(tx) = event_tx {
1202 let _ = tx.send(ToolEvent::OutputChunk {
1203 tool_name: "bash".to_owned(),
1204 command: code.to_owned(),
1205 chunk: interleaved.clone(),
1206 });
1207 }
1208 combined.push_str(&interleaved);
1209 if is_stderr {
1210 stderr_buf.push_str(&chunk);
1211 } else {
1212 stdout_buf.push_str(&chunk);
1213 }
1214 }
1215 None => break,
1216 }
1217 }
1218 () = tokio::time::sleep_until(deadline) => {
1219 kill_process_tree(&mut child).await;
1220 let msg = format!("[error] command timed out after {timeout_secs}s");
1221 return (
1222 ShellOutputEnvelope {
1223 stdout: stdout_buf,
1224 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
1225 exit_code: 1,
1226 truncated: false,
1227 },
1228 msg,
1229 );
1230 }
1231 () = async {
1232 match cancel_token {
1233 Some(t) => t.cancelled().await,
1234 None => std::future::pending().await,
1235 }
1236 } => {
1237 kill_process_tree(&mut child).await;
1238 return (
1239 ShellOutputEnvelope {
1240 stdout: stdout_buf,
1241 stderr: format!("{stderr_buf}operation aborted"),
1242 exit_code: 130,
1243 truncated: false,
1244 },
1245 "[cancelled] operation aborted".to_string(),
1246 );
1247 }
1248 }
1249 }
1250
1251 let status = child.wait().await;
1252 let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(1);
1253
1254 let (envelope, combined) = if combined.is_empty() {
1255 (
1256 ShellOutputEnvelope {
1257 stdout: String::new(),
1258 stderr: String::new(),
1259 exit_code,
1260 truncated: false,
1261 },
1262 "(no output)".to_string(),
1263 )
1264 } else {
1265 (
1266 ShellOutputEnvelope {
1267 stdout: stdout_buf.trim_end().to_owned(),
1268 stderr: stderr_buf.trim_end().to_owned(),
1269 exit_code,
1270 truncated: false,
1271 },
1272 combined,
1273 )
1274 };
1275 (envelope, combined)
1276}
1277
1278#[cfg(test)]
1279mod tests;