1use std::collections::HashMap;
24use std::path::PathBuf;
25use std::sync::Arc;
26use std::sync::atomic::AtomicBool;
27use std::time::{Duration, Instant};
28
29use tokio::process::Command;
30use tokio_util::sync::CancellationToken;
31
32use schemars::JsonSchema;
33use serde::Deserialize;
34
35use arc_swap::ArcSwap;
36use parking_lot::{Mutex, RwLock};
37
38use zeph_common::ToolName;
39
40use crate::audit::{AuditEntry, AuditLogger, AuditResult, chrono_now};
41use crate::config::ShellConfig;
42use crate::execution_context::ExecutionContext;
43use crate::executor::{
44 ClaimSource, FilterStats, ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput,
45};
46use crate::filter::{OutputFilterRegistry, sanitize_output};
47use crate::permissions::{PermissionAction, PermissionPolicy};
48use crate::sandbox::{Sandbox, SandboxPolicy};
49
50pub mod background;
51pub use background::BackgroundRunSnapshot;
52use background::{BackgroundCompletion, BackgroundHandle, RunId};
53
54pub mod deobfuscate;
55pub use deobfuscate::deobfuscate as deobfuscate_command;
56
57pub mod safe_fix;
58pub use safe_fix::SafeFixSuggestion;
59
60mod transaction;
61use transaction::{TransactionSnapshot, affected_paths, build_scope_matchers, is_write_command};
62
63use crate::risk_chain::RiskChainAccumulator;
64
65const DEFAULT_BLOCKED: &[&str] = &[
66 "rm -rf /", "sudo", "mkfs", "dd if=", "curl", "wget", "nc ", "ncat", "netcat", "shutdown",
67 "reboot", "halt",
68];
69
70#[must_use]
88pub fn is_blocked_rm_worktrees(cmd: &str) -> bool {
89 let lower = cmd.to_lowercase();
90 let tokens: Vec<&str> = lower.split_whitespace().collect();
91
92 let Some(first) = tokens.first() else {
94 return false;
95 };
96 if first.rsplit('/').next().unwrap_or(first) != "rm" {
97 return false;
98 }
99
100 if !lower.contains(".git/worktrees") {
101 return false;
102 }
103
104 let mut has_recursive = false;
105 let mut has_force = false;
106
107 for token in &tokens[1..] {
108 if *token == "--recursive" {
109 has_recursive = true;
110 } else if *token == "--force" {
111 has_force = true;
112 } else if let Some(flags) = token.strip_prefix('-').filter(|f| !f.starts_with('-')) {
113 if flags.contains('r') || flags.contains('R') {
115 has_recursive = true;
116 }
117 if flags.contains('f') {
118 has_force = true;
119 }
120 }
121 }
122
123 has_recursive && has_force
124}
125
126#[cfg(unix)]
128const GRACEFUL_TERM_MS: Duration = Duration::from_millis(250);
129
130pub const DEFAULT_BLOCKED_COMMANDS: &[&str] = DEFAULT_BLOCKED;
143
144pub const SHELL_INTERPRETERS: &[&str] =
150 &["bash", "sh", "zsh", "fish", "dash", "ksh", "csh", "tcsh"];
151
152const SUBSHELL_METACHARS: &[&str] = &["$(", "`", "<(", ">("];
156
157#[must_use]
165pub fn check_blocklist(command: &str, blocklist: &[String]) -> Option<String> {
166 let lower = command.to_lowercase();
167 for meta in SUBSHELL_METACHARS {
169 if lower.contains(meta) {
170 return Some((*meta).to_owned());
171 }
172 }
173 let cleaned = strip_shell_escapes(&lower);
174 let commands = tokenize_commands(&cleaned);
175 for cmd_tokens in &commands {
176 let joined = cmd_tokens.join(" ");
177 if is_blocked_rm_worktrees(&joined) {
178 return Some("rm --recursive --force .git/worktrees".to_owned());
179 }
180 }
181 for blocked in blocklist {
182 for cmd_tokens in &commands {
183 if tokens_match_pattern(cmd_tokens, blocked) {
184 return Some(blocked.clone());
185 }
186 }
187 }
188 None
189}
190
191#[must_use]
196pub fn effective_shell_command<'a>(binary: &str, args: &'a [String]) -> Option<&'a str> {
197 let base = binary.rsplit('/').next().unwrap_or(binary);
198 if !SHELL_INTERPRETERS.contains(&base) {
199 return None;
200 }
201 let pos = args.iter().position(|a| a == "-c")?;
203 args.get(pos + 1).map(String::as_str)
204}
205
206const NETWORK_COMMANDS: &[&str] = &["curl", "wget", "nc ", "ncat", "netcat"];
207
208#[derive(Debug)]
212pub(crate) struct ShellPolicy {
213 pub(crate) blocked_commands: Vec<String>,
214}
215
216#[derive(Clone, Debug)]
223pub struct ShellPolicyHandle {
224 inner: Arc<ArcSwap<ShellPolicy>>,
225}
226
227impl ShellPolicyHandle {
228 pub fn rebuild(&self, config: &crate::config::ShellConfig) {
237 let policy = Arc::new(ShellPolicy {
238 blocked_commands: compute_blocked_commands(config),
239 });
240 self.inner.store(policy);
241 }
242
243 #[must_use]
245 pub fn snapshot_blocked(&self) -> Vec<String> {
246 self.inner.load().blocked_commands.clone()
247 }
248}
249
250pub(crate) fn compute_blocked_commands(config: &crate::config::ShellConfig) -> Vec<String> {
254 let allowed: Vec<String> = config
255 .allowed_commands
256 .iter()
257 .map(|s| s.to_lowercase())
258 .collect();
259 let mut blocked: Vec<String> = DEFAULT_BLOCKED
260 .iter()
261 .filter(|s| !allowed.contains(&s.to_lowercase()))
262 .map(|s| (*s).to_owned())
263 .collect();
264 blocked.extend(config.blocked_commands.iter().map(|s| s.to_lowercase()));
265 if !config.allow_network {
266 for cmd in NETWORK_COMMANDS {
267 let lower = cmd.to_lowercase();
268 if !blocked.contains(&lower) {
269 blocked.push(lower);
270 }
271 }
272 }
273 blocked.sort();
274 blocked.dedup();
275 blocked
276}
277
278#[derive(Deserialize, JsonSchema)]
279pub(crate) struct BashParams {
280 command: String,
282 #[serde(default)]
288 background: bool,
289}
290
291#[derive(Debug)]
314pub struct ShellExecutor {
315 timeout: Duration,
316 policy: Arc<ArcSwap<ShellPolicy>>,
317 confirm_patterns: Vec<String>,
318 env_blocklist: Vec<String>,
319 audit_logger: Option<Arc<AuditLogger>>,
320 tool_event_tx: Option<ToolEventTx>,
321 permission_policy: Option<PermissionPolicy>,
322 output_filter_registry: Option<OutputFilterRegistry>,
323 cancel_token: Option<CancellationToken>,
324 skill_env: RwLock<Option<std::collections::HashMap<String, String>>>,
325 transactional: bool,
326 auto_rollback: bool,
327 auto_rollback_exit_codes: Vec<i32>,
328 snapshot_required: bool,
329 max_snapshot_bytes: u64,
330 transaction_scope_matchers: Vec<globset::GlobMatcher>,
331 sandbox: Option<Arc<dyn Sandbox>>,
332 sandbox_policy: Option<SandboxPolicy>,
333 background_runs: Arc<Mutex<HashMap<RunId, BackgroundHandle>>>,
335 max_background_runs: usize,
337 background_timeout: Duration,
339 shutting_down: Arc<AtomicBool>,
341 background_completion_tx: Option<tokio::sync::mpsc::Sender<BackgroundCompletion>>,
345 environments: Arc<HashMap<String, ExecutionContext>>,
348 allowed_paths_canonical: Vec<PathBuf>,
351 default_env: Option<String>,
353 risk_chain: Option<Arc<RiskChainAccumulator>>,
355 risk_chain_threshold: f32,
357}
358
359#[derive(Debug)]
365pub(crate) struct ResolvedContext {
366 pub(crate) cwd: PathBuf,
368 pub(crate) env: HashMap<String, String>,
370 pub(crate) name: Option<String>,
372 #[allow(dead_code)]
375 pub(crate) trusted: bool,
376}
377
378impl ShellExecutor {
379 #[must_use]
385 pub fn new(config: &ShellConfig) -> Self {
386 let policy = Arc::new(ArcSwap::from_pointee(ShellPolicy {
387 blocked_commands: compute_blocked_commands(config),
388 }));
389
390 let allowed_paths: Vec<PathBuf> = if config.allowed_paths.is_empty() {
391 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
392 } else {
393 config.allowed_paths.iter().map(PathBuf::from).collect()
394 };
395 let allowed_paths_canonical: Vec<PathBuf> = allowed_paths
396 .iter()
397 .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
398 .collect();
399
400 Self {
401 timeout: Duration::from_secs(config.timeout),
402 policy,
403 confirm_patterns: config.confirm_patterns.clone(),
404 env_blocklist: config.env_blocklist.clone(),
405 audit_logger: None,
406 tool_event_tx: None,
407 permission_policy: None,
408 output_filter_registry: None,
409 cancel_token: None,
410 skill_env: RwLock::new(None),
411 transactional: config.transactional,
412 auto_rollback: config.auto_rollback,
413 auto_rollback_exit_codes: config.auto_rollback_exit_codes.clone(),
414 snapshot_required: config.snapshot_required,
415 max_snapshot_bytes: config.max_snapshot_bytes,
416 transaction_scope_matchers: build_scope_matchers(&config.transaction_scope),
417 sandbox: None,
418 sandbox_policy: None,
419 background_runs: Arc::new(Mutex::new(HashMap::new())),
420 max_background_runs: config.max_background_runs,
421 background_timeout: Duration::from_secs(config.background_timeout_secs),
422 shutting_down: Arc::new(AtomicBool::new(false)),
423 background_completion_tx: None,
424 environments: Arc::new(HashMap::new()),
425 allowed_paths_canonical,
426 default_env: None,
427 risk_chain: None,
428 risk_chain_threshold: config.risk_chain_threshold.unwrap_or(0.7),
429 }
430 }
431
432 #[must_use]
437 pub fn with_sandbox(mut self, sandbox: Arc<dyn Sandbox>, policy: SandboxPolicy) -> Self {
438 self.sandbox = Some(sandbox);
439 self.sandbox_policy = Some(policy);
440 self
441 }
442
443 #[must_use]
448 pub fn with_risk_chain(mut self, accumulator: Arc<RiskChainAccumulator>) -> Self {
449 self.risk_chain = Some(accumulator);
450 self
451 }
452
453 pub fn with_execution_config(
464 self,
465 config: &zeph_config::ExecutionConfig,
466 ) -> Result<Self, String> {
467 let registry: HashMap<String, ExecutionContext> = config
468 .environments
469 .iter()
470 .map(|e| {
471 let ctx = ExecutionContext::trusted_from_parts(
472 Some(e.name.clone()),
473 Some(std::path::PathBuf::from(&e.cwd)),
474 e.env.clone(),
475 );
476 (e.name.clone(), ctx)
477 })
478 .collect();
479 self.with_environments(registry, config.default_env.clone())
480 }
481
482 pub fn with_environments(
492 mut self,
493 environments: HashMap<String, ExecutionContext>,
494 default_env: Option<String>,
495 ) -> Result<Self, String> {
496 for (name, ctx) in &environments {
498 if let Some(cwd) = ctx.cwd() {
499 let canonical = cwd.canonicalize().map_err(|e| {
500 format!(
501 "execution environment '{name}': cwd '{}' cannot be canonicalized: {e}",
502 cwd.display()
503 )
504 })?;
505 if !self
506 .allowed_paths_canonical
507 .iter()
508 .any(|p| canonical.starts_with(p))
509 {
510 return Err(format!(
511 "execution environment '{name}': cwd '{}' is outside allowed_paths",
512 cwd.display()
513 ));
514 }
515 }
516 }
517 self.environments = Arc::new(environments);
518 self.default_env = default_env;
519 Ok(self)
520 }
521
522 pub fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
524 *self.skill_env.write() = env;
525 }
526
527 #[must_use]
529 pub fn with_audit(mut self, logger: Arc<AuditLogger>) -> Self {
530 self.audit_logger = Some(logger);
531 self
532 }
533
534 #[must_use]
539 pub fn with_tool_event_tx(mut self, tx: ToolEventTx) -> Self {
540 self.tool_event_tx = Some(tx);
541 self
542 }
543
544 #[must_use]
550 pub fn with_background_completion_tx(
551 mut self,
552 tx: tokio::sync::mpsc::Sender<BackgroundCompletion>,
553 ) -> Self {
554 self.background_completion_tx = Some(tx);
555 self
556 }
557
558 #[must_use]
563 pub fn with_permissions(mut self, policy: PermissionPolicy) -> Self {
564 self.permission_policy = Some(policy);
565 self
566 }
567
568 #[must_use]
571 pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
572 self.cancel_token = Some(token);
573 self
574 }
575
576 #[must_use]
579 pub fn with_output_filters(mut self, registry: OutputFilterRegistry) -> Self {
580 self.output_filter_registry = Some(registry);
581 self
582 }
583
584 #[must_use]
590 pub fn background_runs_snapshot(&self) -> Vec<background::BackgroundRunSnapshot> {
591 let runs = self.background_runs.lock();
592 runs.iter()
593 .map(|(id, h)| {
594 #[allow(clippy::cast_possible_truncation)]
595 let elapsed_ms = h.elapsed().as_millis() as u64;
596 background::BackgroundRunSnapshot {
597 run_id: id.to_string(),
598 command: h.command.clone(),
599 elapsed_ms,
600 }
601 })
602 .collect()
603 }
604
605 #[must_use]
611 pub fn policy_handle(&self) -> ShellPolicyHandle {
612 ShellPolicyHandle {
613 inner: Arc::clone(&self.policy),
614 }
615 }
616
617 #[cfg_attr(
623 feature = "profiling",
624 tracing::instrument(name = "tool.shell", skip_all, fields(exit_code = tracing::field::Empty, duration_ms = tracing::field::Empty))
625 )]
626 pub async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
627 self.execute_inner(response, true).await
628 }
629
630 async fn execute_inner(
631 &self,
632 response: &str,
633 skip_confirm: bool,
634 ) -> Result<Option<ToolOutput>, ToolError> {
635 let blocks = extract_bash_blocks(response);
636 if blocks.is_empty() {
637 return Ok(None);
638 }
639
640 let resolved = self.resolve_context(None)?;
643
644 let mut outputs = Vec::with_capacity(blocks.len());
645 let mut cumulative_filter_stats: Option<FilterStats> = None;
646 let mut last_envelope: Option<ShellOutputEnvelope> = None;
647 #[allow(clippy::cast_possible_truncation)]
648 let blocks_executed = blocks.len() as u32;
649
650 for block in &blocks {
651 let (output_line, per_block_stats, envelope) =
652 self.execute_block(block, skip_confirm, &resolved).await?;
653 if let Some(fs) = per_block_stats {
654 let stats = cumulative_filter_stats.get_or_insert_with(FilterStats::default);
655 stats.raw_chars += fs.raw_chars;
656 stats.filtered_chars += fs.filtered_chars;
657 stats.raw_lines += fs.raw_lines;
658 stats.filtered_lines += fs.filtered_lines;
659 stats.confidence = Some(match (stats.confidence, fs.confidence) {
660 (Some(prev), Some(cur)) => crate::filter::worse_confidence(prev, cur),
661 (Some(prev), None) => prev,
662 (None, Some(cur)) => cur,
663 (None, None) => unreachable!(),
664 });
665 if stats.command.is_none() {
666 stats.command = fs.command;
667 }
668 if stats.kept_lines.is_empty() && !fs.kept_lines.is_empty() {
669 stats.kept_lines = fs.kept_lines;
670 }
671 }
672 last_envelope = Some(envelope);
673 outputs.push(output_line);
674 }
675
676 let raw_response = last_envelope
677 .as_ref()
678 .and_then(|e| serde_json::to_value(e).ok());
679
680 Ok(Some(ToolOutput {
681 tool_name: ToolName::new("bash"),
682 summary: outputs.join("\n\n"),
683 blocks_executed,
684 filter_stats: cumulative_filter_stats,
685 diff: None,
686 streamed: self.tool_event_tx.is_some(),
687 terminal_id: None,
688 locations: None,
689 raw_response,
690 claim_source: Some(ClaimSource::Shell),
691 }))
692 }
693
694 async fn execute_block(
695 &self,
696 block: &str,
697 skip_confirm: bool,
698 resolved: &ResolvedContext,
699 ) -> Result<(String, Option<FilterStats>, ShellOutputEnvelope), ToolError> {
700 self.check_permissions(block, skip_confirm).await?;
701 self.validate_sandbox_with_cwd(block, &resolved.cwd)?;
702
703 let (snapshot, snapshot_warning) = self.capture_snapshot_for(block)?;
704
705 if let Some(ref tx) = self.tool_event_tx {
706 let sandbox_profile = self
707 .sandbox_policy
708 .as_ref()
709 .map(|p| format!("{:?}", p.profile));
710 let _ = tx.try_send(ToolEvent::Started {
712 tool_name: ToolName::new("bash"),
713 command: block.to_owned(),
714 sandbox_profile,
715 resolved_cwd: Some(resolved.cwd.display().to_string()),
716 execution_env: resolved.name.clone(),
717 });
718 }
719
720 let start = Instant::now();
721 let sandbox_pair = self
722 .sandbox
723 .as_ref()
724 .zip(self.sandbox_policy.as_ref())
725 .map(|(sb, pol)| (sb.as_ref(), pol));
726 let (mut envelope, out) = execute_bash_with_context(
727 block,
728 self.timeout,
729 self.tool_event_tx.as_ref(),
730 "",
731 self.cancel_token.as_ref(),
732 resolved,
733 sandbox_pair,
734 )
735 .await;
736 let exit_code = envelope.exit_code;
737 if exit_code == 130
738 && self
739 .cancel_token
740 .as_ref()
741 .is_some_and(CancellationToken::is_cancelled)
742 {
743 return Err(ToolError::Cancelled);
744 }
745 #[allow(clippy::cast_possible_truncation)]
746 let duration_ms = start.elapsed().as_millis() as u64;
747
748 if let Some(snap) = snapshot {
749 self.maybe_rollback(snap, block, exit_code, duration_ms)
750 .await;
751 }
752
753 if let Some(err) = self
754 .classify_and_audit(block, &out, exit_code, duration_ms)
755 .await
756 {
757 self.emit_completed(block, &out, false, None, None).await;
758 return Err(err);
759 }
760
761 let (filtered, per_block_stats) = self.apply_output_filter(block, &out, exit_code);
762
763 self.emit_completed(
764 block,
765 &out,
766 !out.contains("[error]"),
767 per_block_stats.clone(),
768 None,
769 )
770 .await;
771
772 envelope.truncated = filtered.len() < out.len();
774
775 let audit_result = if out.contains("[error]") || out.contains("[stderr]") {
776 AuditResult::Error {
777 message: out.clone(),
778 }
779 } else {
780 AuditResult::Success
781 };
782 self.log_audit_with_context(
783 block,
784 audit_result,
785 duration_ms,
786 None,
787 Some(exit_code),
788 envelope.truncated,
789 resolved,
790 )
791 .await;
792
793 let output_line = match snapshot_warning {
794 Some(warn) => format!("{warn}\n$ {block}\n{filtered}"),
795 None => format!("$ {block}\n{filtered}"),
796 };
797 Ok((output_line, per_block_stats, envelope))
798 }
799
800 #[tracing::instrument(name = "tool.shell.execute_block", skip(self, resolved), level = "info",
805 fields(cwd = %resolved.cwd.display(), env_name = resolved.name.as_deref().unwrap_or("")))]
806 async fn execute_block_with_context(
807 &self,
808 command: &str,
809 skip_confirm: bool,
810 resolved: &ResolvedContext,
811 tool_call_id: &str,
812 ) -> Result<Option<ToolOutput>, ToolError> {
813 self.check_permissions(command, skip_confirm).await?;
814 self.validate_sandbox_with_cwd(command, &resolved.cwd)?;
815
816 let (snapshot, snapshot_warning) = self.capture_snapshot_for(command)?;
817
818 if let Some(ref tx) = self.tool_event_tx {
819 let sandbox_profile = self
820 .sandbox_policy
821 .as_ref()
822 .map(|p| format!("{:?}", p.profile));
823 let _ = tx.try_send(ToolEvent::Started {
824 tool_name: ToolName::new("bash"),
825 command: command.to_owned(),
826 sandbox_profile,
827 resolved_cwd: Some(resolved.cwd.display().to_string()),
828 execution_env: resolved.name.clone(),
829 });
830 }
831
832 let start = Instant::now();
833 let sandbox_pair = self
834 .sandbox
835 .as_ref()
836 .zip(self.sandbox_policy.as_ref())
837 .map(|(sb, pol)| (sb.as_ref(), pol));
838 let (mut envelope, out) = execute_bash_with_context(
839 command,
840 self.timeout,
841 self.tool_event_tx.as_ref(),
842 tool_call_id,
843 self.cancel_token.as_ref(),
844 resolved,
845 sandbox_pair,
846 )
847 .await;
848 let exit_code = envelope.exit_code;
849 if exit_code == 130
850 && self
851 .cancel_token
852 .as_ref()
853 .is_some_and(CancellationToken::is_cancelled)
854 {
855 return Err(ToolError::Cancelled);
856 }
857 #[allow(clippy::cast_possible_truncation)]
858 let duration_ms = start.elapsed().as_millis() as u64;
859
860 if let Some(snap) = snapshot {
861 self.maybe_rollback(snap, command, exit_code, duration_ms)
862 .await;
863 }
864
865 if let Some(err) = self
866 .classify_and_audit(command, &out, exit_code, duration_ms)
867 .await
868 {
869 self.emit_completed(command, &out, false, None, None).await;
870 return Err(err);
871 }
872
873 let (filtered, per_block_stats) = self.apply_output_filter(command, &out, exit_code);
874
875 self.emit_completed(
876 command,
877 &out,
878 !out.contains("[error]"),
879 per_block_stats.clone(),
880 None,
881 )
882 .await;
883
884 envelope.truncated = filtered.len() < out.len();
885
886 let audit_result = if out.contains("[error]") || out.contains("[stderr]") {
887 AuditResult::Error {
888 message: out.clone(),
889 }
890 } else {
891 AuditResult::Success
892 };
893 self.log_audit_with_context(
894 command,
895 audit_result,
896 duration_ms,
897 None,
898 Some(exit_code),
899 envelope.truncated,
900 resolved,
901 )
902 .await;
903
904 let output_line = match snapshot_warning {
905 Some(warn) => format!("{warn}\n$ {command}\n{filtered}"),
906 None => format!("$ {command}\n{filtered}"),
907 };
908 Ok(Some(ToolOutput {
909 tool_name: ToolName::new("bash"),
910 summary: output_line,
911 blocks_executed: 1,
912 filter_stats: per_block_stats,
913 diff: None,
914 streamed: false,
915 terminal_id: None,
916 locations: None,
917 raw_response: None,
918 claim_source: Some(ClaimSource::Shell),
919 }))
920 }
921
922 fn capture_snapshot_for(
923 &self,
924 block: &str,
925 ) -> Result<(Option<TransactionSnapshot>, Option<String>), ToolError> {
926 if !self.transactional || !is_write_command(block) {
927 return Ok((None, None));
928 }
929 let paths = affected_paths(block, &self.transaction_scope_matchers);
930 if paths.is_empty() {
931 return Ok((None, None));
932 }
933 match TransactionSnapshot::capture(&paths, self.max_snapshot_bytes) {
934 Ok(snap) => {
935 tracing::debug!(
936 files = snap.file_count(),
937 bytes = snap.total_bytes(),
938 "transaction snapshot captured"
939 );
940 Ok((Some(snap), None))
941 }
942 Err(e) if self.snapshot_required => Err(ToolError::SnapshotFailed {
943 reason: e.to_string(),
944 }),
945 Err(e) => {
946 tracing::warn!(err = %e, "transaction snapshot failed, proceeding without rollback");
947 Ok((
948 None,
949 Some(format!("[warn] snapshot failed: {e}; rollback unavailable")),
950 ))
951 }
952 }
953 }
954
955 async fn maybe_rollback(
956 &self,
957 snap: TransactionSnapshot,
958 block: &str,
959 exit_code: i32,
960 duration_ms: u64,
961 ) {
962 let should_rollback = self.auto_rollback
963 && if self.auto_rollback_exit_codes.is_empty() {
964 exit_code >= 2
965 } else {
966 self.auto_rollback_exit_codes.contains(&exit_code)
967 };
968 if !should_rollback {
969 return;
971 }
972 match snap.rollback() {
973 Ok(report) => {
974 tracing::info!(
975 restored = report.restored_count,
976 deleted = report.deleted_count,
977 "transaction rollback completed"
978 );
979 self.log_audit(
980 block,
981 AuditResult::Rollback {
982 restored: report.restored_count,
983 deleted: report.deleted_count,
984 },
985 duration_ms,
986 None,
987 Some(exit_code),
988 false,
989 )
990 .await;
991 if let Some(ref tx) = self.tool_event_tx {
992 let _ = tx
994 .send(ToolEvent::Rollback {
995 tool_name: ToolName::new("bash"),
996 command: block.to_owned(),
997 restored_count: report.restored_count,
998 deleted_count: report.deleted_count,
999 })
1000 .await;
1001 }
1002 }
1003 Err(e) => {
1004 tracing::error!(err = %e, "transaction rollback failed");
1005 }
1006 }
1007 }
1008
1009 async fn classify_and_audit(
1010 &self,
1011 block: &str,
1012 out: &str,
1013 exit_code: i32,
1014 duration_ms: u64,
1015 ) -> Option<ToolError> {
1016 if out.contains("[error] command timed out") {
1017 self.log_audit(
1018 block,
1019 AuditResult::Timeout,
1020 duration_ms,
1021 None,
1022 Some(exit_code),
1023 false,
1024 )
1025 .await;
1026 return Some(ToolError::Timeout {
1027 timeout_secs: self.timeout.as_secs(),
1028 });
1029 }
1030
1031 if let Some(category) = classify_shell_exit(exit_code, out) {
1032 return Some(ToolError::Shell {
1033 exit_code,
1034 category,
1035 message: out.lines().take(3).collect::<Vec<_>>().join("; "),
1036 });
1037 }
1038
1039 None
1040 }
1041
1042 fn apply_output_filter(
1043 &self,
1044 block: &str,
1045 out: &str,
1046 exit_code: i32,
1047 ) -> (String, Option<FilterStats>) {
1048 let sanitized = sanitize_output(out);
1049 if let Some(ref registry) = self.output_filter_registry {
1050 match registry.apply(block, &sanitized, exit_code) {
1051 Some(fr) => {
1052 tracing::debug!(
1053 command = block,
1054 raw = fr.raw_chars,
1055 filtered = fr.filtered_chars,
1056 savings_pct = fr.savings_pct(),
1057 "output filter applied"
1058 );
1059 let stats = FilterStats {
1060 raw_chars: fr.raw_chars,
1061 filtered_chars: fr.filtered_chars,
1062 raw_lines: fr.raw_lines,
1063 filtered_lines: fr.filtered_lines,
1064 confidence: Some(fr.confidence),
1065 command: Some(block.to_owned()),
1066 kept_lines: fr.kept_lines.clone(),
1067 };
1068 (fr.output, Some(stats))
1069 }
1070 None => (sanitized, None),
1071 }
1072 } else {
1073 (sanitized, None)
1074 }
1075 }
1076
1077 async fn emit_completed(
1078 &self,
1079 command: &str,
1080 output: &str,
1081 success: bool,
1082 filter_stats: Option<FilterStats>,
1083 run_id: Option<RunId>,
1084 ) {
1085 if let Some(ref tx) = self.tool_event_tx {
1086 let _ = tx
1088 .send(ToolEvent::Completed {
1089 tool_name: ToolName::new("bash"),
1090 command: command.to_owned(),
1091 output: output.to_owned(),
1092 success,
1093 filter_stats,
1094 diff: None,
1095 run_id,
1096 })
1097 .await;
1098 }
1099 }
1100
1101 #[allow(clippy::too_many_lines)]
1103 async fn check_permissions(&self, block: &str, skip_confirm: bool) -> Result<(), ToolError> {
1104 let normalized = deobfuscate::deobfuscate(block);
1106 let effective = normalized.as_str();
1107
1108 let blocked_cmd = self
1113 .find_blocked_command(block)
1114 .or_else(|| self.find_blocked_command(effective));
1115 if let Some(blocked) = blocked_cmd {
1116 let fix = safe_fix::suggest_fix(effective);
1117 let err = if let Some(suggestion) = fix {
1118 let reason = format!("{blocked} — suggestion: {}", suggestion.alternative);
1119 self.log_audit(
1120 block,
1121 AuditResult::Blocked {
1122 reason: format!("blocked command: {reason}"),
1123 },
1124 0,
1125 None,
1126 None,
1127 false,
1128 )
1129 .await;
1130 ToolError::BlockedWithFix {
1131 command: blocked,
1132 suggestion: Some(suggestion),
1133 }
1134 } else {
1135 self.log_audit(
1136 block,
1137 AuditResult::Blocked {
1138 reason: format!("blocked command: {blocked}"),
1139 },
1140 0,
1141 None,
1142 None,
1143 false,
1144 )
1145 .await;
1146 ToolError::Blocked { command: blocked }
1147 };
1148 return Err(err);
1149 }
1150
1151 if let Some(ref policy) = self.permission_policy {
1152 match policy.check("bash", effective) {
1153 PermissionAction::Deny => {
1154 let err = match safe_fix::suggest_fix(effective) {
1155 Some(suggestion) => ToolError::BlockedWithFix {
1156 command: effective.to_owned(),
1157 suggestion: Some(suggestion),
1158 },
1159 None => ToolError::Blocked {
1160 command: effective.to_owned(),
1161 },
1162 };
1163 self.log_audit(
1164 block,
1165 AuditResult::Blocked {
1166 reason: "denied by permission policy".to_owned(),
1167 },
1168 0,
1169 None,
1170 None,
1171 false,
1172 )
1173 .await;
1174 return Err(err);
1175 }
1176 PermissionAction::Ask if !skip_confirm => {
1177 return Err(ToolError::ConfirmationRequired {
1178 command: effective.to_owned(),
1179 });
1180 }
1181 _ => {}
1182 }
1183 } else if !skip_confirm {
1184 let confirm_pattern = self
1187 .find_confirm_command(block)
1188 .or_else(|| self.find_confirm_command(effective));
1189 if let Some(pattern) = confirm_pattern {
1190 return Err(ToolError::ConfirmationRequired {
1191 command: pattern.to_owned(),
1192 });
1193 }
1194 }
1195
1196 if let Some(ref chain) = self.risk_chain {
1198 let verdict = chain.record("bash", effective, self.risk_chain_threshold);
1199 if verdict.should_block {
1200 let chain_name = verdict
1201 .chain_pattern
1202 .unwrap_or_else(|| "unknown".to_owned());
1203 tracing::warn!(
1204 chain = chain_name,
1205 score = verdict.cumulative_score,
1206 "risk chain threshold exceeded"
1207 );
1208 return Err(ToolError::Blocked {
1209 command: format!(
1210 "risk chain blocked: {} (score {:.2})",
1211 chain_name, verdict.cumulative_score
1212 ),
1213 });
1214 }
1215 }
1216
1217 Ok(())
1218 }
1219
1220 #[tracing::instrument(name = "tools.shell.resolve_context", skip(self, ctx), level = "info")]
1233 pub(crate) fn resolve_context(
1234 &self,
1235 ctx: Option<&ExecutionContext>,
1236 ) -> Result<ResolvedContext, ToolError> {
1237 let mut env: HashMap<String, String> = std::env::vars().collect();
1239
1240 env.retain(|k, _| {
1242 !self
1243 .env_blocklist
1244 .iter()
1245 .any(|prefix| k.starts_with(prefix.as_str()))
1246 });
1247
1248 if let Some(skill) = self.skill_env.read().as_ref() {
1250 for (k, v) in skill {
1251 env.insert(k.clone(), v.clone());
1252 }
1253 }
1254
1255 let mut resolved_name: Option<String> = None;
1257 let mut cwd_override: Option<PathBuf> = None;
1258 let mut trusted = false;
1259
1260 if let Some(default_name) = &self.default_env
1262 && let Some(default_ctx) = self.environments.get(default_name.as_str())
1263 {
1264 resolved_name.get_or_insert_with(|| default_name.clone());
1265 if cwd_override.is_none() {
1266 cwd_override = default_ctx.cwd().map(ToOwned::to_owned);
1267 }
1268 trusted = default_ctx.is_trusted();
1269 for (k, v) in default_ctx.env_overrides() {
1270 env.insert(k.clone(), v.clone());
1271 }
1272 }
1273
1274 if let Some(ctx) = ctx {
1276 if let Some(name) = ctx.name() {
1277 if let Some(reg_ctx) = self.environments.get(name) {
1278 resolved_name = Some(name.to_owned());
1279 if let Some(cwd) = reg_ctx.cwd() {
1280 cwd_override = Some(cwd.to_owned());
1281 }
1282 trusted = reg_ctx.is_trusted();
1283 for (k, v) in reg_ctx.env_overrides() {
1284 env.insert(k.clone(), v.clone());
1285 }
1286 } else {
1287 return Err(ToolError::Execution(std::io::Error::other(format!(
1288 "unknown execution environment '{name}'"
1289 ))));
1290 }
1291 }
1292
1293 if let Some(cwd) = ctx.cwd() {
1295 cwd_override = Some(cwd.to_owned());
1296 }
1297 if !ctx.is_trusted() {
1298 trusted = false;
1299 }
1300 for (k, v) in ctx.env_overrides() {
1301 env.insert(k.clone(), v.clone());
1302 }
1303 }
1304
1305 if !trusted {
1307 env.retain(|k, _| {
1308 !self
1309 .env_blocklist
1310 .iter()
1311 .any(|prefix| k.starts_with(prefix.as_str()))
1312 });
1313 }
1314
1315 let cwd = if let Some(raw) = cwd_override {
1317 let raw = if raw.is_absolute() {
1320 raw
1321 } else {
1322 std::env::current_dir()
1323 .unwrap_or_else(|_| PathBuf::from("."))
1324 .join(raw)
1325 };
1326 let canonical = raw
1327 .canonicalize()
1328 .map_err(|_| ToolError::SandboxViolation {
1329 path: raw.display().to_string(),
1330 })?;
1331 if !self
1333 .allowed_paths_canonical
1334 .iter()
1335 .any(|p| canonical.starts_with(p))
1336 {
1337 return Err(ToolError::SandboxViolation {
1338 path: canonical.display().to_string(),
1339 });
1340 }
1341 canonical
1342 } else {
1343 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1344 };
1345
1346 Ok(ResolvedContext {
1347 cwd,
1348 env,
1349 name: resolved_name,
1350 trusted,
1351 })
1352 }
1353
1354 fn validate_sandbox_with_cwd(
1355 &self,
1356 code: &str,
1357 cwd: &std::path::Path,
1358 ) -> Result<(), ToolError> {
1359 for token in extract_paths(code) {
1360 if has_traversal(&token) {
1361 return Err(ToolError::SandboxViolation { path: token });
1362 }
1363
1364 if self.allowed_paths_canonical.is_empty() {
1365 continue;
1366 }
1367
1368 let path = if token.starts_with('/') {
1369 PathBuf::from(&token)
1370 } else {
1371 cwd.join(&token)
1372 };
1373 let canonical = if let Ok(c) = path.canonicalize() {
1379 c
1380 } else {
1381 let components: Vec<_> = path.components().collect();
1383 let mut base_len = components.len();
1384 let canonical_base = loop {
1385 if base_len == 0 {
1386 break PathBuf::new();
1387 }
1388 let candidate: PathBuf = components[..base_len].iter().collect();
1389 if let Ok(c) = candidate.canonicalize() {
1390 break c;
1391 }
1392 base_len -= 1;
1393 };
1394 components[base_len..]
1396 .iter()
1397 .fold(canonical_base, |acc, c| acc.join(c))
1398 };
1399 if !self
1400 .allowed_paths_canonical
1401 .iter()
1402 .any(|allowed| canonical.starts_with(allowed))
1403 {
1404 return Err(ToolError::SandboxViolation {
1405 path: canonical.display().to_string(),
1406 });
1407 }
1408 }
1409 Ok(())
1410 }
1411
1412 fn validate_sandbox(&self, code: &str) -> Result<(), ToolError> {
1413 let cwd = std::env::current_dir().unwrap_or_default();
1414 self.validate_sandbox_with_cwd(code, &cwd)
1415 }
1416
1417 fn find_blocked_command(&self, code: &str) -> Option<String> {
1457 let snapshot = self.policy.load_full();
1458 let cleaned = strip_shell_escapes(&code.to_lowercase());
1459 let commands = tokenize_commands(&cleaned);
1460 for cmd_tokens in &commands {
1461 let joined = cmd_tokens.join(" ");
1462 if is_blocked_rm_worktrees(&joined) {
1463 return Some("rm --recursive --force .git/worktrees".to_owned());
1464 }
1465 }
1466 for blocked in &snapshot.blocked_commands {
1467 for cmd_tokens in &commands {
1468 if tokens_match_pattern(cmd_tokens, blocked) {
1469 return Some(blocked.clone());
1470 }
1471 }
1472 }
1473 for inner in extract_subshell_contents(&cleaned) {
1475 let inner_commands = tokenize_commands(&inner);
1476 for cmd_tokens in &inner_commands {
1477 let joined = cmd_tokens.join(" ");
1478 if is_blocked_rm_worktrees(&joined) {
1479 return Some("rm --recursive --force .git/worktrees".to_owned());
1480 }
1481 }
1482 for blocked in &snapshot.blocked_commands {
1483 for cmd_tokens in &inner_commands {
1484 if tokens_match_pattern(cmd_tokens, blocked) {
1485 return Some(blocked.clone());
1486 }
1487 }
1488 }
1489 }
1490 None
1491 }
1492
1493 fn find_confirm_command(&self, code: &str) -> Option<&str> {
1494 let normalized = code.to_lowercase();
1495 for pattern in &self.confirm_patterns {
1496 if normalized.contains(pattern.as_str()) {
1497 return Some(pattern.as_str());
1498 }
1499 }
1500 None
1501 }
1502
1503 async fn log_audit(
1504 &self,
1505 command: &str,
1506 result: AuditResult,
1507 duration_ms: u64,
1508 error: Option<&ToolError>,
1509 exit_code: Option<i32>,
1510 truncated: bool,
1511 ) {
1512 if let Some(ref logger) = self.audit_logger {
1513 let (error_category, error_domain, error_phase) =
1514 error.map_or((None, None, None), |e| {
1515 let cat = e.category();
1516 (
1517 Some(cat.label().to_owned()),
1518 Some(cat.domain().label().to_owned()),
1519 Some(cat.phase().label().to_owned()),
1520 )
1521 });
1522 let entry = AuditEntry {
1523 timestamp: chrono_now(),
1524 tool: "shell".into(),
1525 command: command.into(),
1526 result,
1527 duration_ms,
1528 error_category,
1529 error_domain,
1530 error_phase,
1531 claim_source: Some(ClaimSource::Shell),
1532 mcp_server_id: None,
1533 injection_flagged: false,
1534 embedding_anomalous: false,
1535 cross_boundary_mcp_to_acp: false,
1536 adversarial_policy_decision: None,
1537 exit_code,
1538 truncated,
1539 caller_id: None,
1540 policy_match: None,
1541 correlation_id: None,
1542 vigil_risk: None,
1543 execution_env: None,
1544 resolved_cwd: None,
1545 scope_at_definition: None,
1546 scope_at_dispatch: None,
1547 };
1548 logger.log(&entry).await;
1549 }
1550 }
1551
1552 #[allow(clippy::too_many_arguments)]
1553 async fn log_audit_with_context(
1554 &self,
1555 command: &str,
1556 result: AuditResult,
1557 duration_ms: u64,
1558 error: Option<&ToolError>,
1559 exit_code: Option<i32>,
1560 truncated: bool,
1561 resolved: &ResolvedContext,
1562 ) {
1563 if let Some(ref logger) = self.audit_logger {
1564 let (error_category, error_domain, error_phase) =
1565 error.map_or((None, None, None), |e| {
1566 let cat = e.category();
1567 (
1568 Some(cat.label().to_owned()),
1569 Some(cat.domain().label().to_owned()),
1570 Some(cat.phase().label().to_owned()),
1571 )
1572 });
1573 let entry = AuditEntry {
1574 timestamp: chrono_now(),
1575 tool: "shell".into(),
1576 command: command.into(),
1577 result,
1578 duration_ms,
1579 error_category,
1580 error_domain,
1581 error_phase,
1582 claim_source: Some(ClaimSource::Shell),
1583 mcp_server_id: None,
1584 injection_flagged: false,
1585 embedding_anomalous: false,
1586 cross_boundary_mcp_to_acp: false,
1587 adversarial_policy_decision: None,
1588 exit_code,
1589 truncated,
1590 caller_id: None,
1591 policy_match: None,
1592 correlation_id: None,
1593 vigil_risk: None,
1594 execution_env: resolved.name.clone(),
1595 resolved_cwd: Some(resolved.cwd.display().to_string()),
1596 scope_at_definition: None,
1597 scope_at_dispatch: None,
1598 };
1599 logger.log(&entry).await;
1600 }
1601 }
1602}
1603
1604impl ToolExecutor for std::sync::Arc<ShellExecutor> {
1605 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
1606 self.as_ref().execute(response).await
1607 }
1608
1609 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1610 self.as_ref().tool_definitions()
1611 }
1612
1613 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
1614 self.as_ref().execute_tool_call(call).await
1615 }
1616
1617 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
1618 self.as_ref().set_skill_env(env);
1619 }
1620}
1621
1622impl ToolExecutor for ShellExecutor {
1623 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
1624 self.execute_inner(response, false).await
1625 }
1626
1627 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1628 use crate::registry::{InvocationHint, ToolDef};
1629 vec![ToolDef {
1630 id: "bash".into(),
1631 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(),
1632 schema: schemars::schema_for!(BashParams),
1633 invocation: InvocationHint::FencedBlock("bash"),
1634 output_schema: None,
1635 }]
1636 }
1637
1638 #[tracing::instrument(name = "tool.shell.execute_tool_call", skip(self, call), level = "info",
1639 fields(tool_id = %call.tool_id, env = call.context.as_ref().and_then(|c| c.name()).unwrap_or("")))]
1640 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
1641 if call.tool_id != "bash" {
1642 return Ok(None);
1643 }
1644 let params: BashParams = crate::executor::deserialize_params(&call.params)?;
1645 if params.command.is_empty() {
1646 return Ok(None);
1647 }
1648 let command = ¶ms.command;
1649
1650 let resolved = self.resolve_context(call.context.as_ref())?;
1653
1654 if params.background {
1655 let run_id = self
1656 .spawn_background_with_context(command, &resolved)
1657 .await?;
1658 let id_short = &run_id.to_string()[..8];
1659 return Ok(Some(ToolOutput {
1660 tool_name: ToolName::new("bash"),
1661 summary: format!(
1662 "[background] started run_id={run_id} — command: {command}\n\
1663 The command is running in the background. When it completes, \
1664 results will appear at the start of the next turn (run_id_short={id_short})."
1665 ),
1666 blocks_executed: 1,
1667 filter_stats: None,
1668 diff: None,
1669 streamed: true,
1670 terminal_id: None,
1671 locations: None,
1672 raw_response: None,
1673 claim_source: Some(ClaimSource::Shell),
1674 }));
1675 }
1676
1677 self.execute_block_with_context(command, false, &resolved, &call.tool_call_id)
1678 .await
1679 }
1680
1681 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
1682 ShellExecutor::set_skill_env(self, env);
1683 }
1684}
1685
1686impl ShellExecutor {
1687 pub async fn spawn_background(&self, command: &str) -> Result<RunId, ToolError> {
1702 use std::sync::atomic::Ordering;
1703
1704 if self.shutting_down.load(Ordering::Acquire) {
1706 return Err(ToolError::Blocked {
1707 command: command.to_owned(),
1708 });
1709 }
1710
1711 self.check_permissions(command, false).await?;
1713 self.validate_sandbox(command)?;
1714
1715 let run_id = RunId::new();
1717 let mut runs = self.background_runs.lock();
1718 if runs.len() >= self.max_background_runs {
1719 return Err(ToolError::Blocked {
1720 command: format!(
1721 "background run cap reached (max_background_runs={})",
1722 self.max_background_runs
1723 ),
1724 });
1725 }
1726 let abort = CancellationToken::new();
1727 runs.insert(
1728 run_id,
1729 BackgroundHandle {
1730 command: command.to_owned(),
1731 started_at: std::time::Instant::now(),
1732 abort: abort.clone(),
1733 child_pid: None,
1734 },
1735 );
1736 drop(runs);
1737
1738 let tool_event_tx = self.tool_event_tx.clone();
1739 let background_completion_tx = self.background_completion_tx.clone();
1740 let background_runs = Arc::clone(&self.background_runs);
1741 let timeout = self.background_timeout;
1742 let env_blocklist = self.env_blocklist.clone();
1743 let skill_env_snapshot: Option<std::collections::HashMap<String, String>> =
1744 self.skill_env.read().clone();
1745 let command_owned = command.to_owned();
1746
1747 tokio::spawn(run_background_task(
1748 run_id,
1749 command_owned,
1750 timeout,
1751 abort,
1752 background_runs,
1753 tool_event_tx,
1754 background_completion_tx,
1755 skill_env_snapshot,
1756 env_blocklist,
1757 ));
1758
1759 Ok(run_id)
1760 }
1761
1762 async fn spawn_background_with_context(
1771 &self,
1772 command: &str,
1773 resolved: &ResolvedContext,
1774 ) -> Result<RunId, ToolError> {
1775 use std::sync::atomic::Ordering;
1776
1777 if self.shutting_down.load(Ordering::Acquire) {
1778 return Err(ToolError::Blocked {
1779 command: command.to_owned(),
1780 });
1781 }
1782
1783 self.check_permissions(command, false).await?;
1784 self.validate_sandbox_with_cwd(command, &resolved.cwd)?;
1785
1786 let run_id = RunId::new();
1787 let mut runs = self.background_runs.lock();
1788 if runs.len() >= self.max_background_runs {
1789 return Err(ToolError::Blocked {
1790 command: format!(
1791 "background run cap reached (max_background_runs={})",
1792 self.max_background_runs
1793 ),
1794 });
1795 }
1796 let abort = CancellationToken::new();
1797 runs.insert(
1798 run_id,
1799 BackgroundHandle {
1800 command: command.to_owned(),
1801 started_at: std::time::Instant::now(),
1802 abort: abort.clone(),
1803 child_pid: None,
1804 },
1805 );
1806 drop(runs);
1807
1808 let tool_event_tx = self.tool_event_tx.clone();
1809 let background_completion_tx = self.background_completion_tx.clone();
1810 let background_runs = Arc::clone(&self.background_runs);
1811 let timeout = self.background_timeout;
1812 let env = resolved.env.clone();
1813 let cwd = resolved.cwd.clone();
1814 let command_owned = command.to_owned();
1815
1816 tokio::spawn(run_background_task_with_env(
1817 run_id,
1818 command_owned,
1819 timeout,
1820 abort,
1821 background_runs,
1822 tool_event_tx,
1823 background_completion_tx,
1824 env,
1825 cwd,
1826 ));
1827
1828 Ok(run_id)
1829 }
1830
1831 pub async fn shutdown(&self) {
1837 use std::sync::atomic::Ordering;
1838
1839 self.shutting_down.store(true, Ordering::Release);
1840
1841 let handles: Vec<(RunId, String, CancellationToken, Option<u32>)> = {
1842 let runs = self.background_runs.lock();
1843 runs.iter()
1844 .map(|(id, h)| (*id, h.command.clone(), h.abort.clone(), h.child_pid))
1845 .collect()
1846 };
1847
1848 if handles.is_empty() {
1849 return;
1850 }
1851
1852 tracing::info!(
1853 count = handles.len(),
1854 "cancelling background shell runs for shutdown"
1855 );
1856
1857 for (run_id, command, abort, pid_opt) in &handles {
1858 abort.cancel();
1859
1860 #[cfg(unix)]
1861 if let Some(pid) = pid_opt {
1862 send_signal_with_escalation(*pid).await;
1863 }
1864 #[cfg(not(unix))]
1865 let _ = pid_opt;
1866
1867 if let Some(ref tx) = self.tool_event_tx {
1868 let _ = tx
1869 .send(ToolEvent::Completed {
1870 tool_name: ToolName::new("bash"),
1871 command: command.clone(),
1872 output: "[terminated by shutdown]".to_owned(),
1873 success: false,
1874 filter_stats: None,
1875 diff: None,
1876 run_id: Some(*run_id),
1877 })
1878 .await;
1879 }
1880 }
1881
1882 self.background_runs.lock().clear();
1883 }
1884}
1885
1886#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1896async fn run_background_task(
1897 run_id: RunId,
1898 command: String,
1899 timeout: Duration,
1900 abort: CancellationToken,
1901 background_runs: Arc<Mutex<HashMap<RunId, BackgroundHandle>>>,
1902 tool_event_tx: Option<ToolEventTx>,
1903 background_completion_tx: Option<tokio::sync::mpsc::Sender<BackgroundCompletion>>,
1904 skill_env_snapshot: Option<std::collections::HashMap<String, String>>,
1905 env_blocklist: Vec<String>,
1906) {
1907 use std::process::Stdio;
1908
1909 let started_at = std::time::Instant::now();
1910
1911 let mut cmd = build_bash_command(&command, skill_env_snapshot.as_ref(), &env_blocklist);
1916 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
1917
1918 let mut child = match cmd.spawn() {
1919 Ok(c) => c,
1920 Err(ref e) => {
1921 let (_, out) = spawn_error_envelope(e);
1922 background_runs.lock().remove(&run_id);
1923 emit_completed(tool_event_tx.as_ref(), &command, out.clone(), false, run_id).await;
1924 if let Some(ref tx) = background_completion_tx {
1925 let _ = tx
1926 .send(BackgroundCompletion {
1927 run_id,
1928 exit_code: 1,
1929 output: out,
1930 success: false,
1931 elapsed_ms: 0,
1932 command,
1933 })
1934 .await;
1935 }
1936 return;
1937 }
1938 };
1939
1940 if let Some(pid) = child.id()
1942 && let Some(handle) = background_runs.lock().get_mut(&run_id)
1943 {
1944 handle.child_pid = Some(pid);
1945 }
1946
1947 let stdout = child.stdout.take().expect("stdout piped");
1949 let stderr = child.stderr.take().expect("stderr piped");
1950 let mut line_rx = spawn_output_readers(stdout, stderr);
1951
1952 let mut combined = String::new();
1953 let mut stdout_buf = String::new();
1954 let mut stderr_buf = String::new();
1955 let deadline = tokio::time::Instant::now() + timeout;
1956 let timeout_secs = timeout.as_secs();
1957
1958 let (_, out) = match run_bash_stream(
1959 &command,
1960 deadline,
1961 Some(&abort),
1962 tool_event_tx.as_ref(),
1963 "",
1964 &mut line_rx,
1965 &mut combined,
1966 &mut stdout_buf,
1967 &mut stderr_buf,
1968 &mut child,
1969 )
1970 .await
1971 {
1972 BashLoopOutcome::TimedOut => (
1973 ShellOutputEnvelope {
1974 stdout: stdout_buf,
1975 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
1976 exit_code: 1,
1977 truncated: false,
1978 },
1979 format!("[error] command timed out after {timeout_secs}s"),
1980 ),
1981 BashLoopOutcome::Cancelled => (
1982 ShellOutputEnvelope {
1983 stdout: stdout_buf,
1984 stderr: format!("{stderr_buf}operation aborted"),
1985 exit_code: 130,
1986 truncated: false,
1987 },
1988 "[cancelled] operation aborted".to_string(),
1989 ),
1990 BashLoopOutcome::StreamClosed => {
1991 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
1992 }
1993 };
1994
1995 #[allow(clippy::cast_possible_truncation)]
1996 let elapsed_ms = started_at.elapsed().as_millis() as u64;
1997 let success = !out.contains("[error]");
1998 let exit_code = i32::from(!success);
1999 let truncated = crate::executor::truncate_tool_output_at(&out, 4096);
2000
2001 background_runs.lock().remove(&run_id);
2002 emit_completed(
2003 tool_event_tx.as_ref(),
2004 &command,
2005 truncated.clone(),
2006 success,
2007 run_id,
2008 )
2009 .await;
2010
2011 if let Some(ref tx) = background_completion_tx {
2012 let completion = BackgroundCompletion {
2013 run_id,
2014 exit_code,
2015 output: truncated,
2016 success,
2017 elapsed_ms,
2018 command,
2019 };
2020 if tx.send(completion).await.is_err() {
2021 tracing::warn!(
2022 run_id = %run_id,
2023 "background completion channel closed; agent may have shut down"
2024 );
2025 }
2026 }
2027
2028 tracing::debug!(run_id = %run_id, exit_code, elapsed_ms, "background shell run completed");
2029}
2030
2031#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
2034async fn run_background_task_with_env(
2035 run_id: RunId,
2036 command: String,
2037 timeout: Duration,
2038 abort: CancellationToken,
2039 background_runs: Arc<Mutex<HashMap<RunId, BackgroundHandle>>>,
2040 tool_event_tx: Option<ToolEventTx>,
2041 background_completion_tx: Option<tokio::sync::mpsc::Sender<BackgroundCompletion>>,
2042 env: HashMap<String, String>,
2043 cwd: PathBuf,
2044) {
2045 use std::process::Stdio;
2046
2047 let started_at = std::time::Instant::now();
2048
2049 let mut cmd = build_bash_command_with_context(&command, &env, &cwd);
2050 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2051
2052 let mut child = match cmd.spawn() {
2053 Ok(c) => c,
2054 Err(ref e) => {
2055 let (_, out) = spawn_error_envelope(e);
2056 background_runs.lock().remove(&run_id);
2057 emit_completed(tool_event_tx.as_ref(), &command, out.clone(), false, run_id).await;
2058 if let Some(ref tx) = background_completion_tx {
2059 let _ = tx
2060 .send(BackgroundCompletion {
2061 run_id,
2062 exit_code: 1,
2063 output: out,
2064 success: false,
2065 elapsed_ms: 0,
2066 command,
2067 })
2068 .await;
2069 }
2070 return;
2071 }
2072 };
2073
2074 if let Some(pid) = child.id()
2075 && let Some(handle) = background_runs.lock().get_mut(&run_id)
2076 {
2077 handle.child_pid = Some(pid);
2078 }
2079
2080 let stdout = child.stdout.take().expect("stdout piped");
2081 let stderr = child.stderr.take().expect("stderr piped");
2082 let mut line_rx = spawn_output_readers(stdout, stderr);
2083
2084 let mut combined = String::new();
2085 let mut stdout_buf = String::new();
2086 let mut stderr_buf = String::new();
2087 let deadline = tokio::time::Instant::now() + timeout;
2088 let timeout_secs = timeout.as_secs();
2089
2090 let (_, out) = match run_bash_stream(
2091 &command,
2092 deadline,
2093 Some(&abort),
2094 tool_event_tx.as_ref(),
2095 "",
2096 &mut line_rx,
2097 &mut combined,
2098 &mut stdout_buf,
2099 &mut stderr_buf,
2100 &mut child,
2101 )
2102 .await
2103 {
2104 BashLoopOutcome::TimedOut => (
2105 ShellOutputEnvelope {
2106 stdout: stdout_buf,
2107 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2108 exit_code: 1,
2109 truncated: false,
2110 },
2111 format!("[error] command timed out after {timeout_secs}s"),
2112 ),
2113 BashLoopOutcome::Cancelled => (
2114 ShellOutputEnvelope {
2115 stdout: stdout_buf,
2116 stderr: stderr_buf,
2117 exit_code: 130,
2118 truncated: false,
2119 },
2120 "[cancelled] operation aborted".to_string(),
2121 ),
2122 BashLoopOutcome::StreamClosed => {
2123 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2124 }
2125 };
2126
2127 #[allow(clippy::cast_possible_truncation)]
2128 let elapsed_ms = started_at.elapsed().as_millis() as u64;
2129 let success = !out.contains("[error]");
2130 let exit_code = i32::from(!success);
2131 let truncated = crate::executor::truncate_tool_output_at(&out, 4096);
2132
2133 background_runs.lock().remove(&run_id);
2134 emit_completed(
2135 tool_event_tx.as_ref(),
2136 &command,
2137 truncated.clone(),
2138 success,
2139 run_id,
2140 )
2141 .await;
2142
2143 if let Some(ref tx) = background_completion_tx {
2144 let completion = BackgroundCompletion {
2145 run_id,
2146 exit_code,
2147 output: truncated,
2148 success,
2149 elapsed_ms,
2150 command,
2151 };
2152 if tx.send(completion).await.is_err() {
2153 tracing::warn!(
2154 run_id = %run_id,
2155 "background completion channel closed; agent may have shut down"
2156 );
2157 }
2158 }
2159
2160 tracing::debug!(run_id = %run_id, exit_code, elapsed_ms, "background shell run (with context) completed");
2161}
2162
2163async fn emit_completed(
2165 tool_event_tx: Option<&ToolEventTx>,
2166 command: &str,
2167 output: String,
2168 success: bool,
2169 run_id: RunId,
2170) {
2171 if let Some(tx) = tool_event_tx {
2172 let _ = tx
2173 .send(ToolEvent::Completed {
2174 tool_name: ToolName::new("bash"),
2175 command: command.to_owned(),
2176 output,
2177 success,
2178 filter_stats: None,
2179 diff: None,
2180 run_id: Some(run_id),
2181 })
2182 .await;
2183 }
2184}
2185
2186pub(crate) fn strip_shell_escapes(input: &str) -> String {
2190 let mut out = String::with_capacity(input.len());
2191 let bytes = input.as_bytes();
2192 let mut i = 0;
2193 while i < bytes.len() {
2194 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'\'' {
2196 let mut j = i + 2; let mut decoded = String::new();
2198 let mut valid = false;
2199 while j < bytes.len() && bytes[j] != b'\'' {
2200 if bytes[j] == b'\\' && j + 1 < bytes.len() {
2201 let next = bytes[j + 1];
2202 if next == b'x' && j + 3 < bytes.len() {
2203 let hi = (bytes[j + 2] as char).to_digit(16);
2205 let lo = (bytes[j + 3] as char).to_digit(16);
2206 if let (Some(h), Some(l)) = (hi, lo) {
2207 #[allow(clippy::cast_possible_truncation)]
2208 let byte = ((h << 4) | l) as u8;
2209 decoded.push(byte as char);
2210 j += 4;
2211 valid = true;
2212 continue;
2213 }
2214 } else if next.is_ascii_digit() {
2215 let mut val = u32::from(next - b'0');
2217 let mut len = 2; if j + 2 < bytes.len() && bytes[j + 2].is_ascii_digit() {
2219 val = val * 8 + u32::from(bytes[j + 2] - b'0');
2220 len = 3;
2221 if j + 3 < bytes.len() && bytes[j + 3].is_ascii_digit() {
2222 val = val * 8 + u32::from(bytes[j + 3] - b'0');
2223 len = 4;
2224 }
2225 }
2226 #[allow(clippy::cast_possible_truncation)]
2227 decoded.push((val & 0xFF) as u8 as char);
2228 j += len;
2229 valid = true;
2230 continue;
2231 }
2232 decoded.push(next as char);
2234 j += 2;
2235 } else {
2236 decoded.push(bytes[j] as char);
2237 j += 1;
2238 }
2239 }
2240 if j < bytes.len() && bytes[j] == b'\'' && valid {
2241 out.push_str(&decoded);
2242 i = j + 1;
2243 continue;
2244 }
2245 }
2247 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
2249 i += 2;
2250 continue;
2251 }
2252 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] != b'\n' {
2254 i += 1;
2255 out.push(bytes[i] as char);
2256 i += 1;
2257 continue;
2258 }
2259 if bytes[i] == b'"' || bytes[i] == b'\'' {
2261 let quote = bytes[i];
2262 i += 1;
2263 while i < bytes.len() && bytes[i] != quote {
2264 out.push(bytes[i] as char);
2265 i += 1;
2266 }
2267 if i < bytes.len() {
2268 i += 1; }
2270 continue;
2271 }
2272 out.push(bytes[i] as char);
2273 i += 1;
2274 }
2275 out
2276}
2277
2278pub(crate) fn extract_subshell_contents(s: &str) -> Vec<String> {
2288 let mut results = Vec::new();
2289 let chars: Vec<char> = s.chars().collect();
2290 let len = chars.len();
2291 let mut i = 0;
2292
2293 while i < len {
2294 if chars[i] == '`' {
2296 let start = i + 1;
2297 let mut j = start;
2298 while j < len && chars[j] != '`' {
2299 j += 1;
2300 }
2301 if j < len {
2302 results.push(chars[start..j].iter().collect());
2303 }
2304 i = j + 1;
2305 continue;
2306 }
2307
2308 let next_is_open_paren = i + 1 < len && chars[i + 1] == '(';
2310 let is_paren_subshell = next_is_open_paren && matches!(chars[i], '$' | '<' | '>');
2311
2312 if is_paren_subshell {
2313 let start = i + 2;
2314 let mut depth: usize = 1;
2315 let mut j = start;
2316 while j < len && depth > 0 {
2317 match chars[j] {
2318 '(' => depth += 1,
2319 ')' => depth -= 1,
2320 _ => {}
2321 }
2322 if depth > 0 {
2323 j += 1;
2324 } else {
2325 break;
2326 }
2327 }
2328 if depth == 0 {
2329 results.push(chars[start..j].iter().collect());
2330 }
2331 i = j + 1;
2332 continue;
2333 }
2334
2335 i += 1;
2336 }
2337
2338 results
2339}
2340
2341pub(crate) fn tokenize_commands(normalized: &str) -> Vec<Vec<String>> {
2344 let replaced = normalized.replace("||", "\n").replace("&&", "\n");
2346 replaced
2347 .split([';', '|', '\n'])
2348 .map(|seg| {
2349 seg.split_whitespace()
2350 .map(str::to_owned)
2351 .collect::<Vec<String>>()
2352 })
2353 .filter(|tokens| !tokens.is_empty())
2354 .collect()
2355}
2356
2357const TRANSPARENT_PREFIXES: &[&str] = &["env", "command", "exec", "nice", "nohup", "time", "xargs"];
2360
2361fn cmd_basename(tok: &str) -> &str {
2363 tok.rsplit('/').next().unwrap_or(tok)
2364}
2365
2366pub(crate) fn tokens_match_pattern(tokens: &[String], pattern: &str) -> bool {
2373 if tokens.is_empty() || pattern.is_empty() {
2374 return false;
2375 }
2376 let pattern = pattern.trim();
2377 let pattern_tokens: Vec<&str> = pattern.split_whitespace().collect();
2378 if pattern_tokens.is_empty() {
2379 return false;
2380 }
2381
2382 let start = tokens
2384 .iter()
2385 .position(|t| !TRANSPARENT_PREFIXES.contains(&cmd_basename(t)))
2386 .unwrap_or(0);
2387 let effective = &tokens[start..];
2388 if effective.is_empty() {
2389 return false;
2390 }
2391
2392 if pattern_tokens.len() == 1 {
2393 let pat = pattern_tokens[0];
2394 let base = cmd_basename(&effective[0]);
2395 base == pat || base.starts_with(&format!("{pat}."))
2397 } else {
2398 let n = pattern_tokens.len().min(effective.len());
2400 let mut parts: Vec<&str> = vec![cmd_basename(&effective[0])];
2401 parts.extend(effective[1..n].iter().map(String::as_str));
2402 let joined = parts.join(" ");
2403 if joined.starts_with(pattern) {
2404 return true;
2405 }
2406 if effective.len() > n {
2407 let mut parts2: Vec<&str> = vec![cmd_basename(&effective[0])];
2408 parts2.extend(effective[1..=n].iter().map(String::as_str));
2409 parts2.join(" ").starts_with(pattern)
2410 } else {
2411 false
2412 }
2413 }
2414}
2415
2416fn extract_paths(code: &str) -> Vec<String> {
2417 let mut result = Vec::new();
2418
2419 let mut tokens: Vec<String> = Vec::new();
2421 let mut current = String::new();
2422 let mut chars = code.chars().peekable();
2423 while let Some(c) = chars.next() {
2424 match c {
2425 '"' | '\'' => {
2426 let quote = c;
2427 while let Some(&nc) = chars.peek() {
2428 if nc == quote {
2429 chars.next();
2430 break;
2431 }
2432 current.push(chars.next().unwrap());
2433 }
2434 }
2435 c if c.is_whitespace() || matches!(c, ';' | '|' | '&') => {
2436 if !current.is_empty() {
2437 tokens.push(std::mem::take(&mut current));
2438 }
2439 }
2440 _ => current.push(c),
2441 }
2442 }
2443 if !current.is_empty() {
2444 tokens.push(current);
2445 }
2446
2447 for token in tokens {
2448 let trimmed = token.trim_end_matches([';', '&', '|']).to_owned();
2449 if trimmed.is_empty() {
2450 continue;
2451 }
2452 if trimmed.starts_with('/')
2453 || trimmed.starts_with("./")
2454 || trimmed.starts_with("../")
2455 || trimmed == ".."
2456 || (trimmed.starts_with('.') && trimmed.contains('/'))
2457 || is_relative_path_token(&trimmed)
2458 {
2459 result.push(trimmed);
2460 }
2461 }
2462 result
2463}
2464
2465fn is_relative_path_token(token: &str) -> bool {
2472 if !token.contains('/') || token.starts_with('/') || token.starts_with('.') {
2474 return false;
2475 }
2476 if token.contains("://") {
2478 return false;
2479 }
2480 if let Some(eq_pos) = token.find('=') {
2482 let key = &token[..eq_pos];
2483 if key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
2484 return false;
2485 }
2486 }
2487 token
2489 .chars()
2490 .next()
2491 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
2492}
2493
2494fn classify_shell_exit(
2500 exit_code: i32,
2501 output: &str,
2502) -> Option<crate::error_taxonomy::ToolErrorCategory> {
2503 use crate::error_taxonomy::ToolErrorCategory;
2504 match exit_code {
2505 126 => Some(ToolErrorCategory::PolicyBlocked),
2507 127 => Some(ToolErrorCategory::PermanentFailure),
2509 _ => {
2510 let lower = output.to_lowercase();
2511 if lower.contains("permission denied") {
2512 Some(ToolErrorCategory::PolicyBlocked)
2513 } else if lower.contains("no such file or directory") {
2514 Some(ToolErrorCategory::PermanentFailure)
2515 } else {
2516 None
2517 }
2518 }
2519 }
2520}
2521
2522fn has_traversal(path: &str) -> bool {
2523 path.split('/').any(|seg| seg == "..")
2524}
2525
2526fn extract_bash_blocks(text: &str) -> Vec<&str> {
2527 crate::executor::extract_fenced_blocks(text, "bash")
2528}
2529
2530#[cfg(unix)]
2546async fn send_signal_with_escalation(pid: u32) {
2547 use nix::errno::Errno;
2548 use nix::sys::signal::{Signal, kill};
2549 use nix::unistd::Pid;
2550
2551 let Ok(pid_i32) = i32::try_from(pid) else {
2552 return;
2553 };
2554 let target = Pid::from_raw(pid_i32);
2555
2556 if let Err(e) = kill(target, Signal::SIGTERM)
2557 && e != Errno::ESRCH
2558 {
2559 tracing::debug!(pid, err = %e, "SIGTERM failed");
2560 }
2561 tokio::time::sleep(GRACEFUL_TERM_MS).await;
2562 let _ = Command::new("pkill")
2564 .args(["-KILL", "-P", &pid.to_string()])
2565 .status()
2566 .await;
2567 if let Err(e) = kill(target, Signal::SIGKILL)
2568 && e != Errno::ESRCH
2569 {
2570 tracing::debug!(pid, err = %e, "SIGKILL failed");
2571 }
2572}
2573
2574async fn kill_process_tree(child: &mut tokio::process::Child) {
2580 #[cfg(unix)]
2581 if let Some(pid) = child.id() {
2582 send_signal_with_escalation(pid).await;
2583 }
2584 let _ = child.kill().await;
2585}
2586
2587#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2592pub struct ShellOutputEnvelope {
2593 pub stdout: String,
2595 pub stderr: String,
2597 pub exit_code: i32,
2599 pub truncated: bool,
2601}
2602
2603#[allow(dead_code, clippy::too_many_arguments)]
2605async fn execute_bash(
2606 code: &str,
2607 timeout: Duration,
2608 event_tx: Option<&ToolEventTx>,
2609 cancel_token: Option<&CancellationToken>,
2610 extra_env: Option<&std::collections::HashMap<String, String>>,
2611 env_blocklist: &[String],
2612 sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2613 tool_call_id: &str,
2614) -> (ShellOutputEnvelope, String) {
2615 use std::process::Stdio;
2616
2617 let timeout_secs = timeout.as_secs();
2618 let mut cmd = build_bash_command(code, extra_env, env_blocklist);
2619
2620 if let Err(envelope_err) = apply_sandbox(&mut cmd, sandbox) {
2621 return envelope_err;
2622 }
2623
2624 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2625
2626 let mut child = match cmd.spawn() {
2627 Ok(c) => c,
2628 Err(ref e) => return spawn_error_envelope(e),
2629 };
2630
2631 let stdout = child.stdout.take().expect("stdout piped");
2632 let stderr = child.stderr.take().expect("stderr piped");
2633 let mut line_rx = spawn_output_readers(stdout, stderr);
2634
2635 let mut combined = String::new();
2636 let mut stdout_buf = String::new();
2637 let mut stderr_buf = String::new();
2638 let deadline = tokio::time::Instant::now() + timeout;
2639
2640 match run_bash_stream(
2641 code,
2642 deadline,
2643 cancel_token,
2644 event_tx,
2645 tool_call_id,
2646 &mut line_rx,
2647 &mut combined,
2648 &mut stdout_buf,
2649 &mut stderr_buf,
2650 &mut child,
2651 )
2652 .await
2653 {
2654 BashLoopOutcome::TimedOut => {
2655 let msg = format!("[error] command timed out after {timeout_secs}s");
2656 (
2657 ShellOutputEnvelope {
2658 stdout: stdout_buf,
2659 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2660 exit_code: 1,
2661 truncated: false,
2662 },
2663 msg,
2664 )
2665 }
2666 BashLoopOutcome::Cancelled => (
2667 ShellOutputEnvelope {
2668 stdout: stdout_buf,
2669 stderr: format!("{stderr_buf}operation aborted"),
2670 exit_code: 130,
2671 truncated: false,
2672 },
2673 "[cancelled] operation aborted".to_string(),
2674 ),
2675 BashLoopOutcome::StreamClosed => {
2676 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2677 }
2678 }
2679}
2680
2681fn build_bash_command(
2682 code: &str,
2683 extra_env: Option<&std::collections::HashMap<String, String>>,
2684 env_blocklist: &[String],
2685) -> Command {
2686 let mut cmd = Command::new("bash");
2687 cmd.arg("-c").arg(code);
2688 for (key, _) in std::env::vars() {
2689 if env_blocklist
2690 .iter()
2691 .any(|prefix| key.starts_with(prefix.as_str()))
2692 {
2693 cmd.env_remove(&key);
2694 }
2695 }
2696 if let Some(env) = extra_env {
2697 cmd.envs(env);
2698 }
2699 cmd
2700}
2701
2702fn build_bash_command_with_context(
2707 code: &str,
2708 resolved_env: &HashMap<String, String>,
2709 cwd: &std::path::Path,
2710) -> Command {
2711 let mut cmd = Command::new("bash");
2712 cmd.arg("-c").arg(code);
2713 cmd.env_clear();
2714 cmd.envs(resolved_env);
2715 cmd.current_dir(cwd);
2716 cmd
2717}
2718
2719async fn execute_bash_with_context(
2724 code: &str,
2725 timeout: Duration,
2726 event_tx: Option<&ToolEventTx>,
2727 tool_call_id: &str,
2728 cancel_token: Option<&CancellationToken>,
2729 resolved: &ResolvedContext,
2730 sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2731) -> (ShellOutputEnvelope, String) {
2732 use std::process::Stdio;
2733
2734 let timeout_secs = timeout.as_secs();
2735 let mut cmd = build_bash_command_with_context(code, &resolved.env, &resolved.cwd);
2736
2737 if let Err(envelope_err) = apply_sandbox(&mut cmd, sandbox) {
2738 return envelope_err;
2739 }
2740
2741 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2742
2743 let mut child = match cmd.spawn() {
2744 Ok(c) => c,
2745 Err(ref e) => return spawn_error_envelope(e),
2746 };
2747
2748 let stdout = child.stdout.take().expect("stdout piped");
2749 let stderr = child.stderr.take().expect("stderr piped");
2750 let mut line_rx = spawn_output_readers(stdout, stderr);
2751
2752 let mut combined = String::new();
2753 let mut stdout_buf = String::new();
2754 let mut stderr_buf = String::new();
2755 let deadline = tokio::time::Instant::now() + timeout;
2756
2757 match run_bash_stream(
2758 code,
2759 deadline,
2760 cancel_token,
2761 event_tx,
2762 tool_call_id,
2763 &mut line_rx,
2764 &mut combined,
2765 &mut stdout_buf,
2766 &mut stderr_buf,
2767 &mut child,
2768 )
2769 .await
2770 {
2771 BashLoopOutcome::TimedOut => {
2772 let msg = format!("[error] command timed out after {timeout_secs}s");
2773 (
2774 ShellOutputEnvelope {
2775 stdout: stdout_buf,
2776 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2777 exit_code: 1,
2778 truncated: false,
2779 },
2780 msg,
2781 )
2782 }
2783 BashLoopOutcome::Cancelled => (
2784 ShellOutputEnvelope {
2785 stdout: stdout_buf,
2786 stderr: format!("{stderr_buf}operation aborted"),
2787 exit_code: 130,
2788 truncated: false,
2789 },
2790 "[cancelled] operation aborted".to_string(),
2791 ),
2792 BashLoopOutcome::StreamClosed => {
2793 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2794 }
2795 }
2796}
2797
2798fn apply_sandbox(
2799 cmd: &mut Command,
2800 sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2801) -> Result<(), (ShellOutputEnvelope, String)> {
2802 if let Some((sb, policy)) = sandbox
2804 && let Err(err) = sb.wrap(cmd, policy)
2805 {
2806 let msg = format!("[error] sandbox setup failed: {err}");
2807 return Err((
2808 ShellOutputEnvelope {
2809 stdout: String::new(),
2810 stderr: msg.clone(),
2811 exit_code: 1,
2812 truncated: false,
2813 },
2814 msg,
2815 ));
2816 }
2817 Ok(())
2818}
2819
2820fn spawn_error_envelope(e: &std::io::Error) -> (ShellOutputEnvelope, String) {
2821 let msg = format!("[error] {e}");
2822 (
2823 ShellOutputEnvelope {
2824 stdout: String::new(),
2825 stderr: msg.clone(),
2826 exit_code: 1,
2827 truncated: false,
2828 },
2829 msg,
2830 )
2831}
2832
2833fn spawn_output_readers(
2836 stdout: tokio::process::ChildStdout,
2837 stderr: tokio::process::ChildStderr,
2838) -> tokio::sync::mpsc::Receiver<(bool, String)> {
2839 use tokio::io::{AsyncBufReadExt, BufReader};
2840
2841 let (line_tx, line_rx) = tokio::sync::mpsc::channel::<(bool, String)>(64);
2842
2843 let stdout_tx = line_tx.clone();
2844 tokio::spawn(async move {
2845 let mut reader = BufReader::new(stdout);
2846 let mut buf = String::new();
2847 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
2848 let _ = stdout_tx.send((false, buf.clone())).await;
2849 buf.clear();
2850 }
2851 });
2852
2853 tokio::spawn(async move {
2854 let mut reader = BufReader::new(stderr);
2855 let mut buf = String::new();
2856 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
2857 let _ = line_tx.send((true, buf.clone())).await;
2858 buf.clear();
2859 }
2860 });
2861
2862 line_rx
2863}
2864
2865enum BashLoopOutcome {
2870 StreamClosed,
2871 TimedOut,
2872 Cancelled,
2873}
2874
2875#[allow(clippy::too_many_arguments)]
2876async fn run_bash_stream(
2877 code: &str,
2878 deadline: tokio::time::Instant,
2879 cancel_token: Option<&CancellationToken>,
2880 event_tx: Option<&ToolEventTx>,
2881 tool_call_id: &str,
2882 line_rx: &mut tokio::sync::mpsc::Receiver<(bool, String)>,
2883 combined: &mut String,
2884 stdout_buf: &mut String,
2885 stderr_buf: &mut String,
2886 child: &mut tokio::process::Child,
2887) -> BashLoopOutcome {
2888 loop {
2889 tokio::select! {
2890 line = line_rx.recv() => {
2891 match line {
2892 Some((is_stderr, chunk)) => {
2893 let interleaved = if is_stderr {
2894 format!("[stderr] {chunk}")
2895 } else {
2896 chunk.clone()
2897 };
2898 if let Some(tx) = event_tx {
2899 let _ = tx.try_send(ToolEvent::OutputChunk {
2901 tool_name: ToolName::new("bash"),
2902 command: code.to_owned(),
2903 chunk: interleaved.clone(),
2904 tool_call_id: tool_call_id.to_owned(),
2905 });
2906 }
2907 combined.push_str(&interleaved);
2908 if is_stderr {
2909 stderr_buf.push_str(&chunk);
2910 } else {
2911 stdout_buf.push_str(&chunk);
2912 }
2913 }
2914 None => return BashLoopOutcome::StreamClosed,
2915 }
2916 }
2917 () = tokio::time::sleep_until(deadline) => {
2918 kill_process_tree(child).await;
2919 return BashLoopOutcome::TimedOut;
2920 }
2921 () = async {
2922 match cancel_token {
2923 Some(t) => t.cancelled().await,
2924 None => std::future::pending().await,
2925 }
2926 } => {
2927 kill_process_tree(child).await;
2928 return BashLoopOutcome::Cancelled;
2929 }
2930 }
2931 }
2932}
2933
2934async fn finalize_envelope(
2935 child: &mut tokio::process::Child,
2936 combined: String,
2937 stdout_buf: String,
2938 stderr_buf: String,
2939) -> (ShellOutputEnvelope, String) {
2940 let status = child.wait().await;
2941 let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(1);
2942
2943 if combined.is_empty() {
2944 (
2945 ShellOutputEnvelope {
2946 stdout: String::new(),
2947 stderr: String::new(),
2948 exit_code,
2949 truncated: false,
2950 },
2951 "(no output)".to_string(),
2952 )
2953 } else {
2954 (
2955 ShellOutputEnvelope {
2956 stdout: stdout_buf.trim_end().to_owned(),
2957 stderr: stderr_buf.trim_end().to_owned(),
2958 exit_code,
2959 truncated: false,
2960 },
2961 combined,
2962 )
2963 }
2964}
2965
2966#[cfg(test)]
2967mod tests;