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::security::is_path_within;
39use zeph_common::{TaskSupervisor, ToolName};
40
41use crate::audit::{AuditEntry, AuditLogger, AuditResult, chrono_now};
42use crate::config::ShellConfig;
43use crate::execution_context::ExecutionContext;
44use crate::executor::{
45 ClaimSource, FilterStats, ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput,
46};
47use crate::filter::{OutputFilterRegistry, sanitize_output};
48use crate::permissions::{PermissionAction, PermissionPolicy};
49use crate::sandbox::{Sandbox, SandboxPolicy};
50
51pub mod background;
52pub use background::BackgroundRunSnapshot;
53use background::{BackgroundCompletion, BackgroundHandle, RunId};
54
55pub mod deobfuscate;
56pub use deobfuscate::deobfuscate as deobfuscate_command;
57
58pub mod safe_fix;
59pub use safe_fix::SafeFixSuggestion;
60
61mod checkpoint;
62use checkpoint::{Checkpoint, CheckpointStack};
63
64mod transaction;
65use transaction::{TransactionSnapshot, affected_paths, build_scope_matchers, is_write_command};
66
67use crate::risk_chain::RiskChainAccumulator;
68
69const DEFAULT_BLOCKED: &[&str] = &[
70 "sudo", "mkfs", "dd if=", "curl", "wget", "nc ", "ncat", "netcat", "shutdown", "reboot", "halt",
71];
72
73fn rm_recursive_force_flags(tokens: &[&str]) -> (bool, bool) {
77 let mut has_recursive = false;
78 let mut has_force = false;
79
80 for token in tokens {
81 if *token == "--recursive" {
82 has_recursive = true;
83 } else if *token == "--force" {
84 has_force = true;
85 } else if let Some(flags) = token.strip_prefix('-').filter(|f| !f.starts_with('-')) {
86 if flags.contains('r') || flags.contains('R') {
88 has_recursive = true;
89 }
90 if flags.contains('f') {
91 has_force = true;
92 }
93 }
94 }
95
96 (has_recursive, has_force)
97}
98
99#[must_use]
117pub fn is_blocked_rm_worktrees(cmd: &str) -> bool {
118 let lower = cmd.to_lowercase();
119 let tokens: Vec<&str> = lower.split_whitespace().collect();
120
121 let Some(first) = tokens.first() else {
123 return false;
124 };
125 if first.rsplit('/').next().unwrap_or(first) != "rm" {
126 return false;
127 }
128
129 if !lower.contains(".git/worktrees") {
130 return false;
131 }
132
133 let (has_recursive, has_force) = rm_recursive_force_flags(&tokens[1..]);
134 has_recursive && has_force
135}
136
137fn is_literal_root_or_home_target(token: &str) -> bool {
145 let trimmed = token.trim_matches(|c| c == '\'' || c == '"');
146 matches!(
147 trimmed,
148 "/" | "~" | "~/" | "$home" | "${home}" | "$home/" | "${home}/"
149 )
150}
151
152#[must_use]
178pub fn is_blocked_rm_root_or_home(cmd: &str) -> bool {
179 let lower = cmd.to_lowercase();
180 let tokens: Vec<&str> = lower.split_whitespace().collect();
181
182 let Some(first) = tokens.first() else {
183 return false;
184 };
185 if first.rsplit('/').next().unwrap_or(first) != "rm" {
186 return false;
187 }
188
189 let rest = &tokens[1..];
190 let (has_recursive, has_force) = rm_recursive_force_flags(rest);
191 if !has_recursive && !has_force {
192 return false;
193 }
194
195 rest.iter().any(|token| {
196 if is_literal_root_or_home_target(token) {
197 return true;
198 }
199 has_recursive && has_force && token.starts_with('/')
200 })
201}
202
203#[cfg(unix)]
205const GRACEFUL_TERM_MS: Duration = Duration::from_millis(250);
206
207pub const DEFAULT_BLOCKED_COMMANDS: &[&str] = DEFAULT_BLOCKED;
222
223pub const SHELL_INTERPRETERS: &[&str] =
229 &["bash", "sh", "zsh", "fish", "dash", "ksh", "csh", "tcsh"];
230
231const SUBSHELL_METACHARS: &[&str] = &["$(", "`", "<(", ">("];
235
236fn match_blocklist_tokens(cleaned: &str, blocklist: &[String]) -> Option<String> {
245 let commands = tokenize_commands(cleaned);
246 for cmd_tokens in &commands {
247 let joined = cmd_tokens.join(" ");
248 if is_blocked_rm_worktrees(&joined) {
249 return Some("rm --recursive --force .git/worktrees".to_owned());
250 }
251 if is_blocked_rm_root_or_home(&joined) {
252 return Some("rm -rf / (recursive/force targeting root, ~, or $HOME)".to_owned());
253 }
254 }
255 for blocked in blocklist {
256 if EMBEDDED_SUBSTRING_PATTERNS.contains(&blocked.as_str()) {
257 if cleaned.contains(blocked.as_str()) {
258 return Some(blocked.clone());
259 }
260 continue;
261 }
262 for cmd_tokens in &commands {
263 if tokens_match_pattern(cmd_tokens, blocked) {
264 return Some(blocked.clone());
265 }
266 }
267 }
268 None
269}
270
271#[must_use]
279pub fn check_blocklist(command: &str, blocklist: &[String]) -> Option<String> {
280 let lower = command.to_lowercase();
281 for meta in SUBSHELL_METACHARS {
283 if lower.contains(meta) {
284 return Some((*meta).to_owned());
285 }
286 }
287 let cleaned = strip_shell_escapes(&lower);
288 match_blocklist_tokens(&cleaned, blocklist)
289}
290
291#[must_use]
296pub fn effective_shell_command<'a>(binary: &str, args: &'a [String]) -> Option<&'a str> {
297 let base = binary.rsplit('/').next().unwrap_or(binary);
298 if !SHELL_INTERPRETERS.contains(&base) {
299 return None;
300 }
301 let pos = args.iter().position(|a| a == "-c")?;
303 args.get(pos + 1).map(String::as_str)
304}
305
306pub const NETWORK_COMMANDS: &[&str] = &[
334 "curl",
335 "wget",
336 "nc ",
337 "ncat",
338 "netcat",
339 "ssh",
340 "scp",
341 "rsync",
342 "openssl s_client",
343 "socat",
344 "python3 -c",
345 "python -c",
346 "perl -e",
347 "ruby -e",
348 "/dev/tcp",
349 "/dev/udp",
350];
351
352const EMBEDDED_SUBSTRING_PATTERNS: &[&str] = &["/dev/tcp", "/dev/udp"];
366
367#[derive(Debug)]
371pub(crate) struct ShellPolicy {
372 pub(crate) blocked_commands: Vec<String>,
373}
374
375#[derive(Clone, Debug)]
382pub struct ShellPolicyHandle {
383 inner: Arc<ArcSwap<ShellPolicy>>,
384}
385
386impl ShellPolicyHandle {
387 pub fn rebuild(&self, config: &crate::config::ShellConfig) {
396 let policy = Arc::new(ShellPolicy {
397 blocked_commands: compute_blocked_commands(config),
398 });
399 self.inner.store(policy);
400 }
401
402 #[must_use]
404 pub fn snapshot_blocked(&self) -> Vec<String> {
405 self.inner.load().blocked_commands.clone()
406 }
407
408 #[must_use]
416 pub fn new_shared(config: &crate::config::ShellConfig) -> Self {
417 Self {
418 inner: Arc::new(ArcSwap::from_pointee(ShellPolicy {
419 blocked_commands: compute_blocked_commands(config),
420 })),
421 }
422 }
423}
424
425pub(crate) fn compute_blocked_commands(config: &crate::config::ShellConfig) -> Vec<String> {
429 let allowed: Vec<String> = config
430 .allowed_commands
431 .iter()
432 .map(|s| s.to_lowercase())
433 .collect();
434 let mut blocked: Vec<String> = DEFAULT_BLOCKED
435 .iter()
436 .filter(|s| !allowed.contains(&s.to_lowercase()))
437 .map(|s| (*s).to_owned())
438 .collect();
439 blocked.extend(config.blocked_commands.iter().map(|s| s.to_lowercase()));
440 if !config.allow_network {
441 for cmd in NETWORK_COMMANDS {
442 let lower = cmd.to_lowercase();
443 if !blocked.contains(&lower) {
444 blocked.push(lower);
445 }
446 }
447 }
448 blocked.sort();
449 blocked.dedup();
450 blocked
451}
452
453#[derive(Deserialize, JsonSchema)]
454pub(crate) struct BashParams {
455 command: String,
457 #[serde(default)]
463 background: bool,
464}
465
466#[derive(Debug)]
489#[allow(clippy::struct_excessive_bools)]
490pub struct ShellExecutor {
491 timeout: Duration,
492 policy: Arc<ArcSwap<ShellPolicy>>,
493 confirm_patterns: Vec<String>,
494 env_blocklist: Vec<String>,
495 audit_logger: Option<Arc<AuditLogger>>,
496 tool_event_tx: Option<ToolEventTx>,
497 permission_policy: Option<PermissionPolicy>,
498 output_filter_registry: Option<OutputFilterRegistry>,
499 cancel_token: Option<CancellationToken>,
500 skill_env: RwLock<Option<std::collections::HashMap<String, String>>>,
501 transactional: bool,
502 auto_rollback: bool,
503 auto_rollback_exit_codes: Vec<i32>,
504 snapshot_required: bool,
505 max_snapshot_bytes: u64,
506 transaction_scope_matchers: Vec<globset::GlobMatcher>,
507 checkpoint_stack: Arc<Mutex<CheckpointStack>>,
509 checkpoints_enabled: bool,
511 sandbox: Option<Arc<dyn Sandbox>>,
512 sandbox_policy: Option<SandboxPolicy>,
513 background_runs: Arc<Mutex<HashMap<RunId, BackgroundHandle>>>,
515 max_background_runs: usize,
517 background_timeout: Duration,
519 shutting_down: Arc<AtomicBool>,
521 background_completion_tx: Option<tokio::sync::mpsc::Sender<BackgroundCompletion>>,
525 environments: Arc<HashMap<String, ExecutionContext>>,
528 allowed_paths_canonical: Vec<PathBuf>,
531 default_env: Option<String>,
533 risk_chain: Option<Arc<RiskChainAccumulator>>,
535 risk_chain_threshold: f32,
537 task_supervisor: Option<DebugIgnored<TaskSupervisor>>,
542}
543
544struct DebugIgnored<T>(T);
549
550impl<T> std::fmt::Debug for DebugIgnored<T> {
551 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552 f.write_str("<...>")
553 }
554}
555
556impl<T> std::ops::Deref for DebugIgnored<T> {
557 type Target = T;
558 fn deref(&self) -> &T {
559 &self.0
560 }
561}
562
563#[derive(Debug)]
569pub(crate) struct ResolvedContext {
570 pub(crate) cwd: PathBuf,
572 pub(crate) env: HashMap<String, String>,
574 pub(crate) name: Option<String>,
576 #[allow(dead_code)]
579 pub(crate) trusted: bool,
580}
581
582impl ShellExecutor {
583 #[must_use]
589 pub fn new(config: &ShellConfig) -> Self {
590 let policy = Arc::new(ArcSwap::from_pointee(ShellPolicy {
591 blocked_commands: compute_blocked_commands(config),
592 }));
593
594 let allowed_paths: Vec<PathBuf> = if config.allowed_paths.is_empty() {
595 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
596 } else {
597 config.allowed_paths.iter().map(PathBuf::from).collect()
598 };
599 let allowed_paths_canonical: Vec<PathBuf> = allowed_paths
600 .iter()
601 .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
602 .collect();
603
604 Self {
605 timeout: Duration::from_secs(config.timeout),
606 policy,
607 confirm_patterns: config.confirm_patterns.clone(),
608 env_blocklist: config.env_blocklist.clone(),
609 audit_logger: None,
610 tool_event_tx: None,
611 permission_policy: None,
612 output_filter_registry: None,
613 cancel_token: None,
614 skill_env: RwLock::new(None),
615 transactional: config.transactional,
616 auto_rollback: config.auto_rollback,
617 auto_rollback_exit_codes: config.auto_rollback_exit_codes.clone(),
618 snapshot_required: config.snapshot_required,
619 max_snapshot_bytes: config.max_snapshot_bytes,
620 transaction_scope_matchers: build_scope_matchers(&config.transaction_scope),
621 checkpoint_stack: Arc::new(Mutex::new(CheckpointStack::new(config.max_checkpoints))),
622 checkpoints_enabled: config.checkpoints_enabled,
623 sandbox: None,
624 sandbox_policy: None,
625 background_runs: Arc::new(Mutex::new(HashMap::new())),
626 max_background_runs: config.max_background_runs,
627 background_timeout: Duration::from_secs(config.background_timeout_secs),
628 shutting_down: Arc::new(AtomicBool::new(false)),
629 background_completion_tx: None,
630 environments: Arc::new(HashMap::new()),
631 allowed_paths_canonical,
632 default_env: None,
633 risk_chain: None,
634 risk_chain_threshold: config.risk_chain_threshold.unwrap_or(0.7),
635 task_supervisor: None::<DebugIgnored<TaskSupervisor>>,
636 }
637 }
638
639 #[must_use]
644 pub fn with_sandbox(mut self, sandbox: Arc<dyn Sandbox>, policy: SandboxPolicy) -> Self {
645 self.sandbox = Some(sandbox);
646 self.sandbox_policy = Some(policy);
647 self
648 }
649
650 #[must_use]
655 pub fn with_risk_chain(mut self, accumulator: Arc<RiskChainAccumulator>) -> Self {
656 self.risk_chain = Some(accumulator);
657 self
658 }
659
660 #[must_use]
669 pub fn with_shared_policy(mut self, handle: &ShellPolicyHandle) -> Self {
670 self.policy = Arc::clone(&handle.inner);
671 self
672 }
673
674 pub fn with_execution_config(
685 self,
686 config: &zeph_config::ExecutionConfig,
687 ) -> Result<Self, String> {
688 let registry: HashMap<String, ExecutionContext> = config
689 .environments
690 .iter()
691 .map(|e| {
692 let ctx = ExecutionContext::trusted_from_parts(
693 Some(e.name.clone()),
694 Some(std::path::PathBuf::from(&e.cwd)),
695 e.env.clone(),
696 );
697 (e.name.clone(), ctx)
698 })
699 .collect();
700 self.with_environments(registry, config.default_env.clone())
701 }
702
703 pub fn with_environments(
713 mut self,
714 environments: HashMap<String, ExecutionContext>,
715 default_env: Option<String>,
716 ) -> Result<Self, String> {
717 for (name, ctx) in &environments {
719 if let Some(cwd) = ctx.cwd() {
720 let canonical = cwd.canonicalize().map_err(|e| {
721 format!(
722 "execution environment '{name}': cwd '{}' cannot be canonicalized: {e}",
723 cwd.display()
724 )
725 })?;
726 if !self
727 .allowed_paths_canonical
728 .iter()
729 .any(|p| canonical.starts_with(p))
730 {
731 return Err(format!(
732 "execution environment '{name}': cwd '{}' is outside allowed_paths",
733 cwd.display()
734 ));
735 }
736 }
737 }
738 self.environments = Arc::new(environments);
739 self.default_env = default_env;
740 Ok(self)
741 }
742
743 pub fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
745 *self.skill_env.write() = env;
746 }
747
748 #[must_use]
750 pub fn with_audit(mut self, logger: Arc<AuditLogger>) -> Self {
751 self.audit_logger = Some(logger);
752 self
753 }
754
755 #[must_use]
760 pub fn with_tool_event_tx(mut self, tx: ToolEventTx) -> Self {
761 self.tool_event_tx = Some(tx);
762 self
763 }
764
765 #[must_use]
771 pub fn with_background_completion_tx(
772 mut self,
773 tx: tokio::sync::mpsc::Sender<BackgroundCompletion>,
774 ) -> Self {
775 self.background_completion_tx = Some(tx);
776 self
777 }
778
779 #[must_use]
785 pub fn with_task_supervisor(mut self, supervisor: TaskSupervisor) -> Self {
786 self.task_supervisor = Some(DebugIgnored(supervisor));
787 self
788 }
789
790 #[must_use]
795 pub fn with_permissions(mut self, policy: PermissionPolicy) -> Self {
796 self.permission_policy = Some(policy);
797 self
798 }
799
800 #[must_use]
803 pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
804 self.cancel_token = Some(token);
805 self
806 }
807
808 #[must_use]
811 pub fn with_output_filters(mut self, registry: OutputFilterRegistry) -> Self {
812 self.output_filter_registry = Some(registry);
813 self
814 }
815
816 #[must_use]
822 pub fn background_runs_snapshot(&self) -> Vec<background::BackgroundRunSnapshot> {
823 let runs = self.background_runs.lock();
824 runs.iter()
825 .map(|(id, h)| {
826 #[allow(clippy::cast_possible_truncation)]
827 let elapsed_ms = h.elapsed().as_millis() as u64;
828 background::BackgroundRunSnapshot {
829 run_id: id.to_string(),
830 command: h.command.clone(),
831 elapsed_ms,
832 }
833 })
834 .collect()
835 }
836
837 #[must_use]
843 pub fn policy_handle(&self) -> ShellPolicyHandle {
844 ShellPolicyHandle {
845 inner: Arc::clone(&self.policy),
846 }
847 }
848
849 #[cfg_attr(
855 feature = "profiling",
856 tracing::instrument(name = "tools.shell.execute", skip_all, fields(exit_code = tracing::field::Empty, duration_ms = tracing::field::Empty))
857 )]
858 pub async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
859 self.execute_inner(response, true).await
860 }
861
862 async fn execute_inner(
863 &self,
864 response: &str,
865 skip_confirm: bool,
866 ) -> Result<Option<ToolOutput>, ToolError> {
867 let blocks = extract_bash_blocks(response);
868 if blocks.is_empty() {
869 return Ok(None);
870 }
871
872 let resolved = self.resolve_context(None)?;
875
876 let mut outputs = Vec::with_capacity(blocks.len());
877 let mut cumulative_filter_stats: Option<FilterStats> = None;
878 let mut last_envelope: Option<ShellOutputEnvelope> = None;
879 #[allow(clippy::cast_possible_truncation)]
880 let blocks_executed = blocks.len() as u32;
881
882 for block in &blocks {
883 let (output_line, per_block_stats, envelope) =
884 self.execute_block(block, skip_confirm, &resolved).await?;
885 if let Some(fs) = per_block_stats {
886 let stats = cumulative_filter_stats.get_or_insert_with(FilterStats::default);
887 stats.raw_chars += fs.raw_chars;
888 stats.filtered_chars += fs.filtered_chars;
889 stats.raw_lines += fs.raw_lines;
890 stats.filtered_lines += fs.filtered_lines;
891 stats.confidence = Some(match (stats.confidence, fs.confidence) {
892 (Some(prev), Some(cur)) => crate::filter::worse_confidence(prev, cur),
893 (Some(prev), None) => prev,
894 (None, Some(cur)) => cur,
895 (None, None) => unreachable!(),
896 });
897 if stats.command.is_none() {
898 stats.command = fs.command;
899 }
900 if stats.kept_lines.is_empty() && !fs.kept_lines.is_empty() {
901 stats.kept_lines = fs.kept_lines;
902 }
903 }
904 last_envelope = Some(envelope);
905 outputs.push(output_line);
906 }
907
908 let raw_response = last_envelope
909 .as_ref()
910 .and_then(|e| serde_json::to_value(e).ok());
911
912 Ok(Some(ToolOutput {
913 tool_name: ToolName::new("bash"),
914 summary: outputs.join("\n\n"),
915 blocks_executed,
916 filter_stats: cumulative_filter_stats,
917 diff: None,
918 streamed: self.tool_event_tx.is_some(),
919 terminal_id: None,
920 locations: None,
921 raw_response,
922 claim_source: Some(ClaimSource::Shell),
923 ..Default::default()
924 }))
925 }
926
927 async fn execute_block(
928 &self,
929 block: &str,
930 skip_confirm: bool,
931 resolved: &ResolvedContext,
932 ) -> Result<(String, Option<FilterStats>, ShellOutputEnvelope), ToolError> {
933 self.check_permissions(block, skip_confirm).await?;
934 self.validate_sandbox_with_cwd(block, &resolved.cwd)?;
935
936 let (snapshot, snapshot_warning, snap_paths) = self.capture_snapshot_for(block)?;
937
938 if let Some(ref tx) = self.tool_event_tx {
939 let sandbox_profile = self
940 .sandbox_policy
941 .as_ref()
942 .map(|p| format!("{:?}", p.profile));
943 let _ = tx.try_send(ToolEvent::Started {
945 tool_name: ToolName::new("bash"),
946 command: block.to_owned(),
947 sandbox_profile,
948 resolved_cwd: Some(resolved.cwd.display().to_string()),
949 execution_env: resolved.name.clone(),
950 });
951 }
952
953 let start = Instant::now();
954 let sandbox_pair = self
955 .sandbox
956 .as_ref()
957 .zip(self.sandbox_policy.as_ref())
958 .map(|(sb, pol)| (sb.as_ref(), pol));
959 let (mut envelope, out) = execute_bash_with_context(
960 block,
961 self.timeout,
962 self.tool_event_tx.as_ref(),
963 "",
964 self.cancel_token.as_ref(),
965 resolved,
966 sandbox_pair,
967 )
968 .await;
969 let exit_code = envelope.exit_code;
970 if exit_code == 130
971 && self
972 .cancel_token
973 .as_ref()
974 .is_some_and(CancellationToken::is_cancelled)
975 {
976 return Err(ToolError::Cancelled);
977 }
978 #[allow(clippy::cast_possible_truncation)]
979 let duration_ms = start.elapsed().as_millis() as u64;
980
981 if let Some(snap) = snapshot
982 && let Some(surviving) = self
983 .maybe_rollback(snap, block, exit_code, duration_ms)
984 .await
985 && self.checkpoints_enabled
986 {
987 self.record_checkpoint(surviving, block, snap_paths);
988 }
989
990 if let Some(err) = self
991 .classify_and_audit(block, &out, exit_code, duration_ms)
992 .await
993 {
994 self.emit_completed(block, &out, false, None, None).await;
995 return Err(err);
996 }
997
998 let (filtered, per_block_stats) = self.apply_output_filter(block, &out, exit_code);
999
1000 self.emit_completed(
1001 block,
1002 &out,
1003 !out.contains("[error]"),
1004 per_block_stats.clone(),
1005 None,
1006 )
1007 .await;
1008
1009 envelope.truncated = filtered.len() < out.len();
1011
1012 let audit_result = if out.contains("[error]") || out.contains("[stderr]") {
1013 AuditResult::Error {
1014 message: out.clone(),
1015 }
1016 } else {
1017 AuditResult::Success
1018 };
1019 self.log_audit_with_context(
1020 block,
1021 audit_result,
1022 duration_ms,
1023 None,
1024 Some(exit_code),
1025 envelope.truncated,
1026 resolved,
1027 )
1028 .await;
1029
1030 let output_line = match snapshot_warning {
1031 Some(warn) => format!("{warn}\n$ {block}\n{filtered}"),
1032 None => format!("$ {block}\n{filtered}"),
1033 };
1034 Ok((output_line, per_block_stats, envelope))
1035 }
1036
1037 #[allow(clippy::too_many_lines)]
1042 #[tracing::instrument(name = "tools.shell.execute_block", skip(self, resolved), level = "info",
1043 fields(cwd = %resolved.cwd.display(), env_name = resolved.name.as_deref().unwrap_or("")))]
1044 async fn execute_block_with_context(
1045 &self,
1046 command: &str,
1047 skip_confirm: bool,
1048 resolved: &ResolvedContext,
1049 tool_call_id: &str,
1050 ) -> Result<Option<ToolOutput>, ToolError> {
1051 self.check_permissions(command, skip_confirm).await?;
1052 self.validate_sandbox_with_cwd(command, &resolved.cwd)?;
1053
1054 let (snapshot, snapshot_warning, snap_paths) = self.capture_snapshot_for(command)?;
1055
1056 if let Some(ref tx) = self.tool_event_tx {
1057 let sandbox_profile = self
1058 .sandbox_policy
1059 .as_ref()
1060 .map(|p| format!("{:?}", p.profile));
1061 let _ = tx.try_send(ToolEvent::Started {
1062 tool_name: ToolName::new("bash"),
1063 command: command.to_owned(),
1064 sandbox_profile,
1065 resolved_cwd: Some(resolved.cwd.display().to_string()),
1066 execution_env: resolved.name.clone(),
1067 });
1068 }
1069
1070 let start = Instant::now();
1071 let sandbox_pair = self
1072 .sandbox
1073 .as_ref()
1074 .zip(self.sandbox_policy.as_ref())
1075 .map(|(sb, pol)| (sb.as_ref(), pol));
1076 let (mut envelope, out) = execute_bash_with_context(
1077 command,
1078 self.timeout,
1079 self.tool_event_tx.as_ref(),
1080 tool_call_id,
1081 self.cancel_token.as_ref(),
1082 resolved,
1083 sandbox_pair,
1084 )
1085 .await;
1086 let exit_code = envelope.exit_code;
1087 if exit_code == 130
1088 && self
1089 .cancel_token
1090 .as_ref()
1091 .is_some_and(CancellationToken::is_cancelled)
1092 {
1093 return Err(ToolError::Cancelled);
1094 }
1095 #[allow(clippy::cast_possible_truncation)]
1096 let duration_ms = start.elapsed().as_millis() as u64;
1097
1098 if let Some(snap) = snapshot
1099 && let Some(surviving) = self
1100 .maybe_rollback(snap, command, exit_code, duration_ms)
1101 .await
1102 && self.checkpoints_enabled
1103 {
1104 self.record_checkpoint(surviving, command, snap_paths);
1105 }
1106
1107 if let Some(err) = self
1108 .classify_and_audit(command, &out, exit_code, duration_ms)
1109 .await
1110 {
1111 self.emit_completed(command, &out, false, None, None).await;
1112 return Err(err);
1113 }
1114
1115 let (filtered, per_block_stats) = self.apply_output_filter(command, &out, exit_code);
1116
1117 self.emit_completed(
1118 command,
1119 &out,
1120 !out.contains("[error]"),
1121 per_block_stats.clone(),
1122 None,
1123 )
1124 .await;
1125
1126 envelope.truncated = filtered.len() < out.len();
1127
1128 let audit_result = if out.contains("[error]") || out.contains("[stderr]") {
1129 AuditResult::Error {
1130 message: out.clone(),
1131 }
1132 } else {
1133 AuditResult::Success
1134 };
1135 self.log_audit_with_context(
1136 command,
1137 audit_result,
1138 duration_ms,
1139 None,
1140 Some(exit_code),
1141 envelope.truncated,
1142 resolved,
1143 )
1144 .await;
1145
1146 let output_line = match snapshot_warning {
1147 Some(warn) => format!("{warn}\n$ {command}\n{filtered}"),
1148 None => format!("$ {command}\n{filtered}"),
1149 };
1150 Ok(Some(ToolOutput {
1151 tool_name: ToolName::new("bash"),
1152 summary: output_line,
1153 blocks_executed: 1,
1154 filter_stats: per_block_stats,
1155 diff: None,
1156 streamed: false,
1157 terminal_id: None,
1158 locations: None,
1159 raw_response: None,
1160 claim_source: Some(ClaimSource::Shell),
1161 ..Default::default()
1162 }))
1163 }
1164
1165 #[allow(clippy::type_complexity)]
1166 fn capture_snapshot_for(
1167 &self,
1168 block: &str,
1169 ) -> Result<
1170 (
1171 Option<TransactionSnapshot>,
1172 Option<String>,
1173 Vec<std::path::PathBuf>,
1174 ),
1175 ToolError,
1176 > {
1177 if !(self.transactional || self.checkpoints_enabled) || !is_write_command(block) {
1178 return Ok((None, None, Vec::new()));
1179 }
1180 let raw_paths = affected_paths(block, &self.transaction_scope_matchers);
1181 if raw_paths.is_empty() {
1182 return Ok((None, None, Vec::new()));
1183 }
1184 let paths: Vec<std::path::PathBuf> = raw_paths
1190 .into_iter()
1191 .filter(|p| {
1192 let s = p.to_string_lossy();
1193 if has_traversal(&s) {
1194 tracing::warn!(
1195 path = %p.display(),
1196 "checkpoint: skipping path with traversal sequence"
1197 );
1198 return false;
1199 }
1200 if !self.allowed_paths_canonical.is_empty() {
1201 let canonical = canonicalize_or_nearest_ancestor(p);
1202 if !self
1203 .allowed_paths_canonical
1204 .iter()
1205 .any(|a| canonical.starts_with(a))
1206 {
1207 tracing::warn!(
1208 path = %p.display(),
1209 "checkpoint: skipping out-of-sandbox path"
1210 );
1211 return false;
1212 }
1213 }
1214 true
1215 })
1216 .collect();
1217 if paths.is_empty() {
1218 return Ok((None, None, Vec::new()));
1219 }
1220 match TransactionSnapshot::capture(&paths, self.max_snapshot_bytes) {
1221 Ok(snap) => {
1222 tracing::debug!(
1223 files = snap.file_count(),
1224 bytes = snap.total_bytes(),
1225 "transaction snapshot captured"
1226 );
1227 Ok((Some(snap), None, paths))
1228 }
1229 Err(e) if self.snapshot_required => Err(ToolError::SnapshotFailed {
1230 reason: e.to_string(),
1231 }),
1232 Err(e) => {
1233 tracing::warn!(err = %e, "transaction snapshot failed, proceeding without rollback");
1234 Ok((
1235 None,
1236 Some(format!("[warn] snapshot failed: {e}; rollback unavailable")),
1237 Vec::new(),
1238 ))
1239 }
1240 }
1241 }
1242
1243 async fn maybe_rollback(
1249 &self,
1250 snap: TransactionSnapshot,
1251 block: &str,
1252 exit_code: i32,
1253 duration_ms: u64,
1254 ) -> Option<TransactionSnapshot> {
1255 let should_rollback = self.auto_rollback
1256 && if self.auto_rollback_exit_codes.is_empty() {
1257 exit_code >= 2
1258 } else {
1259 self.auto_rollback_exit_codes.contains(&exit_code)
1260 };
1261 if !should_rollback {
1262 return Some(snap);
1264 }
1265 match snap.rollback() {
1266 Ok(report) => {
1267 tracing::info!(
1268 restored = report.restored_count,
1269 deleted = report.deleted_count,
1270 "transaction rollback completed"
1271 );
1272 self.log_audit(
1273 block,
1274 AuditResult::Rollback {
1275 restored: report.restored_count,
1276 deleted: report.deleted_count,
1277 },
1278 duration_ms,
1279 None,
1280 Some(exit_code),
1281 false,
1282 )
1283 .await;
1284 if let Some(ref tx) = self.tool_event_tx {
1285 let _ = tx
1287 .send(ToolEvent::Rollback {
1288 tool_name: ToolName::new("bash"),
1289 command: block.to_owned(),
1290 restored_count: report.restored_count,
1291 deleted_count: report.deleted_count,
1292 })
1293 .await;
1294 }
1295 }
1296 Err(e) => {
1297 tracing::error!(err = %e, "transaction rollback failed");
1298 }
1299 }
1300 None
1301 }
1302
1303 fn record_checkpoint(
1309 &self,
1310 snap: TransactionSnapshot,
1311 command: &str,
1312 paths: Vec<std::path::PathBuf>,
1313 ) {
1314 use std::time::{SystemTime, UNIX_EPOCH};
1315 let captured_at_secs = SystemTime::now()
1316 .duration_since(UNIX_EPOCH)
1317 .unwrap_or_default()
1318 .as_secs();
1319 let mut stack = self.checkpoint_stack.lock();
1320 stack.record(Checkpoint {
1321 before_snapshot: snap,
1322 command: command.to_owned(),
1323 paths,
1324 captured_at_secs,
1325 });
1326 }
1327
1328 async fn classify_and_audit(
1329 &self,
1330 block: &str,
1331 out: &str,
1332 exit_code: i32,
1333 duration_ms: u64,
1334 ) -> Option<ToolError> {
1335 if out.contains("[error] command timed out") {
1336 self.log_audit(
1337 block,
1338 AuditResult::Timeout,
1339 duration_ms,
1340 None,
1341 Some(exit_code),
1342 false,
1343 )
1344 .await;
1345 return Some(ToolError::Timeout {
1346 timeout_secs: self.timeout.as_secs(),
1347 });
1348 }
1349
1350 if let Some(category) = classify_shell_exit(exit_code, out) {
1351 return Some(ToolError::Shell {
1352 exit_code,
1353 category,
1354 message: out.lines().take(3).collect::<Vec<_>>().join("; "),
1355 });
1356 }
1357
1358 None
1359 }
1360
1361 fn apply_output_filter(
1362 &self,
1363 block: &str,
1364 out: &str,
1365 exit_code: i32,
1366 ) -> (String, Option<FilterStats>) {
1367 let sanitized = sanitize_output(out);
1368 if let Some(ref registry) = self.output_filter_registry {
1369 match registry.apply(block, &sanitized, exit_code) {
1370 Some(fr) => {
1371 tracing::debug!(
1372 command = block,
1373 raw = fr.raw_chars,
1374 filtered = fr.filtered_chars,
1375 savings_pct = fr.savings_pct(),
1376 "output filter applied"
1377 );
1378 let stats = FilterStats {
1379 raw_chars: fr.raw_chars,
1380 filtered_chars: fr.filtered_chars,
1381 raw_lines: fr.raw_lines,
1382 filtered_lines: fr.filtered_lines,
1383 confidence: Some(fr.confidence),
1384 command: Some(block.to_owned()),
1385 kept_lines: fr.kept_lines.clone(),
1386 };
1387 (fr.output, Some(stats))
1388 }
1389 None => (sanitized, None),
1390 }
1391 } else {
1392 (sanitized, None)
1393 }
1394 }
1395
1396 async fn emit_completed(
1397 &self,
1398 command: &str,
1399 output: &str,
1400 success: bool,
1401 filter_stats: Option<FilterStats>,
1402 run_id: Option<RunId>,
1403 ) {
1404 if let Some(ref tx) = self.tool_event_tx {
1405 let _ = tx
1407 .send(ToolEvent::Completed {
1408 tool_name: ToolName::new("bash"),
1409 command: command.to_owned(),
1410 output: output.to_owned(),
1411 success,
1412 filter_stats,
1413 diff: None,
1414 run_id,
1415 })
1416 .await;
1417 }
1418 }
1419
1420 #[allow(clippy::too_many_lines)]
1422 async fn check_permissions(&self, block: &str, skip_confirm: bool) -> Result<(), ToolError> {
1423 let normalized = deobfuscate::deobfuscate(block);
1425 let effective = normalized.as_str();
1426
1427 let blocked_cmd = self
1432 .find_blocked_command(block)
1433 .or_else(|| self.find_blocked_command(effective));
1434 if let Some(blocked) = blocked_cmd {
1435 let fix = safe_fix::suggest_fix(effective);
1436 let err = if let Some(suggestion) = fix {
1437 let reason = format!("{blocked} — suggestion: {}", suggestion.alternative);
1438 self.log_audit(
1439 block,
1440 AuditResult::Blocked {
1441 reason: format!("blocked command: {reason}"),
1442 },
1443 0,
1444 None,
1445 None,
1446 false,
1447 )
1448 .await;
1449 ToolError::BlockedWithFix {
1450 command: blocked,
1451 suggestion: Some(suggestion),
1452 }
1453 } else {
1454 self.log_audit(
1455 block,
1456 AuditResult::Blocked {
1457 reason: format!("blocked command: {blocked}"),
1458 },
1459 0,
1460 None,
1461 None,
1462 false,
1463 )
1464 .await;
1465 ToolError::Blocked { command: blocked }
1466 };
1467 return Err(err);
1468 }
1469
1470 if let Some(ref policy) = self.permission_policy {
1471 match policy.check("bash", effective) {
1472 PermissionAction::Deny => {
1473 let err = match safe_fix::suggest_fix(effective) {
1474 Some(suggestion) => ToolError::BlockedWithFix {
1475 command: effective.to_owned(),
1476 suggestion: Some(suggestion),
1477 },
1478 None => ToolError::Blocked {
1479 command: effective.to_owned(),
1480 },
1481 };
1482 self.log_audit(
1483 block,
1484 AuditResult::Blocked {
1485 reason: "denied by permission policy".to_owned(),
1486 },
1487 0,
1488 None,
1489 None,
1490 false,
1491 )
1492 .await;
1493 return Err(err);
1494 }
1495 PermissionAction::Ask if !skip_confirm => {
1496 return Err(ToolError::ConfirmationRequired {
1497 command: effective.to_owned(),
1498 });
1499 }
1500 _ => {}
1501 }
1502 } else if !skip_confirm {
1503 let confirm_pattern = self
1506 .find_confirm_command(block)
1507 .or_else(|| self.find_confirm_command(effective));
1508 if let Some(pattern) = confirm_pattern {
1509 return Err(ToolError::ConfirmationRequired {
1510 command: pattern.to_owned(),
1511 });
1512 }
1513 }
1514
1515 if let Some(ref chain) = self.risk_chain {
1517 let verdict = chain.record("bash", effective, self.risk_chain_threshold);
1518 if verdict.should_block {
1519 let chain_name = verdict
1520 .chain_pattern
1521 .unwrap_or_else(|| "unknown".to_owned());
1522 tracing::warn!(
1523 chain = chain_name,
1524 score = verdict.cumulative_score,
1525 "risk chain threshold exceeded"
1526 );
1527 return Err(ToolError::Blocked {
1528 command: format!(
1529 "risk chain blocked: {} (score {:.2})",
1530 chain_name, verdict.cumulative_score
1531 ),
1532 });
1533 }
1534 }
1535
1536 Ok(())
1537 }
1538
1539 #[tracing::instrument(name = "tools.shell.resolve_context", skip(self, ctx), level = "info")]
1552 pub(crate) fn resolve_context(
1553 &self,
1554 ctx: Option<&ExecutionContext>,
1555 ) -> Result<ResolvedContext, ToolError> {
1556 let mut env: HashMap<String, String> = std::env::vars().collect();
1558
1559 env.retain(|k, _| {
1561 !self
1562 .env_blocklist
1563 .iter()
1564 .any(|prefix| k.starts_with(prefix.as_str()))
1565 });
1566
1567 if let Some(skill) = self.skill_env.read().as_ref() {
1569 for (k, v) in skill {
1570 env.insert(k.clone(), v.clone());
1571 }
1572 }
1573
1574 let mut resolved_name: Option<String> = None;
1576 let mut cwd_override: Option<PathBuf> = None;
1577 let mut trusted = false;
1578
1579 if let Some(default_name) = &self.default_env
1581 && let Some(default_ctx) = self.environments.get(default_name.as_str())
1582 {
1583 resolved_name.get_or_insert_with(|| default_name.clone());
1584 if cwd_override.is_none() {
1585 cwd_override = default_ctx.cwd().map(ToOwned::to_owned);
1586 }
1587 trusted = default_ctx.is_trusted();
1588 for (k, v) in default_ctx.env_overrides() {
1589 env.insert(k.clone(), v.clone());
1590 }
1591 }
1592
1593 if let Some(ctx) = ctx {
1595 if let Some(name) = ctx.name() {
1596 if let Some(reg_ctx) = self.environments.get(name) {
1597 resolved_name = Some(name.to_owned());
1598 if let Some(cwd) = reg_ctx.cwd() {
1599 cwd_override = Some(cwd.to_owned());
1600 }
1601 trusted = reg_ctx.is_trusted();
1602 for (k, v) in reg_ctx.env_overrides() {
1603 env.insert(k.clone(), v.clone());
1604 }
1605 } else {
1606 return Err(ToolError::Execution(std::io::Error::other(format!(
1607 "unknown execution environment '{name}'"
1608 ))));
1609 }
1610 }
1611
1612 if let Some(cwd) = ctx.cwd() {
1614 cwd_override = Some(cwd.to_owned());
1615 }
1616 if !ctx.is_trusted() {
1617 trusted = false;
1618 }
1619 for (k, v) in ctx.env_overrides() {
1620 env.insert(k.clone(), v.clone());
1621 }
1622 }
1623
1624 if !trusted {
1626 env.retain(|k, _| {
1627 !self
1628 .env_blocklist
1629 .iter()
1630 .any(|prefix| k.starts_with(prefix.as_str()))
1631 });
1632 }
1633
1634 let cwd = if let Some(raw) = cwd_override {
1636 let raw = if raw.is_absolute() {
1639 raw
1640 } else {
1641 std::env::current_dir()
1642 .unwrap_or_else(|_| PathBuf::from("."))
1643 .join(raw)
1644 };
1645 let canonical = raw
1646 .canonicalize()
1647 .map_err(|_| ToolError::SandboxViolation {
1648 path: raw.display().to_string(),
1649 })?;
1650 if !self
1652 .allowed_paths_canonical
1653 .iter()
1654 .any(|p| canonical.starts_with(p))
1655 {
1656 return Err(ToolError::SandboxViolation {
1657 path: canonical.display().to_string(),
1658 });
1659 }
1660 canonical
1661 } else {
1662 self.clamped_process_cwd()
1663 };
1664
1665 Ok(ResolvedContext {
1666 cwd,
1667 env,
1668 name: resolved_name,
1669 trusted,
1670 })
1671 }
1672
1673 fn clamped_process_cwd(&self) -> PathBuf {
1682 let process_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1683 if self.allowed_paths_canonical.is_empty() {
1684 return process_cwd;
1685 }
1686 let canon = canonicalize_or_nearest_ancestor(&process_cwd);
1687 if is_path_within(&canon, &self.allowed_paths_canonical) {
1688 return canon;
1689 }
1690 self.allowed_paths_canonical
1691 .iter()
1692 .find(|p| p.is_dir())
1693 .unwrap_or(&self.allowed_paths_canonical[0])
1694 .clone()
1695 }
1696
1697 fn validate_sandbox_with_cwd(
1698 &self,
1699 code: &str,
1700 cwd: &std::path::Path,
1701 ) -> Result<(), ToolError> {
1702 for token in extract_paths(code) {
1703 if has_traversal(&token) {
1704 return Err(ToolError::SandboxViolation { path: token });
1705 }
1706
1707 if self.allowed_paths_canonical.is_empty() {
1708 continue;
1709 }
1710
1711 let path = if token.starts_with('/') {
1712 PathBuf::from(&token)
1713 } else {
1714 cwd.join(&token)
1715 };
1716 let canonical = canonicalize_or_nearest_ancestor(&path);
1722 if !self
1723 .allowed_paths_canonical
1724 .iter()
1725 .any(|allowed| canonical.starts_with(allowed))
1726 {
1727 return Err(ToolError::SandboxViolation {
1728 path: canonical.display().to_string(),
1729 });
1730 }
1731 }
1732 Ok(())
1733 }
1734
1735 #[cfg(test)]
1738 fn validate_sandbox(&self, code: &str) -> Result<(), ToolError> {
1739 let cwd = std::env::current_dir().unwrap_or_default();
1740 self.validate_sandbox_with_cwd(code, &cwd)
1741 }
1742
1743 fn find_blocked_command(&self, code: &str) -> Option<String> {
1783 let snapshot = self.policy.load_full();
1784 let cleaned = strip_shell_escapes(&code.to_lowercase());
1785 if let Some(hit) = match_blocklist_tokens(&cleaned, &snapshot.blocked_commands) {
1786 return Some(hit);
1787 }
1788 for inner in extract_subshell_contents(&cleaned) {
1790 if let Some(hit) = match_blocklist_tokens(&inner, &snapshot.blocked_commands) {
1791 return Some(hit);
1792 }
1793 }
1794 None
1795 }
1796
1797 fn find_confirm_command(&self, code: &str) -> Option<&str> {
1798 let normalized = code.to_lowercase();
1799 for pattern in &self.confirm_patterns {
1800 if normalized.contains(pattern.as_str()) {
1801 return Some(pattern.as_str());
1802 }
1803 }
1804 None
1805 }
1806
1807 fn build_audit_entry(
1808 command: &str,
1809 result: AuditResult,
1810 duration_ms: u64,
1811 error: Option<&ToolError>,
1812 exit_code: Option<i32>,
1813 truncated: bool,
1814 resolved: Option<&ResolvedContext>,
1815 ) -> AuditEntry {
1816 let (error_category, error_domain, error_phase) = error.map_or((None, None, None), |e| {
1817 let cat = e.category();
1818 (
1819 Some(cat.label().to_owned()),
1820 Some(cat.domain().label().to_owned()),
1821 Some(cat.phase().label().to_owned()),
1822 )
1823 });
1824 AuditEntry {
1825 source_kind: None,
1826 trust_level: None,
1827 timestamp: chrono_now(),
1828 tool: "shell".into(),
1829 command: command.into(),
1830 result,
1831 duration_ms,
1832 error_category,
1833 error_domain,
1834 error_phase,
1835 claim_source: Some(ClaimSource::Shell),
1836 mcp_server_id: None,
1837 injection_flagged: false,
1838 embedding_anomalous: false,
1839 cross_boundary_mcp_to_acp: false,
1840 adversarial_policy_decision: None,
1841 exit_code,
1842 truncated,
1843 caller_id: None,
1844 skill_name: None,
1845 policy_match: None,
1846 correlation_id: None,
1847 vigil_risk: None,
1848 execution_env: resolved.and_then(|r| r.name.clone()),
1849 resolved_cwd: resolved.map(|r| r.cwd.display().to_string()),
1850 scope_at_definition: None,
1851 scope_at_dispatch: None,
1852 }
1853 }
1854
1855 async fn log_audit(
1856 &self,
1857 command: &str,
1858 result: AuditResult,
1859 duration_ms: u64,
1860 error: Option<&ToolError>,
1861 exit_code: Option<i32>,
1862 truncated: bool,
1863 ) {
1864 if let Some(ref logger) = self.audit_logger {
1865 let entry = Self::build_audit_entry(
1866 command,
1867 result,
1868 duration_ms,
1869 error,
1870 exit_code,
1871 truncated,
1872 None,
1873 );
1874 logger.log(&entry).await;
1875 }
1876 }
1877
1878 #[allow(clippy::too_many_arguments)]
1879 async fn log_audit_with_context(
1880 &self,
1881 command: &str,
1882 result: AuditResult,
1883 duration_ms: u64,
1884 error: Option<&ToolError>,
1885 exit_code: Option<i32>,
1886 truncated: bool,
1887 resolved: &ResolvedContext,
1888 ) {
1889 if let Some(ref logger) = self.audit_logger {
1890 let entry = Self::build_audit_entry(
1891 command,
1892 result,
1893 duration_ms,
1894 error,
1895 exit_code,
1896 truncated,
1897 Some(resolved),
1898 );
1899 logger.log(&entry).await;
1900 }
1901 }
1902}
1903
1904impl ToolExecutor for ShellExecutor {
1905 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
1906 self.execute_inner(response, false).await
1907 }
1908
1909 async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
1915 self.execute_inner(response, true).await
1916 }
1917
1918 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1919 use crate::registry::{InvocationHint, ToolDef};
1920 vec![ToolDef {
1921 id: "bash".into(),
1922 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(),
1923 schema: schemars::schema_for!(BashParams),
1924 invocation: InvocationHint::FencedBlock("bash"),
1925 output_schema: None,
1926 server_id: None,
1927 }]
1928 }
1929
1930 #[tracing::instrument(name = "tools.shell.execute_tool_call", skip(self, call), level = "info",
1931 fields(tool_id = %call.tool_id, env = call.context.as_ref().and_then(|c| c.name()).unwrap_or("")))]
1932 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
1933 if call.tool_id != "bash" {
1934 return Ok(None);
1935 }
1936 let params: BashParams = crate::executor::deserialize_params(&call.params)?;
1937 if params.command.is_empty() {
1938 return Ok(None);
1939 }
1940 let command = ¶ms.command;
1941
1942 let resolved = self.resolve_context(call.context.as_ref())?;
1945
1946 if params.background {
1947 let run_id = self
1948 .spawn_background_with_context(command, &resolved)
1949 .await?;
1950 let id_short = &run_id.to_string()[..8];
1951 return Ok(Some(ToolOutput {
1952 tool_name: ToolName::new("bash"),
1953 summary: format!(
1954 "[background] started run_id={run_id} — command: {command}\n\
1955 The command is running in the background. When it completes, \
1956 results will appear at the start of the next turn (run_id_short={id_short})."
1957 ),
1958 blocks_executed: 1,
1959 filter_stats: None,
1960 diff: None,
1961 streamed: true,
1962 terminal_id: None,
1963 locations: None,
1964 raw_response: None,
1965 claim_source: Some(ClaimSource::Shell),
1966 ..Default::default()
1967 }));
1968 }
1969
1970 self.execute_block_with_context(command, false, &resolved, &call.tool_call_id)
1971 .await
1972 }
1973
1974 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
1975 ShellExecutor::set_skill_env(self, env);
1976 }
1977
1978 fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
1979 let result = self
1980 .checkpoint_stack
1981 .lock()
1982 .undo(n, self.max_snapshot_bytes);
1983 crate::executor::CheckpointActionResult {
1984 reverted_commands: result.reverted_commands,
1985 restored: result.restored,
1986 deleted: result.deleted,
1987 supported: true,
1988 message: result.message,
1989 }
1990 }
1991
1992 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
1993 let result = self.checkpoint_stack.lock().redo(self.max_snapshot_bytes);
1994 crate::executor::CheckpointActionResult {
1995 reverted_commands: result.reverted_commands,
1996 restored: result.restored,
1997 deleted: result.deleted,
1998 supported: true,
1999 message: result.message,
2000 }
2001 }
2002
2003 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
2004 let stack = self.checkpoint_stack.lock();
2005 let entries = stack
2006 .list_undo()
2007 .into_iter()
2008 .map(|e| crate::executor::CheckpointEntryView {
2009 index: e.index,
2010 command: e.command,
2011 captured_at_secs: e.captured_at_secs,
2012 file_count: e.file_count,
2013 })
2014 .collect();
2015 crate::executor::CheckpointListResult {
2016 entries,
2017 redo_depth: stack.redo_depth(),
2018 supported: true,
2019 }
2020 }
2021
2022 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
2023 false
2024 }
2025
2026 async fn execute_tool_call_confirmed(
2027 &self,
2028 call: &ToolCall,
2029 ) -> Result<Option<ToolOutput>, ToolError> {
2030 self.execute_tool_call(call).await
2031 }
2032
2033 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
2034 false
2035 }
2036}
2037
2038impl ShellExecutor {
2039 #[cfg(all(test, not(target_os = "windows")))]
2047 async fn spawn_background(&self, command: &str) -> Result<RunId, ToolError> {
2048 let resolved = self.resolve_context(None)?;
2049 self.spawn_background_with_context(command, &resolved).await
2050 }
2051
2052 async fn spawn_background_with_context(
2070 &self,
2071 command: &str,
2072 resolved: &ResolvedContext,
2073 ) -> Result<RunId, ToolError> {
2074 use std::sync::atomic::Ordering;
2075
2076 if self.shutting_down.load(Ordering::Acquire) {
2077 return Err(ToolError::Blocked {
2078 command: command.to_owned(),
2079 });
2080 }
2081
2082 self.check_permissions(command, false).await?;
2083 self.validate_sandbox_with_cwd(command, &resolved.cwd)?;
2084
2085 let run_id = RunId::new();
2086 let mut runs = self.background_runs.lock();
2087 if runs.len() >= self.max_background_runs {
2088 return Err(ToolError::Blocked {
2089 command: format!(
2090 "background run cap reached (max_background_runs={})",
2091 self.max_background_runs
2092 ),
2093 });
2094 }
2095 let abort = CancellationToken::new();
2096 runs.insert(
2097 run_id,
2098 BackgroundHandle {
2099 command: command.to_owned(),
2100 started_at: std::time::Instant::now(),
2101 abort: abort.clone(),
2102 child_pid: None,
2103 },
2104 );
2105 drop(runs);
2106
2107 let tool_event_tx = self.tool_event_tx.clone();
2108 let background_completion_tx = self.background_completion_tx.clone();
2109 let background_runs = Arc::clone(&self.background_runs);
2110 let timeout = self.background_timeout;
2111 let env = resolved.env.clone();
2112 let cwd = resolved.cwd.clone();
2113 let command_owned = command.to_owned();
2114
2115 if let Some(ref sup) = self.task_supervisor {
2116 let task_name: Arc<str> = Arc::from(format!("shell_bg_{run_id}").as_str());
2117 drop(sup.spawn_oneshot(task_name, move || {
2118 run_background_task_with_env(
2119 run_id,
2120 command_owned,
2121 timeout,
2122 abort,
2123 background_runs,
2124 tool_event_tx,
2125 background_completion_tx,
2126 env,
2127 cwd,
2128 )
2129 }));
2130 } else {
2131 tokio::spawn(run_background_task_with_env(
2132 run_id,
2133 command_owned,
2134 timeout,
2135 abort,
2136 background_runs,
2137 tool_event_tx,
2138 background_completion_tx,
2139 env,
2140 cwd,
2141 ));
2142 }
2143
2144 Ok(run_id)
2145 }
2146
2147 pub async fn shutdown(&self) {
2153 use std::sync::atomic::Ordering;
2154
2155 self.shutting_down.store(true, Ordering::Release);
2156
2157 let handles: Vec<(RunId, String, CancellationToken, Option<u32>)> = {
2158 let runs = self.background_runs.lock();
2159 runs.iter()
2160 .map(|(id, h)| (*id, h.command.clone(), h.abort.clone(), h.child_pid))
2161 .collect()
2162 };
2163
2164 if handles.is_empty() {
2165 return;
2166 }
2167
2168 tracing::info!(
2169 count = handles.len(),
2170 "cancelling background shell runs for shutdown"
2171 );
2172
2173 for (run_id, command, abort, pid_opt) in &handles {
2174 abort.cancel();
2175
2176 #[cfg(unix)]
2177 if let Some(pid) = pid_opt {
2178 send_signal_with_escalation(*pid).await;
2179 }
2180 #[cfg(not(unix))]
2181 let _ = pid_opt;
2182
2183 if let Some(ref tx) = self.tool_event_tx {
2184 let _ = tx
2185 .send(ToolEvent::Completed {
2186 tool_name: ToolName::new("bash"),
2187 command: command.clone(),
2188 output: "[terminated by shutdown]".to_owned(),
2189 success: false,
2190 filter_stats: None,
2191 diff: None,
2192 run_id: Some(*run_id),
2193 })
2194 .await;
2195 }
2196 }
2197
2198 self.background_runs.lock().clear();
2199 }
2200}
2201
2202#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
2205async fn run_background_task_with_env(
2206 run_id: RunId,
2207 command: String,
2208 timeout: Duration,
2209 abort: CancellationToken,
2210 background_runs: Arc<Mutex<HashMap<RunId, BackgroundHandle>>>,
2211 tool_event_tx: Option<ToolEventTx>,
2212 background_completion_tx: Option<tokio::sync::mpsc::Sender<BackgroundCompletion>>,
2213 env: HashMap<String, String>,
2214 cwd: PathBuf,
2215) {
2216 use std::process::Stdio;
2217
2218 let started_at = std::time::Instant::now();
2219
2220 let mut cmd = build_bash_command_with_context(&command, &env, &cwd);
2221 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2222
2223 let mut child = match cmd.spawn() {
2224 Ok(c) => c,
2225 Err(ref e) => {
2226 let (_, out) = spawn_error_envelope(e);
2227 background_runs.lock().remove(&run_id);
2228 emit_completed(tool_event_tx.as_ref(), &command, out.clone(), false, run_id).await;
2229 if let Some(ref tx) = background_completion_tx {
2230 let _ = tx
2231 .send(BackgroundCompletion {
2232 run_id,
2233 exit_code: 1,
2234 output: out,
2235 success: false,
2236 elapsed_ms: 0,
2237 command,
2238 })
2239 .await;
2240 }
2241 return;
2242 }
2243 };
2244
2245 if let Some(pid) = child.id()
2246 && let Some(handle) = background_runs.lock().get_mut(&run_id)
2247 {
2248 handle.child_pid = Some(pid);
2249 }
2250
2251 let stdout = child.stdout.take().expect("stdout piped");
2252 let stderr = child.stderr.take().expect("stderr piped");
2253 let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2254
2255 let mut combined = String::new();
2256 let mut stdout_buf = String::new();
2257 let mut stderr_buf = String::new();
2258 let deadline = tokio::time::Instant::now() + timeout;
2259 let timeout_secs = timeout.as_secs();
2260
2261 let (_, out) = match run_bash_stream(
2262 &command,
2263 deadline,
2264 Some(&abort),
2265 tool_event_tx.as_ref(),
2266 "",
2267 &mut line_rx,
2268 &mut combined,
2269 &mut stdout_buf,
2270 &mut stderr_buf,
2271 &mut child,
2272 )
2273 .await
2274 {
2275 BashLoopOutcome::TimedOut => (
2276 ShellOutputEnvelope {
2277 stdout: stdout_buf,
2278 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2279 exit_code: 1,
2280 truncated: false,
2281 },
2282 format!("[error] command timed out after {timeout_secs}s"),
2283 ),
2284 BashLoopOutcome::Cancelled => (
2285 ShellOutputEnvelope {
2286 stdout: stdout_buf,
2287 stderr: stderr_buf,
2288 exit_code: 130,
2289 truncated: false,
2290 },
2291 "[cancelled] operation aborted".to_string(),
2292 ),
2293 BashLoopOutcome::StreamClosed => {
2294 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2295 }
2296 };
2297
2298 #[allow(clippy::cast_possible_truncation)]
2299 let elapsed_ms = started_at.elapsed().as_millis() as u64;
2300 let success = !out.contains("[error]");
2301 let exit_code = i32::from(!success);
2302 let truncated = crate::executor::truncate_tool_output_at(&out, 4096);
2303
2304 background_runs.lock().remove(&run_id);
2305 emit_completed(
2306 tool_event_tx.as_ref(),
2307 &command,
2308 truncated.clone(),
2309 success,
2310 run_id,
2311 )
2312 .await;
2313
2314 if let Some(ref tx) = background_completion_tx {
2315 let completion = BackgroundCompletion {
2316 run_id,
2317 exit_code,
2318 output: truncated,
2319 success,
2320 elapsed_ms,
2321 command,
2322 };
2323 if tx.send(completion).await.is_err() {
2324 tracing::warn!(
2325 run_id = %run_id,
2326 "background completion channel closed; agent may have shut down"
2327 );
2328 }
2329 }
2330
2331 tracing::debug!(run_id = %run_id, exit_code, elapsed_ms, "background shell run (with context) completed");
2332}
2333
2334async fn emit_completed(
2336 tool_event_tx: Option<&ToolEventTx>,
2337 command: &str,
2338 output: String,
2339 success: bool,
2340 run_id: RunId,
2341) {
2342 if let Some(tx) = tool_event_tx {
2343 let _ = tx
2344 .send(ToolEvent::Completed {
2345 tool_name: ToolName::new("bash"),
2346 command: command.to_owned(),
2347 output,
2348 success,
2349 filter_stats: None,
2350 diff: None,
2351 run_id: Some(run_id),
2352 })
2353 .await;
2354 }
2355}
2356
2357pub(crate) fn strip_shell_escapes(input: &str) -> String {
2361 let mut out = String::with_capacity(input.len());
2362 let bytes = input.as_bytes();
2363 let mut i = 0;
2364 while i < bytes.len() {
2365 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'\'' {
2367 let mut j = i + 2; let mut decoded = String::new();
2369 let mut valid = false;
2370 while j < bytes.len() && bytes[j] != b'\'' {
2371 if bytes[j] == b'\\' && j + 1 < bytes.len() {
2372 let next = bytes[j + 1];
2373 if next == b'x' && j + 3 < bytes.len() {
2374 let hi = (bytes[j + 2] as char).to_digit(16);
2376 let lo = (bytes[j + 3] as char).to_digit(16);
2377 if let (Some(h), Some(l)) = (hi, lo) {
2378 #[allow(clippy::cast_possible_truncation)]
2379 let byte = ((h << 4) | l) as u8;
2380 decoded.push(byte as char);
2381 j += 4;
2382 valid = true;
2383 continue;
2384 }
2385 } else if next.is_ascii_digit() {
2386 let mut val = u32::from(next - b'0');
2388 let mut len = 2; if j + 2 < bytes.len() && bytes[j + 2].is_ascii_digit() {
2390 val = val * 8 + u32::from(bytes[j + 2] - b'0');
2391 len = 3;
2392 if j + 3 < bytes.len() && bytes[j + 3].is_ascii_digit() {
2393 val = val * 8 + u32::from(bytes[j + 3] - b'0');
2394 len = 4;
2395 }
2396 }
2397 #[allow(clippy::cast_possible_truncation)]
2398 decoded.push((val & 0xFF) as u8 as char);
2399 j += len;
2400 valid = true;
2401 continue;
2402 }
2403 decoded.push(next as char);
2405 j += 2;
2406 } else {
2407 decoded.push(bytes[j] as char);
2408 j += 1;
2409 }
2410 }
2411 if j < bytes.len() && bytes[j] == b'\'' && valid {
2412 out.push_str(&decoded);
2413 i = j + 1;
2414 continue;
2415 }
2416 }
2418 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
2420 i += 2;
2421 continue;
2422 }
2423 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] != b'\n' {
2425 i += 1;
2426 out.push(bytes[i] as char);
2427 i += 1;
2428 continue;
2429 }
2430 if bytes[i] == b'"' || bytes[i] == b'\'' {
2432 let quote = bytes[i];
2433 i += 1;
2434 while i < bytes.len() && bytes[i] != quote {
2435 out.push(bytes[i] as char);
2436 i += 1;
2437 }
2438 if i < bytes.len() {
2439 i += 1; }
2441 continue;
2442 }
2443 out.push(bytes[i] as char);
2444 i += 1;
2445 }
2446 out
2447}
2448
2449pub(crate) fn extract_subshell_contents(s: &str) -> Vec<String> {
2459 let mut results = Vec::new();
2460 let chars: Vec<char> = s.chars().collect();
2461 let len = chars.len();
2462 let mut i = 0;
2463
2464 while i < len {
2465 if chars[i] == '`' {
2467 let start = i + 1;
2468 let mut j = start;
2469 while j < len && chars[j] != '`' {
2470 j += 1;
2471 }
2472 if j < len {
2473 results.push(chars[start..j].iter().collect());
2474 }
2475 i = j + 1;
2476 continue;
2477 }
2478
2479 let next_is_open_paren = i + 1 < len && chars[i + 1] == '(';
2481 let is_paren_subshell = next_is_open_paren && matches!(chars[i], '$' | '<' | '>');
2482
2483 if is_paren_subshell {
2484 let start = i + 2;
2485 let mut depth: usize = 1;
2486 let mut j = start;
2487 while j < len && depth > 0 {
2488 match chars[j] {
2489 '(' => depth += 1,
2490 ')' => depth -= 1,
2491 _ => {}
2492 }
2493 if depth > 0 {
2494 j += 1;
2495 } else {
2496 break;
2497 }
2498 }
2499 if depth == 0 {
2500 results.push(chars[start..j].iter().collect());
2501 }
2502 i = j + 1;
2503 continue;
2504 }
2505
2506 i += 1;
2507 }
2508
2509 results
2510}
2511
2512pub(crate) fn tokenize_commands(normalized: &str) -> Vec<Vec<String>> {
2515 let replaced = normalized.replace("||", "\n").replace("&&", "\n");
2517 replaced
2518 .split([';', '|', '\n'])
2519 .map(|seg| {
2520 seg.split_whitespace()
2521 .map(str::to_owned)
2522 .collect::<Vec<String>>()
2523 })
2524 .filter(|tokens| !tokens.is_empty())
2525 .collect()
2526}
2527
2528const TRANSPARENT_PREFIXES: &[&str] = &["env", "command", "exec", "nice", "nohup", "time", "xargs"];
2531
2532fn cmd_basename(tok: &str) -> &str {
2534 tok.rsplit('/').next().unwrap_or(tok)
2535}
2536
2537pub(crate) fn tokens_match_pattern(tokens: &[String], pattern: &str) -> bool {
2544 if tokens.is_empty() || pattern.is_empty() {
2545 return false;
2546 }
2547 let pattern = pattern.trim();
2548 let pattern_tokens: Vec<&str> = pattern.split_whitespace().collect();
2549 if pattern_tokens.is_empty() {
2550 return false;
2551 }
2552
2553 let start = tokens
2555 .iter()
2556 .position(|t| !TRANSPARENT_PREFIXES.contains(&cmd_basename(t)))
2557 .unwrap_or(0);
2558 let effective = &tokens[start..];
2559 if effective.is_empty() {
2560 return false;
2561 }
2562
2563 if pattern_tokens.len() == 1 {
2564 let pat = pattern_tokens[0];
2565 let base = cmd_basename(&effective[0]);
2566 base == pat || base.starts_with(&format!("{pat}."))
2568 } else {
2569 let n = pattern_tokens.len().min(effective.len());
2571 let mut parts: Vec<&str> = vec![cmd_basename(&effective[0])];
2572 parts.extend(effective[1..n].iter().map(String::as_str));
2573 let joined = parts.join(" ");
2574 if joined.starts_with(pattern) {
2575 return true;
2576 }
2577 if effective.len() > n {
2578 let mut parts2: Vec<&str> = vec![cmd_basename(&effective[0])];
2579 parts2.extend(effective[1..=n].iter().map(String::as_str));
2580 parts2.join(" ").starts_with(pattern)
2581 } else {
2582 false
2583 }
2584 }
2585}
2586
2587fn extract_paths(code: &str) -> Vec<String> {
2588 let mut result = Vec::new();
2589
2590 let mut tokens: Vec<String> = Vec::new();
2592 let mut current = String::new();
2593 let mut chars = code.chars().peekable();
2594 while let Some(c) = chars.next() {
2595 match c {
2596 '"' | '\'' => {
2597 let quote = c;
2598 while let Some(&nc) = chars.peek() {
2599 if nc == quote {
2600 chars.next();
2601 break;
2602 }
2603 current.push(chars.next().unwrap());
2604 }
2605 }
2606 c if c.is_whitespace() || matches!(c, ';' | '|' | '&') => {
2607 if !current.is_empty() {
2608 tokens.push(std::mem::take(&mut current));
2609 }
2610 }
2611 _ => current.push(c),
2612 }
2613 }
2614 if !current.is_empty() {
2615 tokens.push(current);
2616 }
2617
2618 for token in tokens {
2619 let trimmed = token.trim_end_matches([';', '&', '|']).to_owned();
2620 if trimmed.is_empty() {
2621 continue;
2622 }
2623 if trimmed.starts_with('/')
2624 || trimmed.starts_with("./")
2625 || trimmed.starts_with("../")
2626 || trimmed == ".."
2627 || (trimmed.starts_with('.') && trimmed.contains('/'))
2628 || is_relative_path_token(&trimmed)
2629 {
2630 result.push(trimmed);
2631 }
2632 }
2633 result
2634}
2635
2636fn is_relative_path_token(token: &str) -> bool {
2643 if !token.contains('/') || token.starts_with('/') || token.starts_with('.') {
2645 return false;
2646 }
2647 if token.contains("://") {
2649 return false;
2650 }
2651 if let Some(eq_pos) = token.find('=') {
2653 let key = &token[..eq_pos];
2654 if key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
2655 return false;
2656 }
2657 }
2658 token
2660 .chars()
2661 .next()
2662 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
2663}
2664
2665fn classify_shell_exit(
2671 exit_code: i32,
2672 output: &str,
2673) -> Option<crate::error_taxonomy::ToolErrorCategory> {
2674 use crate::error_taxonomy::ToolErrorCategory;
2675 match exit_code {
2676 126 => Some(ToolErrorCategory::PolicyBlocked),
2678 127 => Some(ToolErrorCategory::PermanentFailure),
2680 _ => {
2681 let lower = output.to_lowercase();
2682 if lower.contains("permission denied") {
2683 Some(ToolErrorCategory::PolicyBlocked)
2684 } else if lower.contains("no such file or directory") {
2685 Some(ToolErrorCategory::PermanentFailure)
2686 } else {
2687 None
2688 }
2689 }
2690 }
2691}
2692
2693fn has_traversal(path: &str) -> bool {
2694 path.split(['/', '\\']).any(|seg| seg == "..")
2695}
2696
2697fn canonicalize_or_nearest_ancestor(path: &std::path::Path) -> std::path::PathBuf {
2709 if let Ok(c) = path.canonicalize() {
2710 return c;
2711 }
2712 let components: Vec<_> = path.components().collect();
2713 let mut base_len = components.len();
2714 let canonical_base = loop {
2715 if base_len == 0 {
2716 break None;
2717 }
2718 let candidate: std::path::PathBuf = components[..base_len].iter().collect();
2719 if let Ok(c) = candidate.canonicalize() {
2720 break Some(c);
2721 }
2722 base_len -= 1;
2723 };
2724 match canonical_base {
2725 Some(base) => components[base_len..]
2726 .iter()
2727 .fold(base, |acc, c| acc.join(c)),
2728 None => std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()),
2729 }
2730}
2731
2732fn extract_bash_blocks(text: &str) -> Vec<&str> {
2733 crate::executor::extract_fenced_blocks(text, "bash")
2734}
2735
2736#[cfg(unix)]
2752async fn send_signal_with_escalation(pid: u32) {
2753 use nix::errno::Errno;
2754 use nix::sys::signal::{Signal, kill};
2755 use nix::unistd::Pid;
2756
2757 let Ok(pid_i32) = i32::try_from(pid) else {
2758 return;
2759 };
2760 let target = Pid::from_raw(pid_i32);
2761
2762 if let Err(e) = kill(target, Signal::SIGTERM)
2763 && e != Errno::ESRCH
2764 {
2765 tracing::debug!(pid, err = %e, "SIGTERM failed");
2766 }
2767 tokio::time::sleep(GRACEFUL_TERM_MS).await;
2768 let _ = Command::new("pkill")
2770 .args(["-KILL", "-P", &pid.to_string()])
2771 .status()
2772 .await;
2773 if let Err(e) = kill(target, Signal::SIGKILL)
2774 && e != Errno::ESRCH
2775 {
2776 tracing::debug!(pid, err = %e, "SIGKILL failed");
2777 }
2778}
2779
2780async fn kill_process_tree(child: &mut tokio::process::Child) {
2786 #[cfg(unix)]
2787 if let Some(pid) = child.id() {
2788 send_signal_with_escalation(pid).await;
2789 }
2790 let _ = child.kill().await;
2791}
2792
2793#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2798pub struct ShellOutputEnvelope {
2799 pub stdout: String,
2801 pub stderr: String,
2803 pub exit_code: i32,
2805 pub truncated: bool,
2807}
2808
2809#[allow(dead_code, clippy::too_many_arguments)]
2811async fn execute_bash(
2812 code: &str,
2813 timeout: Duration,
2814 event_tx: Option<&ToolEventTx>,
2815 cancel_token: Option<&CancellationToken>,
2816 extra_env: Option<&std::collections::HashMap<String, String>>,
2817 env_blocklist: &[String],
2818 sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2819 tool_call_id: &str,
2820) -> (ShellOutputEnvelope, String) {
2821 use std::process::Stdio;
2822
2823 let timeout_secs = timeout.as_secs();
2824 let mut cmd = build_bash_command(code, extra_env, env_blocklist);
2825
2826 if let Err(envelope_err) = apply_sandbox(&mut cmd, sandbox) {
2827 return envelope_err;
2828 }
2829
2830 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2831
2832 let mut child = match cmd.spawn() {
2833 Ok(c) => c,
2834 Err(ref e) => return spawn_error_envelope(e),
2835 };
2836
2837 let stdout = child.stdout.take().expect("stdout piped");
2838 let stderr = child.stderr.take().expect("stderr piped");
2839 let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2840
2841 let mut combined = String::new();
2842 let mut stdout_buf = String::new();
2843 let mut stderr_buf = String::new();
2844 let deadline = tokio::time::Instant::now() + timeout;
2845
2846 match run_bash_stream(
2847 code,
2848 deadline,
2849 cancel_token,
2850 event_tx,
2851 tool_call_id,
2852 &mut line_rx,
2853 &mut combined,
2854 &mut stdout_buf,
2855 &mut stderr_buf,
2856 &mut child,
2857 )
2858 .await
2859 {
2860 BashLoopOutcome::TimedOut => {
2861 let msg = format!("[error] command timed out after {timeout_secs}s");
2862 (
2863 ShellOutputEnvelope {
2864 stdout: stdout_buf,
2865 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2866 exit_code: 1,
2867 truncated: false,
2868 },
2869 msg,
2870 )
2871 }
2872 BashLoopOutcome::Cancelled => (
2873 ShellOutputEnvelope {
2874 stdout: stdout_buf,
2875 stderr: format!("{stderr_buf}operation aborted"),
2876 exit_code: 130,
2877 truncated: false,
2878 },
2879 "[cancelled] operation aborted".to_string(),
2880 ),
2881 BashLoopOutcome::StreamClosed => {
2882 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2883 }
2884 }
2885}
2886
2887fn build_bash_command(
2888 code: &str,
2889 extra_env: Option<&std::collections::HashMap<String, String>>,
2890 env_blocklist: &[String],
2891) -> Command {
2892 let mut cmd = Command::new("bash");
2893 cmd.arg("-c").arg(code);
2894 for (key, _) in std::env::vars() {
2895 if env_blocklist
2896 .iter()
2897 .any(|prefix| key.starts_with(prefix.as_str()))
2898 {
2899 cmd.env_remove(&key);
2900 }
2901 }
2902 if let Some(env) = extra_env {
2903 cmd.envs(env);
2904 }
2905 cmd
2906}
2907
2908fn build_bash_command_with_context(
2913 code: &str,
2914 resolved_env: &HashMap<String, String>,
2915 cwd: &std::path::Path,
2916) -> Command {
2917 let mut cmd = Command::new("bash");
2918 cmd.arg("-c").arg(code);
2919 cmd.env_clear();
2920 cmd.envs(resolved_env);
2921 cmd.current_dir(cwd);
2922 cmd
2923}
2924
2925async fn execute_bash_with_context(
2930 code: &str,
2931 timeout: Duration,
2932 event_tx: Option<&ToolEventTx>,
2933 tool_call_id: &str,
2934 cancel_token: Option<&CancellationToken>,
2935 resolved: &ResolvedContext,
2936 sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2937) -> (ShellOutputEnvelope, String) {
2938 use std::process::Stdio;
2939
2940 let timeout_secs = timeout.as_secs();
2941 let mut cmd = build_bash_command_with_context(code, &resolved.env, &resolved.cwd);
2942
2943 if let Err(envelope_err) = apply_sandbox(&mut cmd, sandbox) {
2944 return envelope_err;
2945 }
2946
2947 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2948
2949 let mut child = match cmd.spawn() {
2950 Ok(c) => c,
2951 Err(ref e) => return spawn_error_envelope(e),
2952 };
2953
2954 let stdout = child.stdout.take().expect("stdout piped");
2955 let stderr = child.stderr.take().expect("stderr piped");
2956 let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2957
2958 let mut combined = String::new();
2959 let mut stdout_buf = String::new();
2960 let mut stderr_buf = String::new();
2961 let deadline = tokio::time::Instant::now() + timeout;
2962
2963 match run_bash_stream(
2964 code,
2965 deadline,
2966 cancel_token,
2967 event_tx,
2968 tool_call_id,
2969 &mut line_rx,
2970 &mut combined,
2971 &mut stdout_buf,
2972 &mut stderr_buf,
2973 &mut child,
2974 )
2975 .await
2976 {
2977 BashLoopOutcome::TimedOut => {
2978 let msg = format!("[error] command timed out after {timeout_secs}s");
2979 (
2980 ShellOutputEnvelope {
2981 stdout: stdout_buf,
2982 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2983 exit_code: 1,
2984 truncated: false,
2985 },
2986 msg,
2987 )
2988 }
2989 BashLoopOutcome::Cancelled => (
2990 ShellOutputEnvelope {
2991 stdout: stdout_buf,
2992 stderr: format!("{stderr_buf}operation aborted"),
2993 exit_code: 130,
2994 truncated: false,
2995 },
2996 "[cancelled] operation aborted".to_string(),
2997 ),
2998 BashLoopOutcome::StreamClosed => {
2999 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
3000 }
3001 }
3002}
3003
3004fn apply_sandbox(
3005 cmd: &mut Command,
3006 sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
3007) -> Result<(), (ShellOutputEnvelope, String)> {
3008 if let Some((sb, policy)) = sandbox
3010 && let Err(err) = sb.wrap(cmd, policy)
3011 {
3012 let msg = format!("[error] sandbox setup failed: {err}");
3013 return Err((
3014 ShellOutputEnvelope {
3015 stdout: String::new(),
3016 stderr: msg.clone(),
3017 exit_code: 1,
3018 truncated: false,
3019 },
3020 msg,
3021 ));
3022 }
3023 Ok(())
3024}
3025
3026fn spawn_error_envelope(e: &std::io::Error) -> (ShellOutputEnvelope, String) {
3027 let msg = format!("[error] {e}");
3028 (
3029 ShellOutputEnvelope {
3030 stdout: String::new(),
3031 stderr: msg.clone(),
3032 exit_code: 1,
3033 truncated: false,
3034 },
3035 msg,
3036 )
3037}
3038
3039fn spawn_output_readers(
3045 stdout: tokio::process::ChildStdout,
3046 stderr: tokio::process::ChildStderr,
3047) -> (
3048 tokio::sync::mpsc::Receiver<(bool, String)>,
3049 tokio::task::JoinSet<()>,
3050) {
3051 use tokio::io::{AsyncBufReadExt, BufReader};
3052
3053 let (line_tx, line_rx) = tokio::sync::mpsc::channel::<(bool, String)>(64);
3054 let mut readers = tokio::task::JoinSet::new();
3055
3056 let stdout_tx = line_tx.clone();
3057 readers.spawn(async move {
3058 let mut reader = BufReader::new(stdout);
3059 let mut buf = String::new();
3060 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
3061 let _ = stdout_tx.send((false, buf.clone())).await;
3062 buf.clear();
3063 }
3064 });
3065
3066 readers.spawn(async move {
3067 let mut reader = BufReader::new(stderr);
3068 let mut buf = String::new();
3069 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
3070 let _ = line_tx.send((true, buf.clone())).await;
3071 buf.clear();
3072 }
3073 });
3074
3075 (line_rx, readers)
3076}
3077
3078enum BashLoopOutcome {
3083 StreamClosed,
3084 TimedOut,
3085 Cancelled,
3086}
3087
3088#[allow(clippy::too_many_arguments)]
3089async fn run_bash_stream(
3090 code: &str,
3091 deadline: tokio::time::Instant,
3092 cancel_token: Option<&CancellationToken>,
3093 event_tx: Option<&ToolEventTx>,
3094 tool_call_id: &str,
3095 line_rx: &mut tokio::sync::mpsc::Receiver<(bool, String)>,
3096 combined: &mut String,
3097 stdout_buf: &mut String,
3098 stderr_buf: &mut String,
3099 child: &mut tokio::process::Child,
3100) -> BashLoopOutcome {
3101 loop {
3102 tokio::select! {
3103 line = line_rx.recv() => {
3104 match line {
3105 Some((is_stderr, chunk)) => {
3106 let interleaved = if is_stderr {
3107 format!("[stderr] {chunk}")
3108 } else {
3109 chunk.clone()
3110 };
3111 if let Some(tx) = event_tx {
3112 let _ = tx.try_send(ToolEvent::OutputChunk {
3114 tool_name: ToolName::new("bash"),
3115 command: code.to_owned(),
3116 chunk: interleaved.clone(),
3117 tool_call_id: tool_call_id.to_owned(),
3118 skill_name: None,
3119 });
3120 }
3121 combined.push_str(&interleaved);
3122 if is_stderr {
3123 stderr_buf.push_str(&chunk);
3124 } else {
3125 stdout_buf.push_str(&chunk);
3126 }
3127 }
3128 None => return BashLoopOutcome::StreamClosed,
3129 }
3130 }
3131 () = tokio::time::sleep_until(deadline) => {
3132 kill_process_tree(child).await;
3133 return BashLoopOutcome::TimedOut;
3134 }
3135 () = async {
3136 match cancel_token {
3137 Some(t) => t.cancelled().await,
3138 None => std::future::pending().await,
3139 }
3140 } => {
3141 kill_process_tree(child).await;
3142 return BashLoopOutcome::Cancelled;
3143 }
3144 }
3145 }
3146}
3147
3148async fn finalize_envelope(
3149 child: &mut tokio::process::Child,
3150 combined: String,
3151 stdout_buf: String,
3152 stderr_buf: String,
3153) -> (ShellOutputEnvelope, String) {
3154 let status = child.wait().await;
3155 let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(1);
3156
3157 if combined.is_empty() {
3158 (
3159 ShellOutputEnvelope {
3160 stdout: String::new(),
3161 stderr: String::new(),
3162 exit_code,
3163 truncated: false,
3164 },
3165 "(no output)".to_string(),
3166 )
3167 } else {
3168 (
3169 ShellOutputEnvelope {
3170 stdout: stdout_buf.trim_end().to_owned(),
3171 stderr: stderr_buf.trim_end().to_owned(),
3172 exit_code,
3173 truncated: false,
3174 },
3175 combined,
3176 )
3177 }
3178}
3179
3180#[cfg(test)]
3181mod tests;