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 "rm -rf /", "sudo", "mkfs", "dd if=", "curl", "wget", "nc ", "ncat", "netcat", "shutdown",
71 "reboot", "halt",
72];
73
74#[must_use]
92pub fn is_blocked_rm_worktrees(cmd: &str) -> bool {
93 let lower = cmd.to_lowercase();
94 let tokens: Vec<&str> = lower.split_whitespace().collect();
95
96 let Some(first) = tokens.first() else {
98 return false;
99 };
100 if first.rsplit('/').next().unwrap_or(first) != "rm" {
101 return false;
102 }
103
104 if !lower.contains(".git/worktrees") {
105 return false;
106 }
107
108 let mut has_recursive = false;
109 let mut has_force = false;
110
111 for token in &tokens[1..] {
112 if *token == "--recursive" {
113 has_recursive = true;
114 } else if *token == "--force" {
115 has_force = true;
116 } else if let Some(flags) = token.strip_prefix('-').filter(|f| !f.starts_with('-')) {
117 if flags.contains('r') || flags.contains('R') {
119 has_recursive = true;
120 }
121 if flags.contains('f') {
122 has_force = true;
123 }
124 }
125 }
126
127 has_recursive && has_force
128}
129
130#[cfg(unix)]
132const GRACEFUL_TERM_MS: Duration = Duration::from_millis(250);
133
134pub const DEFAULT_BLOCKED_COMMANDS: &[&str] = DEFAULT_BLOCKED;
147
148pub const SHELL_INTERPRETERS: &[&str] =
154 &["bash", "sh", "zsh", "fish", "dash", "ksh", "csh", "tcsh"];
155
156const SUBSHELL_METACHARS: &[&str] = &["$(", "`", "<(", ">("];
160
161#[must_use]
169pub fn check_blocklist(command: &str, blocklist: &[String]) -> Option<String> {
170 let lower = command.to_lowercase();
171 for meta in SUBSHELL_METACHARS {
173 if lower.contains(meta) {
174 return Some((*meta).to_owned());
175 }
176 }
177 let cleaned = strip_shell_escapes(&lower);
178 let commands = tokenize_commands(&cleaned);
179 for cmd_tokens in &commands {
180 let joined = cmd_tokens.join(" ");
181 if is_blocked_rm_worktrees(&joined) {
182 return Some("rm --recursive --force .git/worktrees".to_owned());
183 }
184 }
185 for blocked in blocklist {
186 for cmd_tokens in &commands {
187 if tokens_match_pattern(cmd_tokens, blocked) {
188 return Some(blocked.clone());
189 }
190 }
191 }
192 None
193}
194
195#[must_use]
200pub fn effective_shell_command<'a>(binary: &str, args: &'a [String]) -> Option<&'a str> {
201 let base = binary.rsplit('/').next().unwrap_or(binary);
202 if !SHELL_INTERPRETERS.contains(&base) {
203 return None;
204 }
205 let pos = args.iter().position(|a| a == "-c")?;
207 args.get(pos + 1).map(String::as_str)
208}
209
210pub const NETWORK_COMMANDS: &[&str] = &["curl", "wget", "nc ", "ncat", "netcat"];
217
218#[derive(Debug)]
222pub(crate) struct ShellPolicy {
223 pub(crate) blocked_commands: Vec<String>,
224}
225
226#[derive(Clone, Debug)]
233pub struct ShellPolicyHandle {
234 inner: Arc<ArcSwap<ShellPolicy>>,
235}
236
237impl ShellPolicyHandle {
238 pub fn rebuild(&self, config: &crate::config::ShellConfig) {
247 let policy = Arc::new(ShellPolicy {
248 blocked_commands: compute_blocked_commands(config),
249 });
250 self.inner.store(policy);
251 }
252
253 #[must_use]
255 pub fn snapshot_blocked(&self) -> Vec<String> {
256 self.inner.load().blocked_commands.clone()
257 }
258}
259
260pub(crate) fn compute_blocked_commands(config: &crate::config::ShellConfig) -> Vec<String> {
264 let allowed: Vec<String> = config
265 .allowed_commands
266 .iter()
267 .map(|s| s.to_lowercase())
268 .collect();
269 let mut blocked: Vec<String> = DEFAULT_BLOCKED
270 .iter()
271 .filter(|s| !allowed.contains(&s.to_lowercase()))
272 .map(|s| (*s).to_owned())
273 .collect();
274 blocked.extend(config.blocked_commands.iter().map(|s| s.to_lowercase()));
275 if !config.allow_network {
276 for cmd in NETWORK_COMMANDS {
277 let lower = cmd.to_lowercase();
278 if !blocked.contains(&lower) {
279 blocked.push(lower);
280 }
281 }
282 }
283 blocked.sort();
284 blocked.dedup();
285 blocked
286}
287
288#[derive(Deserialize, JsonSchema)]
289pub(crate) struct BashParams {
290 command: String,
292 #[serde(default)]
298 background: bool,
299}
300
301#[derive(Debug)]
324#[allow(clippy::struct_excessive_bools)]
325pub struct ShellExecutor {
326 timeout: Duration,
327 policy: Arc<ArcSwap<ShellPolicy>>,
328 confirm_patterns: Vec<String>,
329 env_blocklist: Vec<String>,
330 audit_logger: Option<Arc<AuditLogger>>,
331 tool_event_tx: Option<ToolEventTx>,
332 permission_policy: Option<PermissionPolicy>,
333 output_filter_registry: Option<OutputFilterRegistry>,
334 cancel_token: Option<CancellationToken>,
335 skill_env: RwLock<Option<std::collections::HashMap<String, String>>>,
336 transactional: bool,
337 auto_rollback: bool,
338 auto_rollback_exit_codes: Vec<i32>,
339 snapshot_required: bool,
340 max_snapshot_bytes: u64,
341 transaction_scope_matchers: Vec<globset::GlobMatcher>,
342 checkpoint_stack: Arc<Mutex<CheckpointStack>>,
344 checkpoints_enabled: bool,
346 sandbox: Option<Arc<dyn Sandbox>>,
347 sandbox_policy: Option<SandboxPolicy>,
348 background_runs: Arc<Mutex<HashMap<RunId, BackgroundHandle>>>,
350 max_background_runs: usize,
352 background_timeout: Duration,
354 shutting_down: Arc<AtomicBool>,
356 background_completion_tx: Option<tokio::sync::mpsc::Sender<BackgroundCompletion>>,
360 environments: Arc<HashMap<String, ExecutionContext>>,
363 allowed_paths_canonical: Vec<PathBuf>,
366 default_env: Option<String>,
368 risk_chain: Option<Arc<RiskChainAccumulator>>,
370 risk_chain_threshold: f32,
372 task_supervisor: Option<DebugIgnored<TaskSupervisor>>,
377}
378
379struct DebugIgnored<T>(T);
384
385impl<T> std::fmt::Debug for DebugIgnored<T> {
386 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387 f.write_str("<...>")
388 }
389}
390
391impl<T> std::ops::Deref for DebugIgnored<T> {
392 type Target = T;
393 fn deref(&self) -> &T {
394 &self.0
395 }
396}
397
398#[derive(Debug)]
404pub(crate) struct ResolvedContext {
405 pub(crate) cwd: PathBuf,
407 pub(crate) env: HashMap<String, String>,
409 pub(crate) name: Option<String>,
411 #[allow(dead_code)]
414 pub(crate) trusted: bool,
415}
416
417impl ShellExecutor {
418 #[must_use]
424 pub fn new(config: &ShellConfig) -> Self {
425 let policy = Arc::new(ArcSwap::from_pointee(ShellPolicy {
426 blocked_commands: compute_blocked_commands(config),
427 }));
428
429 let allowed_paths: Vec<PathBuf> = if config.allowed_paths.is_empty() {
430 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
431 } else {
432 config.allowed_paths.iter().map(PathBuf::from).collect()
433 };
434 let allowed_paths_canonical: Vec<PathBuf> = allowed_paths
435 .iter()
436 .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
437 .collect();
438
439 Self {
440 timeout: Duration::from_secs(config.timeout),
441 policy,
442 confirm_patterns: config.confirm_patterns.clone(),
443 env_blocklist: config.env_blocklist.clone(),
444 audit_logger: None,
445 tool_event_tx: None,
446 permission_policy: None,
447 output_filter_registry: None,
448 cancel_token: None,
449 skill_env: RwLock::new(None),
450 transactional: config.transactional,
451 auto_rollback: config.auto_rollback,
452 auto_rollback_exit_codes: config.auto_rollback_exit_codes.clone(),
453 snapshot_required: config.snapshot_required,
454 max_snapshot_bytes: config.max_snapshot_bytes,
455 transaction_scope_matchers: build_scope_matchers(&config.transaction_scope),
456 checkpoint_stack: Arc::new(Mutex::new(CheckpointStack::new(config.max_checkpoints))),
457 checkpoints_enabled: config.checkpoints_enabled,
458 sandbox: None,
459 sandbox_policy: None,
460 background_runs: Arc::new(Mutex::new(HashMap::new())),
461 max_background_runs: config.max_background_runs,
462 background_timeout: Duration::from_secs(config.background_timeout_secs),
463 shutting_down: Arc::new(AtomicBool::new(false)),
464 background_completion_tx: None,
465 environments: Arc::new(HashMap::new()),
466 allowed_paths_canonical,
467 default_env: None,
468 risk_chain: None,
469 risk_chain_threshold: config.risk_chain_threshold.unwrap_or(0.7),
470 task_supervisor: None::<DebugIgnored<TaskSupervisor>>,
471 }
472 }
473
474 #[must_use]
479 pub fn with_sandbox(mut self, sandbox: Arc<dyn Sandbox>, policy: SandboxPolicy) -> Self {
480 self.sandbox = Some(sandbox);
481 self.sandbox_policy = Some(policy);
482 self
483 }
484
485 #[must_use]
490 pub fn with_risk_chain(mut self, accumulator: Arc<RiskChainAccumulator>) -> Self {
491 self.risk_chain = Some(accumulator);
492 self
493 }
494
495 pub fn with_execution_config(
506 self,
507 config: &zeph_config::ExecutionConfig,
508 ) -> Result<Self, String> {
509 let registry: HashMap<String, ExecutionContext> = config
510 .environments
511 .iter()
512 .map(|e| {
513 let ctx = ExecutionContext::trusted_from_parts(
514 Some(e.name.clone()),
515 Some(std::path::PathBuf::from(&e.cwd)),
516 e.env.clone(),
517 );
518 (e.name.clone(), ctx)
519 })
520 .collect();
521 self.with_environments(registry, config.default_env.clone())
522 }
523
524 pub fn with_environments(
534 mut self,
535 environments: HashMap<String, ExecutionContext>,
536 default_env: Option<String>,
537 ) -> Result<Self, String> {
538 for (name, ctx) in &environments {
540 if let Some(cwd) = ctx.cwd() {
541 let canonical = cwd.canonicalize().map_err(|e| {
542 format!(
543 "execution environment '{name}': cwd '{}' cannot be canonicalized: {e}",
544 cwd.display()
545 )
546 })?;
547 if !self
548 .allowed_paths_canonical
549 .iter()
550 .any(|p| canonical.starts_with(p))
551 {
552 return Err(format!(
553 "execution environment '{name}': cwd '{}' is outside allowed_paths",
554 cwd.display()
555 ));
556 }
557 }
558 }
559 self.environments = Arc::new(environments);
560 self.default_env = default_env;
561 Ok(self)
562 }
563
564 pub fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
566 *self.skill_env.write() = env;
567 }
568
569 #[must_use]
571 pub fn with_audit(mut self, logger: Arc<AuditLogger>) -> Self {
572 self.audit_logger = Some(logger);
573 self
574 }
575
576 #[must_use]
581 pub fn with_tool_event_tx(mut self, tx: ToolEventTx) -> Self {
582 self.tool_event_tx = Some(tx);
583 self
584 }
585
586 #[must_use]
592 pub fn with_background_completion_tx(
593 mut self,
594 tx: tokio::sync::mpsc::Sender<BackgroundCompletion>,
595 ) -> Self {
596 self.background_completion_tx = Some(tx);
597 self
598 }
599
600 #[must_use]
606 pub fn with_task_supervisor(mut self, supervisor: TaskSupervisor) -> Self {
607 self.task_supervisor = Some(DebugIgnored(supervisor));
608 self
609 }
610
611 #[must_use]
616 pub fn with_permissions(mut self, policy: PermissionPolicy) -> Self {
617 self.permission_policy = Some(policy);
618 self
619 }
620
621 #[must_use]
624 pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
625 self.cancel_token = Some(token);
626 self
627 }
628
629 #[must_use]
632 pub fn with_output_filters(mut self, registry: OutputFilterRegistry) -> Self {
633 self.output_filter_registry = Some(registry);
634 self
635 }
636
637 #[must_use]
643 pub fn background_runs_snapshot(&self) -> Vec<background::BackgroundRunSnapshot> {
644 let runs = self.background_runs.lock();
645 runs.iter()
646 .map(|(id, h)| {
647 #[allow(clippy::cast_possible_truncation)]
648 let elapsed_ms = h.elapsed().as_millis() as u64;
649 background::BackgroundRunSnapshot {
650 run_id: id.to_string(),
651 command: h.command.clone(),
652 elapsed_ms,
653 }
654 })
655 .collect()
656 }
657
658 #[must_use]
664 pub fn policy_handle(&self) -> ShellPolicyHandle {
665 ShellPolicyHandle {
666 inner: Arc::clone(&self.policy),
667 }
668 }
669
670 #[cfg_attr(
676 feature = "profiling",
677 tracing::instrument(name = "tools.shell.execute", skip_all, fields(exit_code = tracing::field::Empty, duration_ms = tracing::field::Empty))
678 )]
679 pub async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
680 self.execute_inner(response, true).await
681 }
682
683 async fn execute_inner(
684 &self,
685 response: &str,
686 skip_confirm: bool,
687 ) -> Result<Option<ToolOutput>, ToolError> {
688 let blocks = extract_bash_blocks(response);
689 if blocks.is_empty() {
690 return Ok(None);
691 }
692
693 let resolved = self.resolve_context(None)?;
696
697 let mut outputs = Vec::with_capacity(blocks.len());
698 let mut cumulative_filter_stats: Option<FilterStats> = None;
699 let mut last_envelope: Option<ShellOutputEnvelope> = None;
700 #[allow(clippy::cast_possible_truncation)]
701 let blocks_executed = blocks.len() as u32;
702
703 for block in &blocks {
704 let (output_line, per_block_stats, envelope) =
705 self.execute_block(block, skip_confirm, &resolved).await?;
706 if let Some(fs) = per_block_stats {
707 let stats = cumulative_filter_stats.get_or_insert_with(FilterStats::default);
708 stats.raw_chars += fs.raw_chars;
709 stats.filtered_chars += fs.filtered_chars;
710 stats.raw_lines += fs.raw_lines;
711 stats.filtered_lines += fs.filtered_lines;
712 stats.confidence = Some(match (stats.confidence, fs.confidence) {
713 (Some(prev), Some(cur)) => crate::filter::worse_confidence(prev, cur),
714 (Some(prev), None) => prev,
715 (None, Some(cur)) => cur,
716 (None, None) => unreachable!(),
717 });
718 if stats.command.is_none() {
719 stats.command = fs.command;
720 }
721 if stats.kept_lines.is_empty() && !fs.kept_lines.is_empty() {
722 stats.kept_lines = fs.kept_lines;
723 }
724 }
725 last_envelope = Some(envelope);
726 outputs.push(output_line);
727 }
728
729 let raw_response = last_envelope
730 .as_ref()
731 .and_then(|e| serde_json::to_value(e).ok());
732
733 Ok(Some(ToolOutput {
734 tool_name: ToolName::new("bash"),
735 summary: outputs.join("\n\n"),
736 blocks_executed,
737 filter_stats: cumulative_filter_stats,
738 diff: None,
739 streamed: self.tool_event_tx.is_some(),
740 terminal_id: None,
741 locations: None,
742 raw_response,
743 claim_source: Some(ClaimSource::Shell),
744 ..Default::default()
745 }))
746 }
747
748 async fn execute_block(
749 &self,
750 block: &str,
751 skip_confirm: bool,
752 resolved: &ResolvedContext,
753 ) -> Result<(String, Option<FilterStats>, ShellOutputEnvelope), ToolError> {
754 self.check_permissions(block, skip_confirm).await?;
755 self.validate_sandbox_with_cwd(block, &resolved.cwd)?;
756
757 let (snapshot, snapshot_warning, snap_paths) = self.capture_snapshot_for(block)?;
758
759 if let Some(ref tx) = self.tool_event_tx {
760 let sandbox_profile = self
761 .sandbox_policy
762 .as_ref()
763 .map(|p| format!("{:?}", p.profile));
764 let _ = tx.try_send(ToolEvent::Started {
766 tool_name: ToolName::new("bash"),
767 command: block.to_owned(),
768 sandbox_profile,
769 resolved_cwd: Some(resolved.cwd.display().to_string()),
770 execution_env: resolved.name.clone(),
771 });
772 }
773
774 let start = Instant::now();
775 let sandbox_pair = self
776 .sandbox
777 .as_ref()
778 .zip(self.sandbox_policy.as_ref())
779 .map(|(sb, pol)| (sb.as_ref(), pol));
780 let (mut envelope, out) = execute_bash_with_context(
781 block,
782 self.timeout,
783 self.tool_event_tx.as_ref(),
784 "",
785 self.cancel_token.as_ref(),
786 resolved,
787 sandbox_pair,
788 )
789 .await;
790 let exit_code = envelope.exit_code;
791 if exit_code == 130
792 && self
793 .cancel_token
794 .as_ref()
795 .is_some_and(CancellationToken::is_cancelled)
796 {
797 return Err(ToolError::Cancelled);
798 }
799 #[allow(clippy::cast_possible_truncation)]
800 let duration_ms = start.elapsed().as_millis() as u64;
801
802 if let Some(snap) = snapshot
803 && let Some(surviving) = self
804 .maybe_rollback(snap, block, exit_code, duration_ms)
805 .await
806 && self.checkpoints_enabled
807 {
808 self.record_checkpoint(surviving, block, snap_paths);
809 }
810
811 if let Some(err) = self
812 .classify_and_audit(block, &out, exit_code, duration_ms)
813 .await
814 {
815 self.emit_completed(block, &out, false, None, None).await;
816 return Err(err);
817 }
818
819 let (filtered, per_block_stats) = self.apply_output_filter(block, &out, exit_code);
820
821 self.emit_completed(
822 block,
823 &out,
824 !out.contains("[error]"),
825 per_block_stats.clone(),
826 None,
827 )
828 .await;
829
830 envelope.truncated = filtered.len() < out.len();
832
833 let audit_result = if out.contains("[error]") || out.contains("[stderr]") {
834 AuditResult::Error {
835 message: out.clone(),
836 }
837 } else {
838 AuditResult::Success
839 };
840 self.log_audit_with_context(
841 block,
842 audit_result,
843 duration_ms,
844 None,
845 Some(exit_code),
846 envelope.truncated,
847 resolved,
848 )
849 .await;
850
851 let output_line = match snapshot_warning {
852 Some(warn) => format!("{warn}\n$ {block}\n{filtered}"),
853 None => format!("$ {block}\n{filtered}"),
854 };
855 Ok((output_line, per_block_stats, envelope))
856 }
857
858 #[allow(clippy::too_many_lines)]
863 #[tracing::instrument(name = "tools.shell.execute_block", skip(self, resolved), level = "info",
864 fields(cwd = %resolved.cwd.display(), env_name = resolved.name.as_deref().unwrap_or("")))]
865 async fn execute_block_with_context(
866 &self,
867 command: &str,
868 skip_confirm: bool,
869 resolved: &ResolvedContext,
870 tool_call_id: &str,
871 ) -> Result<Option<ToolOutput>, ToolError> {
872 self.check_permissions(command, skip_confirm).await?;
873 self.validate_sandbox_with_cwd(command, &resolved.cwd)?;
874
875 let (snapshot, snapshot_warning, snap_paths) = self.capture_snapshot_for(command)?;
876
877 if let Some(ref tx) = self.tool_event_tx {
878 let sandbox_profile = self
879 .sandbox_policy
880 .as_ref()
881 .map(|p| format!("{:?}", p.profile));
882 let _ = tx.try_send(ToolEvent::Started {
883 tool_name: ToolName::new("bash"),
884 command: command.to_owned(),
885 sandbox_profile,
886 resolved_cwd: Some(resolved.cwd.display().to_string()),
887 execution_env: resolved.name.clone(),
888 });
889 }
890
891 let start = Instant::now();
892 let sandbox_pair = self
893 .sandbox
894 .as_ref()
895 .zip(self.sandbox_policy.as_ref())
896 .map(|(sb, pol)| (sb.as_ref(), pol));
897 let (mut envelope, out) = execute_bash_with_context(
898 command,
899 self.timeout,
900 self.tool_event_tx.as_ref(),
901 tool_call_id,
902 self.cancel_token.as_ref(),
903 resolved,
904 sandbox_pair,
905 )
906 .await;
907 let exit_code = envelope.exit_code;
908 if exit_code == 130
909 && self
910 .cancel_token
911 .as_ref()
912 .is_some_and(CancellationToken::is_cancelled)
913 {
914 return Err(ToolError::Cancelled);
915 }
916 #[allow(clippy::cast_possible_truncation)]
917 let duration_ms = start.elapsed().as_millis() as u64;
918
919 if let Some(snap) = snapshot
920 && let Some(surviving) = self
921 .maybe_rollback(snap, command, exit_code, duration_ms)
922 .await
923 && self.checkpoints_enabled
924 {
925 self.record_checkpoint(surviving, command, snap_paths);
926 }
927
928 if let Some(err) = self
929 .classify_and_audit(command, &out, exit_code, duration_ms)
930 .await
931 {
932 self.emit_completed(command, &out, false, None, None).await;
933 return Err(err);
934 }
935
936 let (filtered, per_block_stats) = self.apply_output_filter(command, &out, exit_code);
937
938 self.emit_completed(
939 command,
940 &out,
941 !out.contains("[error]"),
942 per_block_stats.clone(),
943 None,
944 )
945 .await;
946
947 envelope.truncated = filtered.len() < out.len();
948
949 let audit_result = if out.contains("[error]") || out.contains("[stderr]") {
950 AuditResult::Error {
951 message: out.clone(),
952 }
953 } else {
954 AuditResult::Success
955 };
956 self.log_audit_with_context(
957 command,
958 audit_result,
959 duration_ms,
960 None,
961 Some(exit_code),
962 envelope.truncated,
963 resolved,
964 )
965 .await;
966
967 let output_line = match snapshot_warning {
968 Some(warn) => format!("{warn}\n$ {command}\n{filtered}"),
969 None => format!("$ {command}\n{filtered}"),
970 };
971 Ok(Some(ToolOutput {
972 tool_name: ToolName::new("bash"),
973 summary: output_line,
974 blocks_executed: 1,
975 filter_stats: per_block_stats,
976 diff: None,
977 streamed: false,
978 terminal_id: None,
979 locations: None,
980 raw_response: None,
981 claim_source: Some(ClaimSource::Shell),
982 ..Default::default()
983 }))
984 }
985
986 #[allow(clippy::type_complexity)]
987 fn capture_snapshot_for(
988 &self,
989 block: &str,
990 ) -> Result<
991 (
992 Option<TransactionSnapshot>,
993 Option<String>,
994 Vec<std::path::PathBuf>,
995 ),
996 ToolError,
997 > {
998 if !(self.transactional || self.checkpoints_enabled) || !is_write_command(block) {
999 return Ok((None, None, Vec::new()));
1000 }
1001 let raw_paths = affected_paths(block, &self.transaction_scope_matchers);
1002 if raw_paths.is_empty() {
1003 return Ok((None, None, Vec::new()));
1004 }
1005 let paths: Vec<std::path::PathBuf> = raw_paths
1011 .into_iter()
1012 .filter(|p| {
1013 let s = p.to_string_lossy();
1014 if has_traversal(&s) {
1015 tracing::warn!(
1016 path = %p.display(),
1017 "checkpoint: skipping path with traversal sequence"
1018 );
1019 return false;
1020 }
1021 if !self.allowed_paths_canonical.is_empty() {
1022 let canonical = canonicalize_or_nearest_ancestor(p);
1023 if !self
1024 .allowed_paths_canonical
1025 .iter()
1026 .any(|a| canonical.starts_with(a))
1027 {
1028 tracing::warn!(
1029 path = %p.display(),
1030 "checkpoint: skipping out-of-sandbox path"
1031 );
1032 return false;
1033 }
1034 }
1035 true
1036 })
1037 .collect();
1038 if paths.is_empty() {
1039 return Ok((None, None, Vec::new()));
1040 }
1041 match TransactionSnapshot::capture(&paths, self.max_snapshot_bytes) {
1042 Ok(snap) => {
1043 tracing::debug!(
1044 files = snap.file_count(),
1045 bytes = snap.total_bytes(),
1046 "transaction snapshot captured"
1047 );
1048 Ok((Some(snap), None, paths))
1049 }
1050 Err(e) if self.snapshot_required => Err(ToolError::SnapshotFailed {
1051 reason: e.to_string(),
1052 }),
1053 Err(e) => {
1054 tracing::warn!(err = %e, "transaction snapshot failed, proceeding without rollback");
1055 Ok((
1056 None,
1057 Some(format!("[warn] snapshot failed: {e}; rollback unavailable")),
1058 Vec::new(),
1059 ))
1060 }
1061 }
1062 }
1063
1064 async fn maybe_rollback(
1070 &self,
1071 snap: TransactionSnapshot,
1072 block: &str,
1073 exit_code: i32,
1074 duration_ms: u64,
1075 ) -> Option<TransactionSnapshot> {
1076 let should_rollback = self.auto_rollback
1077 && if self.auto_rollback_exit_codes.is_empty() {
1078 exit_code >= 2
1079 } else {
1080 self.auto_rollback_exit_codes.contains(&exit_code)
1081 };
1082 if !should_rollback {
1083 return Some(snap);
1085 }
1086 match snap.rollback() {
1087 Ok(report) => {
1088 tracing::info!(
1089 restored = report.restored_count,
1090 deleted = report.deleted_count,
1091 "transaction rollback completed"
1092 );
1093 self.log_audit(
1094 block,
1095 AuditResult::Rollback {
1096 restored: report.restored_count,
1097 deleted: report.deleted_count,
1098 },
1099 duration_ms,
1100 None,
1101 Some(exit_code),
1102 false,
1103 )
1104 .await;
1105 if let Some(ref tx) = self.tool_event_tx {
1106 let _ = tx
1108 .send(ToolEvent::Rollback {
1109 tool_name: ToolName::new("bash"),
1110 command: block.to_owned(),
1111 restored_count: report.restored_count,
1112 deleted_count: report.deleted_count,
1113 })
1114 .await;
1115 }
1116 }
1117 Err(e) => {
1118 tracing::error!(err = %e, "transaction rollback failed");
1119 }
1120 }
1121 None
1122 }
1123
1124 fn record_checkpoint(
1130 &self,
1131 snap: TransactionSnapshot,
1132 command: &str,
1133 paths: Vec<std::path::PathBuf>,
1134 ) {
1135 use std::time::{SystemTime, UNIX_EPOCH};
1136 let captured_at_secs = SystemTime::now()
1137 .duration_since(UNIX_EPOCH)
1138 .unwrap_or_default()
1139 .as_secs();
1140 let mut stack = self.checkpoint_stack.lock();
1141 stack.record(Checkpoint {
1142 before_snapshot: snap,
1143 command: command.to_owned(),
1144 paths,
1145 captured_at_secs,
1146 });
1147 }
1148
1149 async fn classify_and_audit(
1150 &self,
1151 block: &str,
1152 out: &str,
1153 exit_code: i32,
1154 duration_ms: u64,
1155 ) -> Option<ToolError> {
1156 if out.contains("[error] command timed out") {
1157 self.log_audit(
1158 block,
1159 AuditResult::Timeout,
1160 duration_ms,
1161 None,
1162 Some(exit_code),
1163 false,
1164 )
1165 .await;
1166 return Some(ToolError::Timeout {
1167 timeout_secs: self.timeout.as_secs(),
1168 });
1169 }
1170
1171 if let Some(category) = classify_shell_exit(exit_code, out) {
1172 return Some(ToolError::Shell {
1173 exit_code,
1174 category,
1175 message: out.lines().take(3).collect::<Vec<_>>().join("; "),
1176 });
1177 }
1178
1179 None
1180 }
1181
1182 fn apply_output_filter(
1183 &self,
1184 block: &str,
1185 out: &str,
1186 exit_code: i32,
1187 ) -> (String, Option<FilterStats>) {
1188 let sanitized = sanitize_output(out);
1189 if let Some(ref registry) = self.output_filter_registry {
1190 match registry.apply(block, &sanitized, exit_code) {
1191 Some(fr) => {
1192 tracing::debug!(
1193 command = block,
1194 raw = fr.raw_chars,
1195 filtered = fr.filtered_chars,
1196 savings_pct = fr.savings_pct(),
1197 "output filter applied"
1198 );
1199 let stats = FilterStats {
1200 raw_chars: fr.raw_chars,
1201 filtered_chars: fr.filtered_chars,
1202 raw_lines: fr.raw_lines,
1203 filtered_lines: fr.filtered_lines,
1204 confidence: Some(fr.confidence),
1205 command: Some(block.to_owned()),
1206 kept_lines: fr.kept_lines.clone(),
1207 };
1208 (fr.output, Some(stats))
1209 }
1210 None => (sanitized, None),
1211 }
1212 } else {
1213 (sanitized, None)
1214 }
1215 }
1216
1217 async fn emit_completed(
1218 &self,
1219 command: &str,
1220 output: &str,
1221 success: bool,
1222 filter_stats: Option<FilterStats>,
1223 run_id: Option<RunId>,
1224 ) {
1225 if let Some(ref tx) = self.tool_event_tx {
1226 let _ = tx
1228 .send(ToolEvent::Completed {
1229 tool_name: ToolName::new("bash"),
1230 command: command.to_owned(),
1231 output: output.to_owned(),
1232 success,
1233 filter_stats,
1234 diff: None,
1235 run_id,
1236 })
1237 .await;
1238 }
1239 }
1240
1241 #[allow(clippy::too_many_lines)]
1243 async fn check_permissions(&self, block: &str, skip_confirm: bool) -> Result<(), ToolError> {
1244 let normalized = deobfuscate::deobfuscate(block);
1246 let effective = normalized.as_str();
1247
1248 let blocked_cmd = self
1253 .find_blocked_command(block)
1254 .or_else(|| self.find_blocked_command(effective));
1255 if let Some(blocked) = blocked_cmd {
1256 let fix = safe_fix::suggest_fix(effective);
1257 let err = if let Some(suggestion) = fix {
1258 let reason = format!("{blocked} — suggestion: {}", suggestion.alternative);
1259 self.log_audit(
1260 block,
1261 AuditResult::Blocked {
1262 reason: format!("blocked command: {reason}"),
1263 },
1264 0,
1265 None,
1266 None,
1267 false,
1268 )
1269 .await;
1270 ToolError::BlockedWithFix {
1271 command: blocked,
1272 suggestion: Some(suggestion),
1273 }
1274 } else {
1275 self.log_audit(
1276 block,
1277 AuditResult::Blocked {
1278 reason: format!("blocked command: {blocked}"),
1279 },
1280 0,
1281 None,
1282 None,
1283 false,
1284 )
1285 .await;
1286 ToolError::Blocked { command: blocked }
1287 };
1288 return Err(err);
1289 }
1290
1291 if let Some(ref policy) = self.permission_policy {
1292 match policy.check("bash", effective) {
1293 PermissionAction::Deny => {
1294 let err = match safe_fix::suggest_fix(effective) {
1295 Some(suggestion) => ToolError::BlockedWithFix {
1296 command: effective.to_owned(),
1297 suggestion: Some(suggestion),
1298 },
1299 None => ToolError::Blocked {
1300 command: effective.to_owned(),
1301 },
1302 };
1303 self.log_audit(
1304 block,
1305 AuditResult::Blocked {
1306 reason: "denied by permission policy".to_owned(),
1307 },
1308 0,
1309 None,
1310 None,
1311 false,
1312 )
1313 .await;
1314 return Err(err);
1315 }
1316 PermissionAction::Ask if !skip_confirm => {
1317 return Err(ToolError::ConfirmationRequired {
1318 command: effective.to_owned(),
1319 });
1320 }
1321 _ => {}
1322 }
1323 } else if !skip_confirm {
1324 let confirm_pattern = self
1327 .find_confirm_command(block)
1328 .or_else(|| self.find_confirm_command(effective));
1329 if let Some(pattern) = confirm_pattern {
1330 return Err(ToolError::ConfirmationRequired {
1331 command: pattern.to_owned(),
1332 });
1333 }
1334 }
1335
1336 if let Some(ref chain) = self.risk_chain {
1338 let verdict = chain.record("bash", effective, self.risk_chain_threshold);
1339 if verdict.should_block {
1340 let chain_name = verdict
1341 .chain_pattern
1342 .unwrap_or_else(|| "unknown".to_owned());
1343 tracing::warn!(
1344 chain = chain_name,
1345 score = verdict.cumulative_score,
1346 "risk chain threshold exceeded"
1347 );
1348 return Err(ToolError::Blocked {
1349 command: format!(
1350 "risk chain blocked: {} (score {:.2})",
1351 chain_name, verdict.cumulative_score
1352 ),
1353 });
1354 }
1355 }
1356
1357 Ok(())
1358 }
1359
1360 #[tracing::instrument(name = "tools.shell.resolve_context", skip(self, ctx), level = "info")]
1373 pub(crate) fn resolve_context(
1374 &self,
1375 ctx: Option<&ExecutionContext>,
1376 ) -> Result<ResolvedContext, ToolError> {
1377 let mut env: HashMap<String, String> = std::env::vars().collect();
1379
1380 env.retain(|k, _| {
1382 !self
1383 .env_blocklist
1384 .iter()
1385 .any(|prefix| k.starts_with(prefix.as_str()))
1386 });
1387
1388 if let Some(skill) = self.skill_env.read().as_ref() {
1390 for (k, v) in skill {
1391 env.insert(k.clone(), v.clone());
1392 }
1393 }
1394
1395 let mut resolved_name: Option<String> = None;
1397 let mut cwd_override: Option<PathBuf> = None;
1398 let mut trusted = false;
1399
1400 if let Some(default_name) = &self.default_env
1402 && let Some(default_ctx) = self.environments.get(default_name.as_str())
1403 {
1404 resolved_name.get_or_insert_with(|| default_name.clone());
1405 if cwd_override.is_none() {
1406 cwd_override = default_ctx.cwd().map(ToOwned::to_owned);
1407 }
1408 trusted = default_ctx.is_trusted();
1409 for (k, v) in default_ctx.env_overrides() {
1410 env.insert(k.clone(), v.clone());
1411 }
1412 }
1413
1414 if let Some(ctx) = ctx {
1416 if let Some(name) = ctx.name() {
1417 if let Some(reg_ctx) = self.environments.get(name) {
1418 resolved_name = Some(name.to_owned());
1419 if let Some(cwd) = reg_ctx.cwd() {
1420 cwd_override = Some(cwd.to_owned());
1421 }
1422 trusted = reg_ctx.is_trusted();
1423 for (k, v) in reg_ctx.env_overrides() {
1424 env.insert(k.clone(), v.clone());
1425 }
1426 } else {
1427 return Err(ToolError::Execution(std::io::Error::other(format!(
1428 "unknown execution environment '{name}'"
1429 ))));
1430 }
1431 }
1432
1433 if let Some(cwd) = ctx.cwd() {
1435 cwd_override = Some(cwd.to_owned());
1436 }
1437 if !ctx.is_trusted() {
1438 trusted = false;
1439 }
1440 for (k, v) in ctx.env_overrides() {
1441 env.insert(k.clone(), v.clone());
1442 }
1443 }
1444
1445 if !trusted {
1447 env.retain(|k, _| {
1448 !self
1449 .env_blocklist
1450 .iter()
1451 .any(|prefix| k.starts_with(prefix.as_str()))
1452 });
1453 }
1454
1455 let cwd = if let Some(raw) = cwd_override {
1457 let raw = if raw.is_absolute() {
1460 raw
1461 } else {
1462 std::env::current_dir()
1463 .unwrap_or_else(|_| PathBuf::from("."))
1464 .join(raw)
1465 };
1466 let canonical = raw
1467 .canonicalize()
1468 .map_err(|_| ToolError::SandboxViolation {
1469 path: raw.display().to_string(),
1470 })?;
1471 if !self
1473 .allowed_paths_canonical
1474 .iter()
1475 .any(|p| canonical.starts_with(p))
1476 {
1477 return Err(ToolError::SandboxViolation {
1478 path: canonical.display().to_string(),
1479 });
1480 }
1481 canonical
1482 } else {
1483 self.clamped_process_cwd()
1484 };
1485
1486 Ok(ResolvedContext {
1487 cwd,
1488 env,
1489 name: resolved_name,
1490 trusted,
1491 })
1492 }
1493
1494 fn clamped_process_cwd(&self) -> PathBuf {
1503 let process_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1504 if self.allowed_paths_canonical.is_empty() {
1505 return process_cwd;
1506 }
1507 let canon = canonicalize_or_nearest_ancestor(&process_cwd);
1508 if is_path_within(&canon, &self.allowed_paths_canonical) {
1509 return canon;
1510 }
1511 self.allowed_paths_canonical
1512 .iter()
1513 .find(|p| p.is_dir())
1514 .unwrap_or(&self.allowed_paths_canonical[0])
1515 .clone()
1516 }
1517
1518 fn validate_sandbox_with_cwd(
1519 &self,
1520 code: &str,
1521 cwd: &std::path::Path,
1522 ) -> Result<(), ToolError> {
1523 for token in extract_paths(code) {
1524 if has_traversal(&token) {
1525 return Err(ToolError::SandboxViolation { path: token });
1526 }
1527
1528 if self.allowed_paths_canonical.is_empty() {
1529 continue;
1530 }
1531
1532 let path = if token.starts_with('/') {
1533 PathBuf::from(&token)
1534 } else {
1535 cwd.join(&token)
1536 };
1537 let canonical = canonicalize_or_nearest_ancestor(&path);
1543 if !self
1544 .allowed_paths_canonical
1545 .iter()
1546 .any(|allowed| canonical.starts_with(allowed))
1547 {
1548 return Err(ToolError::SandboxViolation {
1549 path: canonical.display().to_string(),
1550 });
1551 }
1552 }
1553 Ok(())
1554 }
1555
1556 #[cfg(test)]
1559 fn validate_sandbox(&self, code: &str) -> Result<(), ToolError> {
1560 let cwd = std::env::current_dir().unwrap_or_default();
1561 self.validate_sandbox_with_cwd(code, &cwd)
1562 }
1563
1564 fn find_blocked_command(&self, code: &str) -> Option<String> {
1604 let snapshot = self.policy.load_full();
1605 let cleaned = strip_shell_escapes(&code.to_lowercase());
1606 let commands = tokenize_commands(&cleaned);
1607 for cmd_tokens in &commands {
1608 let joined = cmd_tokens.join(" ");
1609 if is_blocked_rm_worktrees(&joined) {
1610 return Some("rm --recursive --force .git/worktrees".to_owned());
1611 }
1612 }
1613 for blocked in &snapshot.blocked_commands {
1614 for cmd_tokens in &commands {
1615 if tokens_match_pattern(cmd_tokens, blocked) {
1616 return Some(blocked.clone());
1617 }
1618 }
1619 }
1620 for inner in extract_subshell_contents(&cleaned) {
1622 let inner_commands = tokenize_commands(&inner);
1623 for cmd_tokens in &inner_commands {
1624 let joined = cmd_tokens.join(" ");
1625 if is_blocked_rm_worktrees(&joined) {
1626 return Some("rm --recursive --force .git/worktrees".to_owned());
1627 }
1628 }
1629 for blocked in &snapshot.blocked_commands {
1630 for cmd_tokens in &inner_commands {
1631 if tokens_match_pattern(cmd_tokens, blocked) {
1632 return Some(blocked.clone());
1633 }
1634 }
1635 }
1636 }
1637 None
1638 }
1639
1640 fn find_confirm_command(&self, code: &str) -> Option<&str> {
1641 let normalized = code.to_lowercase();
1642 for pattern in &self.confirm_patterns {
1643 if normalized.contains(pattern.as_str()) {
1644 return Some(pattern.as_str());
1645 }
1646 }
1647 None
1648 }
1649
1650 fn build_audit_entry(
1651 command: &str,
1652 result: AuditResult,
1653 duration_ms: u64,
1654 error: Option<&ToolError>,
1655 exit_code: Option<i32>,
1656 truncated: bool,
1657 resolved: Option<&ResolvedContext>,
1658 ) -> AuditEntry {
1659 let (error_category, error_domain, error_phase) = error.map_or((None, None, None), |e| {
1660 let cat = e.category();
1661 (
1662 Some(cat.label().to_owned()),
1663 Some(cat.domain().label().to_owned()),
1664 Some(cat.phase().label().to_owned()),
1665 )
1666 });
1667 AuditEntry {
1668 timestamp: chrono_now(),
1669 tool: "shell".into(),
1670 command: command.into(),
1671 result,
1672 duration_ms,
1673 error_category,
1674 error_domain,
1675 error_phase,
1676 claim_source: Some(ClaimSource::Shell),
1677 mcp_server_id: None,
1678 injection_flagged: false,
1679 embedding_anomalous: false,
1680 cross_boundary_mcp_to_acp: false,
1681 adversarial_policy_decision: None,
1682 exit_code,
1683 truncated,
1684 caller_id: None,
1685 skill_name: None,
1686 policy_match: None,
1687 correlation_id: None,
1688 vigil_risk: None,
1689 execution_env: resolved.and_then(|r| r.name.clone()),
1690 resolved_cwd: resolved.map(|r| r.cwd.display().to_string()),
1691 scope_at_definition: None,
1692 scope_at_dispatch: None,
1693 }
1694 }
1695
1696 async fn log_audit(
1697 &self,
1698 command: &str,
1699 result: AuditResult,
1700 duration_ms: u64,
1701 error: Option<&ToolError>,
1702 exit_code: Option<i32>,
1703 truncated: bool,
1704 ) {
1705 if let Some(ref logger) = self.audit_logger {
1706 let entry = Self::build_audit_entry(
1707 command,
1708 result,
1709 duration_ms,
1710 error,
1711 exit_code,
1712 truncated,
1713 None,
1714 );
1715 logger.log(&entry).await;
1716 }
1717 }
1718
1719 #[allow(clippy::too_many_arguments)]
1720 async fn log_audit_with_context(
1721 &self,
1722 command: &str,
1723 result: AuditResult,
1724 duration_ms: u64,
1725 error: Option<&ToolError>,
1726 exit_code: Option<i32>,
1727 truncated: bool,
1728 resolved: &ResolvedContext,
1729 ) {
1730 if let Some(ref logger) = self.audit_logger {
1731 let entry = Self::build_audit_entry(
1732 command,
1733 result,
1734 duration_ms,
1735 error,
1736 exit_code,
1737 truncated,
1738 Some(resolved),
1739 );
1740 logger.log(&entry).await;
1741 }
1742 }
1743}
1744
1745impl ToolExecutor for ShellExecutor {
1746 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
1747 self.execute_inner(response, false).await
1748 }
1749
1750 async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
1756 self.execute_inner(response, true).await
1757 }
1758
1759 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1760 use crate::registry::{InvocationHint, ToolDef};
1761 vec![ToolDef {
1762 id: "bash".into(),
1763 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(),
1764 schema: schemars::schema_for!(BashParams),
1765 invocation: InvocationHint::FencedBlock("bash"),
1766 output_schema: None,
1767 server_id: None,
1768 }]
1769 }
1770
1771 #[tracing::instrument(name = "tools.shell.execute_tool_call", skip(self, call), level = "info",
1772 fields(tool_id = %call.tool_id, env = call.context.as_ref().and_then(|c| c.name()).unwrap_or("")))]
1773 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
1774 if call.tool_id != "bash" {
1775 return Ok(None);
1776 }
1777 let params: BashParams = crate::executor::deserialize_params(&call.params)?;
1778 if params.command.is_empty() {
1779 return Ok(None);
1780 }
1781 let command = ¶ms.command;
1782
1783 let resolved = self.resolve_context(call.context.as_ref())?;
1786
1787 if params.background {
1788 let run_id = self
1789 .spawn_background_with_context(command, &resolved)
1790 .await?;
1791 let id_short = &run_id.to_string()[..8];
1792 return Ok(Some(ToolOutput {
1793 tool_name: ToolName::new("bash"),
1794 summary: format!(
1795 "[background] started run_id={run_id} — command: {command}\n\
1796 The command is running in the background. When it completes, \
1797 results will appear at the start of the next turn (run_id_short={id_short})."
1798 ),
1799 blocks_executed: 1,
1800 filter_stats: None,
1801 diff: None,
1802 streamed: true,
1803 terminal_id: None,
1804 locations: None,
1805 raw_response: None,
1806 claim_source: Some(ClaimSource::Shell),
1807 ..Default::default()
1808 }));
1809 }
1810
1811 self.execute_block_with_context(command, false, &resolved, &call.tool_call_id)
1812 .await
1813 }
1814
1815 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
1816 ShellExecutor::set_skill_env(self, env);
1817 }
1818
1819 fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
1820 let result = self
1821 .checkpoint_stack
1822 .lock()
1823 .undo(n, self.max_snapshot_bytes);
1824 crate::executor::CheckpointActionResult {
1825 reverted_commands: result.reverted_commands,
1826 restored: result.restored,
1827 deleted: result.deleted,
1828 supported: true,
1829 message: result.message,
1830 }
1831 }
1832
1833 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
1834 let result = self.checkpoint_stack.lock().redo(self.max_snapshot_bytes);
1835 crate::executor::CheckpointActionResult {
1836 reverted_commands: result.reverted_commands,
1837 restored: result.restored,
1838 deleted: result.deleted,
1839 supported: true,
1840 message: result.message,
1841 }
1842 }
1843
1844 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
1845 let stack = self.checkpoint_stack.lock();
1846 let entries = stack
1847 .list_undo()
1848 .into_iter()
1849 .map(|e| crate::executor::CheckpointEntryView {
1850 index: e.index,
1851 command: e.command,
1852 captured_at_secs: e.captured_at_secs,
1853 file_count: e.file_count,
1854 })
1855 .collect();
1856 crate::executor::CheckpointListResult {
1857 entries,
1858 redo_depth: stack.redo_depth(),
1859 supported: true,
1860 }
1861 }
1862
1863 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
1864 false
1865 }
1866
1867 async fn execute_tool_call_confirmed(
1868 &self,
1869 call: &ToolCall,
1870 ) -> Result<Option<ToolOutput>, ToolError> {
1871 self.execute_tool_call(call).await
1872 }
1873
1874 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
1875 false
1876 }
1877}
1878
1879impl ShellExecutor {
1880 #[cfg(test)]
1885 async fn spawn_background(&self, command: &str) -> Result<RunId, ToolError> {
1886 let resolved = self.resolve_context(None)?;
1887 self.spawn_background_with_context(command, &resolved).await
1888 }
1889
1890 async fn spawn_background_with_context(
1908 &self,
1909 command: &str,
1910 resolved: &ResolvedContext,
1911 ) -> Result<RunId, ToolError> {
1912 use std::sync::atomic::Ordering;
1913
1914 if self.shutting_down.load(Ordering::Acquire) {
1915 return Err(ToolError::Blocked {
1916 command: command.to_owned(),
1917 });
1918 }
1919
1920 self.check_permissions(command, false).await?;
1921 self.validate_sandbox_with_cwd(command, &resolved.cwd)?;
1922
1923 let run_id = RunId::new();
1924 let mut runs = self.background_runs.lock();
1925 if runs.len() >= self.max_background_runs {
1926 return Err(ToolError::Blocked {
1927 command: format!(
1928 "background run cap reached (max_background_runs={})",
1929 self.max_background_runs
1930 ),
1931 });
1932 }
1933 let abort = CancellationToken::new();
1934 runs.insert(
1935 run_id,
1936 BackgroundHandle {
1937 command: command.to_owned(),
1938 started_at: std::time::Instant::now(),
1939 abort: abort.clone(),
1940 child_pid: None,
1941 },
1942 );
1943 drop(runs);
1944
1945 let tool_event_tx = self.tool_event_tx.clone();
1946 let background_completion_tx = self.background_completion_tx.clone();
1947 let background_runs = Arc::clone(&self.background_runs);
1948 let timeout = self.background_timeout;
1949 let env = resolved.env.clone();
1950 let cwd = resolved.cwd.clone();
1951 let command_owned = command.to_owned();
1952
1953 if let Some(ref sup) = self.task_supervisor {
1954 let task_name: Arc<str> = Arc::from(format!("shell_bg_{run_id}").as_str());
1955 drop(sup.spawn_oneshot(task_name, move || {
1956 run_background_task_with_env(
1957 run_id,
1958 command_owned,
1959 timeout,
1960 abort,
1961 background_runs,
1962 tool_event_tx,
1963 background_completion_tx,
1964 env,
1965 cwd,
1966 )
1967 }));
1968 } else {
1969 tokio::spawn(run_background_task_with_env(
1970 run_id,
1971 command_owned,
1972 timeout,
1973 abort,
1974 background_runs,
1975 tool_event_tx,
1976 background_completion_tx,
1977 env,
1978 cwd,
1979 ));
1980 }
1981
1982 Ok(run_id)
1983 }
1984
1985 pub async fn shutdown(&self) {
1991 use std::sync::atomic::Ordering;
1992
1993 self.shutting_down.store(true, Ordering::Release);
1994
1995 let handles: Vec<(RunId, String, CancellationToken, Option<u32>)> = {
1996 let runs = self.background_runs.lock();
1997 runs.iter()
1998 .map(|(id, h)| (*id, h.command.clone(), h.abort.clone(), h.child_pid))
1999 .collect()
2000 };
2001
2002 if handles.is_empty() {
2003 return;
2004 }
2005
2006 tracing::info!(
2007 count = handles.len(),
2008 "cancelling background shell runs for shutdown"
2009 );
2010
2011 for (run_id, command, abort, pid_opt) in &handles {
2012 abort.cancel();
2013
2014 #[cfg(unix)]
2015 if let Some(pid) = pid_opt {
2016 send_signal_with_escalation(*pid).await;
2017 }
2018 #[cfg(not(unix))]
2019 let _ = pid_opt;
2020
2021 if let Some(ref tx) = self.tool_event_tx {
2022 let _ = tx
2023 .send(ToolEvent::Completed {
2024 tool_name: ToolName::new("bash"),
2025 command: command.clone(),
2026 output: "[terminated by shutdown]".to_owned(),
2027 success: false,
2028 filter_stats: None,
2029 diff: None,
2030 run_id: Some(*run_id),
2031 })
2032 .await;
2033 }
2034 }
2035
2036 self.background_runs.lock().clear();
2037 }
2038}
2039
2040#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
2043async fn run_background_task_with_env(
2044 run_id: RunId,
2045 command: String,
2046 timeout: Duration,
2047 abort: CancellationToken,
2048 background_runs: Arc<Mutex<HashMap<RunId, BackgroundHandle>>>,
2049 tool_event_tx: Option<ToolEventTx>,
2050 background_completion_tx: Option<tokio::sync::mpsc::Sender<BackgroundCompletion>>,
2051 env: HashMap<String, String>,
2052 cwd: PathBuf,
2053) {
2054 use std::process::Stdio;
2055
2056 let started_at = std::time::Instant::now();
2057
2058 let mut cmd = build_bash_command_with_context(&command, &env, &cwd);
2059 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2060
2061 let mut child = match cmd.spawn() {
2062 Ok(c) => c,
2063 Err(ref e) => {
2064 let (_, out) = spawn_error_envelope(e);
2065 background_runs.lock().remove(&run_id);
2066 emit_completed(tool_event_tx.as_ref(), &command, out.clone(), false, run_id).await;
2067 if let Some(ref tx) = background_completion_tx {
2068 let _ = tx
2069 .send(BackgroundCompletion {
2070 run_id,
2071 exit_code: 1,
2072 output: out,
2073 success: false,
2074 elapsed_ms: 0,
2075 command,
2076 })
2077 .await;
2078 }
2079 return;
2080 }
2081 };
2082
2083 if let Some(pid) = child.id()
2084 && let Some(handle) = background_runs.lock().get_mut(&run_id)
2085 {
2086 handle.child_pid = Some(pid);
2087 }
2088
2089 let stdout = child.stdout.take().expect("stdout piped");
2090 let stderr = child.stderr.take().expect("stderr piped");
2091 let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2092
2093 let mut combined = String::new();
2094 let mut stdout_buf = String::new();
2095 let mut stderr_buf = String::new();
2096 let deadline = tokio::time::Instant::now() + timeout;
2097 let timeout_secs = timeout.as_secs();
2098
2099 let (_, out) = match run_bash_stream(
2100 &command,
2101 deadline,
2102 Some(&abort),
2103 tool_event_tx.as_ref(),
2104 "",
2105 &mut line_rx,
2106 &mut combined,
2107 &mut stdout_buf,
2108 &mut stderr_buf,
2109 &mut child,
2110 )
2111 .await
2112 {
2113 BashLoopOutcome::TimedOut => (
2114 ShellOutputEnvelope {
2115 stdout: stdout_buf,
2116 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2117 exit_code: 1,
2118 truncated: false,
2119 },
2120 format!("[error] command timed out after {timeout_secs}s"),
2121 ),
2122 BashLoopOutcome::Cancelled => (
2123 ShellOutputEnvelope {
2124 stdout: stdout_buf,
2125 stderr: stderr_buf,
2126 exit_code: 130,
2127 truncated: false,
2128 },
2129 "[cancelled] operation aborted".to_string(),
2130 ),
2131 BashLoopOutcome::StreamClosed => {
2132 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2133 }
2134 };
2135
2136 #[allow(clippy::cast_possible_truncation)]
2137 let elapsed_ms = started_at.elapsed().as_millis() as u64;
2138 let success = !out.contains("[error]");
2139 let exit_code = i32::from(!success);
2140 let truncated = crate::executor::truncate_tool_output_at(&out, 4096);
2141
2142 background_runs.lock().remove(&run_id);
2143 emit_completed(
2144 tool_event_tx.as_ref(),
2145 &command,
2146 truncated.clone(),
2147 success,
2148 run_id,
2149 )
2150 .await;
2151
2152 if let Some(ref tx) = background_completion_tx {
2153 let completion = BackgroundCompletion {
2154 run_id,
2155 exit_code,
2156 output: truncated,
2157 success,
2158 elapsed_ms,
2159 command,
2160 };
2161 if tx.send(completion).await.is_err() {
2162 tracing::warn!(
2163 run_id = %run_id,
2164 "background completion channel closed; agent may have shut down"
2165 );
2166 }
2167 }
2168
2169 tracing::debug!(run_id = %run_id, exit_code, elapsed_ms, "background shell run (with context) completed");
2170}
2171
2172async fn emit_completed(
2174 tool_event_tx: Option<&ToolEventTx>,
2175 command: &str,
2176 output: String,
2177 success: bool,
2178 run_id: RunId,
2179) {
2180 if let Some(tx) = tool_event_tx {
2181 let _ = tx
2182 .send(ToolEvent::Completed {
2183 tool_name: ToolName::new("bash"),
2184 command: command.to_owned(),
2185 output,
2186 success,
2187 filter_stats: None,
2188 diff: None,
2189 run_id: Some(run_id),
2190 })
2191 .await;
2192 }
2193}
2194
2195pub(crate) fn strip_shell_escapes(input: &str) -> String {
2199 let mut out = String::with_capacity(input.len());
2200 let bytes = input.as_bytes();
2201 let mut i = 0;
2202 while i < bytes.len() {
2203 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'\'' {
2205 let mut j = i + 2; let mut decoded = String::new();
2207 let mut valid = false;
2208 while j < bytes.len() && bytes[j] != b'\'' {
2209 if bytes[j] == b'\\' && j + 1 < bytes.len() {
2210 let next = bytes[j + 1];
2211 if next == b'x' && j + 3 < bytes.len() {
2212 let hi = (bytes[j + 2] as char).to_digit(16);
2214 let lo = (bytes[j + 3] as char).to_digit(16);
2215 if let (Some(h), Some(l)) = (hi, lo) {
2216 #[allow(clippy::cast_possible_truncation)]
2217 let byte = ((h << 4) | l) as u8;
2218 decoded.push(byte as char);
2219 j += 4;
2220 valid = true;
2221 continue;
2222 }
2223 } else if next.is_ascii_digit() {
2224 let mut val = u32::from(next - b'0');
2226 let mut len = 2; if j + 2 < bytes.len() && bytes[j + 2].is_ascii_digit() {
2228 val = val * 8 + u32::from(bytes[j + 2] - b'0');
2229 len = 3;
2230 if j + 3 < bytes.len() && bytes[j + 3].is_ascii_digit() {
2231 val = val * 8 + u32::from(bytes[j + 3] - b'0');
2232 len = 4;
2233 }
2234 }
2235 #[allow(clippy::cast_possible_truncation)]
2236 decoded.push((val & 0xFF) as u8 as char);
2237 j += len;
2238 valid = true;
2239 continue;
2240 }
2241 decoded.push(next as char);
2243 j += 2;
2244 } else {
2245 decoded.push(bytes[j] as char);
2246 j += 1;
2247 }
2248 }
2249 if j < bytes.len() && bytes[j] == b'\'' && valid {
2250 out.push_str(&decoded);
2251 i = j + 1;
2252 continue;
2253 }
2254 }
2256 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
2258 i += 2;
2259 continue;
2260 }
2261 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] != b'\n' {
2263 i += 1;
2264 out.push(bytes[i] as char);
2265 i += 1;
2266 continue;
2267 }
2268 if bytes[i] == b'"' || bytes[i] == b'\'' {
2270 let quote = bytes[i];
2271 i += 1;
2272 while i < bytes.len() && bytes[i] != quote {
2273 out.push(bytes[i] as char);
2274 i += 1;
2275 }
2276 if i < bytes.len() {
2277 i += 1; }
2279 continue;
2280 }
2281 out.push(bytes[i] as char);
2282 i += 1;
2283 }
2284 out
2285}
2286
2287pub(crate) fn extract_subshell_contents(s: &str) -> Vec<String> {
2297 let mut results = Vec::new();
2298 let chars: Vec<char> = s.chars().collect();
2299 let len = chars.len();
2300 let mut i = 0;
2301
2302 while i < len {
2303 if chars[i] == '`' {
2305 let start = i + 1;
2306 let mut j = start;
2307 while j < len && chars[j] != '`' {
2308 j += 1;
2309 }
2310 if j < len {
2311 results.push(chars[start..j].iter().collect());
2312 }
2313 i = j + 1;
2314 continue;
2315 }
2316
2317 let next_is_open_paren = i + 1 < len && chars[i + 1] == '(';
2319 let is_paren_subshell = next_is_open_paren && matches!(chars[i], '$' | '<' | '>');
2320
2321 if is_paren_subshell {
2322 let start = i + 2;
2323 let mut depth: usize = 1;
2324 let mut j = start;
2325 while j < len && depth > 0 {
2326 match chars[j] {
2327 '(' => depth += 1,
2328 ')' => depth -= 1,
2329 _ => {}
2330 }
2331 if depth > 0 {
2332 j += 1;
2333 } else {
2334 break;
2335 }
2336 }
2337 if depth == 0 {
2338 results.push(chars[start..j].iter().collect());
2339 }
2340 i = j + 1;
2341 continue;
2342 }
2343
2344 i += 1;
2345 }
2346
2347 results
2348}
2349
2350pub(crate) fn tokenize_commands(normalized: &str) -> Vec<Vec<String>> {
2353 let replaced = normalized.replace("||", "\n").replace("&&", "\n");
2355 replaced
2356 .split([';', '|', '\n'])
2357 .map(|seg| {
2358 seg.split_whitespace()
2359 .map(str::to_owned)
2360 .collect::<Vec<String>>()
2361 })
2362 .filter(|tokens| !tokens.is_empty())
2363 .collect()
2364}
2365
2366const TRANSPARENT_PREFIXES: &[&str] = &["env", "command", "exec", "nice", "nohup", "time", "xargs"];
2369
2370fn cmd_basename(tok: &str) -> &str {
2372 tok.rsplit('/').next().unwrap_or(tok)
2373}
2374
2375pub(crate) fn tokens_match_pattern(tokens: &[String], pattern: &str) -> bool {
2382 if tokens.is_empty() || pattern.is_empty() {
2383 return false;
2384 }
2385 let pattern = pattern.trim();
2386 let pattern_tokens: Vec<&str> = pattern.split_whitespace().collect();
2387 if pattern_tokens.is_empty() {
2388 return false;
2389 }
2390
2391 let start = tokens
2393 .iter()
2394 .position(|t| !TRANSPARENT_PREFIXES.contains(&cmd_basename(t)))
2395 .unwrap_or(0);
2396 let effective = &tokens[start..];
2397 if effective.is_empty() {
2398 return false;
2399 }
2400
2401 if pattern_tokens.len() == 1 {
2402 let pat = pattern_tokens[0];
2403 let base = cmd_basename(&effective[0]);
2404 base == pat || base.starts_with(&format!("{pat}."))
2406 } else {
2407 let n = pattern_tokens.len().min(effective.len());
2409 let mut parts: Vec<&str> = vec![cmd_basename(&effective[0])];
2410 parts.extend(effective[1..n].iter().map(String::as_str));
2411 let joined = parts.join(" ");
2412 if joined.starts_with(pattern) {
2413 return true;
2414 }
2415 if effective.len() > n {
2416 let mut parts2: Vec<&str> = vec![cmd_basename(&effective[0])];
2417 parts2.extend(effective[1..=n].iter().map(String::as_str));
2418 parts2.join(" ").starts_with(pattern)
2419 } else {
2420 false
2421 }
2422 }
2423}
2424
2425fn extract_paths(code: &str) -> Vec<String> {
2426 let mut result = Vec::new();
2427
2428 let mut tokens: Vec<String> = Vec::new();
2430 let mut current = String::new();
2431 let mut chars = code.chars().peekable();
2432 while let Some(c) = chars.next() {
2433 match c {
2434 '"' | '\'' => {
2435 let quote = c;
2436 while let Some(&nc) = chars.peek() {
2437 if nc == quote {
2438 chars.next();
2439 break;
2440 }
2441 current.push(chars.next().unwrap());
2442 }
2443 }
2444 c if c.is_whitespace() || matches!(c, ';' | '|' | '&') => {
2445 if !current.is_empty() {
2446 tokens.push(std::mem::take(&mut current));
2447 }
2448 }
2449 _ => current.push(c),
2450 }
2451 }
2452 if !current.is_empty() {
2453 tokens.push(current);
2454 }
2455
2456 for token in tokens {
2457 let trimmed = token.trim_end_matches([';', '&', '|']).to_owned();
2458 if trimmed.is_empty() {
2459 continue;
2460 }
2461 if trimmed.starts_with('/')
2462 || trimmed.starts_with("./")
2463 || trimmed.starts_with("../")
2464 || trimmed == ".."
2465 || (trimmed.starts_with('.') && trimmed.contains('/'))
2466 || is_relative_path_token(&trimmed)
2467 {
2468 result.push(trimmed);
2469 }
2470 }
2471 result
2472}
2473
2474fn is_relative_path_token(token: &str) -> bool {
2481 if !token.contains('/') || token.starts_with('/') || token.starts_with('.') {
2483 return false;
2484 }
2485 if token.contains("://") {
2487 return false;
2488 }
2489 if let Some(eq_pos) = token.find('=') {
2491 let key = &token[..eq_pos];
2492 if key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
2493 return false;
2494 }
2495 }
2496 token
2498 .chars()
2499 .next()
2500 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
2501}
2502
2503fn classify_shell_exit(
2509 exit_code: i32,
2510 output: &str,
2511) -> Option<crate::error_taxonomy::ToolErrorCategory> {
2512 use crate::error_taxonomy::ToolErrorCategory;
2513 match exit_code {
2514 126 => Some(ToolErrorCategory::PolicyBlocked),
2516 127 => Some(ToolErrorCategory::PermanentFailure),
2518 _ => {
2519 let lower = output.to_lowercase();
2520 if lower.contains("permission denied") {
2521 Some(ToolErrorCategory::PolicyBlocked)
2522 } else if lower.contains("no such file or directory") {
2523 Some(ToolErrorCategory::PermanentFailure)
2524 } else {
2525 None
2526 }
2527 }
2528 }
2529}
2530
2531fn has_traversal(path: &str) -> bool {
2532 path.split(['/', '\\']).any(|seg| seg == "..")
2533}
2534
2535fn canonicalize_or_nearest_ancestor(path: &std::path::Path) -> std::path::PathBuf {
2547 if let Ok(c) = path.canonicalize() {
2548 return c;
2549 }
2550 let components: Vec<_> = path.components().collect();
2551 let mut base_len = components.len();
2552 let canonical_base = loop {
2553 if base_len == 0 {
2554 break None;
2555 }
2556 let candidate: std::path::PathBuf = components[..base_len].iter().collect();
2557 if let Ok(c) = candidate.canonicalize() {
2558 break Some(c);
2559 }
2560 base_len -= 1;
2561 };
2562 match canonical_base {
2563 Some(base) => components[base_len..]
2564 .iter()
2565 .fold(base, |acc, c| acc.join(c)),
2566 None => std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()),
2567 }
2568}
2569
2570fn extract_bash_blocks(text: &str) -> Vec<&str> {
2571 crate::executor::extract_fenced_blocks(text, "bash")
2572}
2573
2574#[cfg(unix)]
2590async fn send_signal_with_escalation(pid: u32) {
2591 use nix::errno::Errno;
2592 use nix::sys::signal::{Signal, kill};
2593 use nix::unistd::Pid;
2594
2595 let Ok(pid_i32) = i32::try_from(pid) else {
2596 return;
2597 };
2598 let target = Pid::from_raw(pid_i32);
2599
2600 if let Err(e) = kill(target, Signal::SIGTERM)
2601 && e != Errno::ESRCH
2602 {
2603 tracing::debug!(pid, err = %e, "SIGTERM failed");
2604 }
2605 tokio::time::sleep(GRACEFUL_TERM_MS).await;
2606 let _ = Command::new("pkill")
2608 .args(["-KILL", "-P", &pid.to_string()])
2609 .status()
2610 .await;
2611 if let Err(e) = kill(target, Signal::SIGKILL)
2612 && e != Errno::ESRCH
2613 {
2614 tracing::debug!(pid, err = %e, "SIGKILL failed");
2615 }
2616}
2617
2618async fn kill_process_tree(child: &mut tokio::process::Child) {
2624 #[cfg(unix)]
2625 if let Some(pid) = child.id() {
2626 send_signal_with_escalation(pid).await;
2627 }
2628 let _ = child.kill().await;
2629}
2630
2631#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2636pub struct ShellOutputEnvelope {
2637 pub stdout: String,
2639 pub stderr: String,
2641 pub exit_code: i32,
2643 pub truncated: bool,
2645}
2646
2647#[allow(dead_code, clippy::too_many_arguments)]
2649async fn execute_bash(
2650 code: &str,
2651 timeout: Duration,
2652 event_tx: Option<&ToolEventTx>,
2653 cancel_token: Option<&CancellationToken>,
2654 extra_env: Option<&std::collections::HashMap<String, String>>,
2655 env_blocklist: &[String],
2656 sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2657 tool_call_id: &str,
2658) -> (ShellOutputEnvelope, String) {
2659 use std::process::Stdio;
2660
2661 let timeout_secs = timeout.as_secs();
2662 let mut cmd = build_bash_command(code, extra_env, env_blocklist);
2663
2664 if let Err(envelope_err) = apply_sandbox(&mut cmd, sandbox) {
2665 return envelope_err;
2666 }
2667
2668 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2669
2670 let mut child = match cmd.spawn() {
2671 Ok(c) => c,
2672 Err(ref e) => return spawn_error_envelope(e),
2673 };
2674
2675 let stdout = child.stdout.take().expect("stdout piped");
2676 let stderr = child.stderr.take().expect("stderr piped");
2677 let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2678
2679 let mut combined = String::new();
2680 let mut stdout_buf = String::new();
2681 let mut stderr_buf = String::new();
2682 let deadline = tokio::time::Instant::now() + timeout;
2683
2684 match run_bash_stream(
2685 code,
2686 deadline,
2687 cancel_token,
2688 event_tx,
2689 tool_call_id,
2690 &mut line_rx,
2691 &mut combined,
2692 &mut stdout_buf,
2693 &mut stderr_buf,
2694 &mut child,
2695 )
2696 .await
2697 {
2698 BashLoopOutcome::TimedOut => {
2699 let msg = format!("[error] command timed out after {timeout_secs}s");
2700 (
2701 ShellOutputEnvelope {
2702 stdout: stdout_buf,
2703 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2704 exit_code: 1,
2705 truncated: false,
2706 },
2707 msg,
2708 )
2709 }
2710 BashLoopOutcome::Cancelled => (
2711 ShellOutputEnvelope {
2712 stdout: stdout_buf,
2713 stderr: format!("{stderr_buf}operation aborted"),
2714 exit_code: 130,
2715 truncated: false,
2716 },
2717 "[cancelled] operation aborted".to_string(),
2718 ),
2719 BashLoopOutcome::StreamClosed => {
2720 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2721 }
2722 }
2723}
2724
2725fn build_bash_command(
2726 code: &str,
2727 extra_env: Option<&std::collections::HashMap<String, String>>,
2728 env_blocklist: &[String],
2729) -> Command {
2730 let mut cmd = Command::new("bash");
2731 cmd.arg("-c").arg(code);
2732 for (key, _) in std::env::vars() {
2733 if env_blocklist
2734 .iter()
2735 .any(|prefix| key.starts_with(prefix.as_str()))
2736 {
2737 cmd.env_remove(&key);
2738 }
2739 }
2740 if let Some(env) = extra_env {
2741 cmd.envs(env);
2742 }
2743 cmd
2744}
2745
2746fn build_bash_command_with_context(
2751 code: &str,
2752 resolved_env: &HashMap<String, String>,
2753 cwd: &std::path::Path,
2754) -> Command {
2755 let mut cmd = Command::new("bash");
2756 cmd.arg("-c").arg(code);
2757 cmd.env_clear();
2758 cmd.envs(resolved_env);
2759 cmd.current_dir(cwd);
2760 cmd
2761}
2762
2763async fn execute_bash_with_context(
2768 code: &str,
2769 timeout: Duration,
2770 event_tx: Option<&ToolEventTx>,
2771 tool_call_id: &str,
2772 cancel_token: Option<&CancellationToken>,
2773 resolved: &ResolvedContext,
2774 sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2775) -> (ShellOutputEnvelope, String) {
2776 use std::process::Stdio;
2777
2778 let timeout_secs = timeout.as_secs();
2779 let mut cmd = build_bash_command_with_context(code, &resolved.env, &resolved.cwd);
2780
2781 if let Err(envelope_err) = apply_sandbox(&mut cmd, sandbox) {
2782 return envelope_err;
2783 }
2784
2785 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2786
2787 let mut child = match cmd.spawn() {
2788 Ok(c) => c,
2789 Err(ref e) => return spawn_error_envelope(e),
2790 };
2791
2792 let stdout = child.stdout.take().expect("stdout piped");
2793 let stderr = child.stderr.take().expect("stderr piped");
2794 let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2795
2796 let mut combined = String::new();
2797 let mut stdout_buf = String::new();
2798 let mut stderr_buf = String::new();
2799 let deadline = tokio::time::Instant::now() + timeout;
2800
2801 match run_bash_stream(
2802 code,
2803 deadline,
2804 cancel_token,
2805 event_tx,
2806 tool_call_id,
2807 &mut line_rx,
2808 &mut combined,
2809 &mut stdout_buf,
2810 &mut stderr_buf,
2811 &mut child,
2812 )
2813 .await
2814 {
2815 BashLoopOutcome::TimedOut => {
2816 let msg = format!("[error] command timed out after {timeout_secs}s");
2817 (
2818 ShellOutputEnvelope {
2819 stdout: stdout_buf,
2820 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2821 exit_code: 1,
2822 truncated: false,
2823 },
2824 msg,
2825 )
2826 }
2827 BashLoopOutcome::Cancelled => (
2828 ShellOutputEnvelope {
2829 stdout: stdout_buf,
2830 stderr: format!("{stderr_buf}operation aborted"),
2831 exit_code: 130,
2832 truncated: false,
2833 },
2834 "[cancelled] operation aborted".to_string(),
2835 ),
2836 BashLoopOutcome::StreamClosed => {
2837 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2838 }
2839 }
2840}
2841
2842fn apply_sandbox(
2843 cmd: &mut Command,
2844 sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2845) -> Result<(), (ShellOutputEnvelope, String)> {
2846 if let Some((sb, policy)) = sandbox
2848 && let Err(err) = sb.wrap(cmd, policy)
2849 {
2850 let msg = format!("[error] sandbox setup failed: {err}");
2851 return Err((
2852 ShellOutputEnvelope {
2853 stdout: String::new(),
2854 stderr: msg.clone(),
2855 exit_code: 1,
2856 truncated: false,
2857 },
2858 msg,
2859 ));
2860 }
2861 Ok(())
2862}
2863
2864fn spawn_error_envelope(e: &std::io::Error) -> (ShellOutputEnvelope, String) {
2865 let msg = format!("[error] {e}");
2866 (
2867 ShellOutputEnvelope {
2868 stdout: String::new(),
2869 stderr: msg.clone(),
2870 exit_code: 1,
2871 truncated: false,
2872 },
2873 msg,
2874 )
2875}
2876
2877fn spawn_output_readers(
2883 stdout: tokio::process::ChildStdout,
2884 stderr: tokio::process::ChildStderr,
2885) -> (
2886 tokio::sync::mpsc::Receiver<(bool, String)>,
2887 tokio::task::JoinSet<()>,
2888) {
2889 use tokio::io::{AsyncBufReadExt, BufReader};
2890
2891 let (line_tx, line_rx) = tokio::sync::mpsc::channel::<(bool, String)>(64);
2892 let mut readers = tokio::task::JoinSet::new();
2893
2894 let stdout_tx = line_tx.clone();
2895 readers.spawn(async move {
2896 let mut reader = BufReader::new(stdout);
2897 let mut buf = String::new();
2898 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
2899 let _ = stdout_tx.send((false, buf.clone())).await;
2900 buf.clear();
2901 }
2902 });
2903
2904 readers.spawn(async move {
2905 let mut reader = BufReader::new(stderr);
2906 let mut buf = String::new();
2907 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
2908 let _ = line_tx.send((true, buf.clone())).await;
2909 buf.clear();
2910 }
2911 });
2912
2913 (line_rx, readers)
2914}
2915
2916enum BashLoopOutcome {
2921 StreamClosed,
2922 TimedOut,
2923 Cancelled,
2924}
2925
2926#[allow(clippy::too_many_arguments)]
2927async fn run_bash_stream(
2928 code: &str,
2929 deadline: tokio::time::Instant,
2930 cancel_token: Option<&CancellationToken>,
2931 event_tx: Option<&ToolEventTx>,
2932 tool_call_id: &str,
2933 line_rx: &mut tokio::sync::mpsc::Receiver<(bool, String)>,
2934 combined: &mut String,
2935 stdout_buf: &mut String,
2936 stderr_buf: &mut String,
2937 child: &mut tokio::process::Child,
2938) -> BashLoopOutcome {
2939 loop {
2940 tokio::select! {
2941 line = line_rx.recv() => {
2942 match line {
2943 Some((is_stderr, chunk)) => {
2944 let interleaved = if is_stderr {
2945 format!("[stderr] {chunk}")
2946 } else {
2947 chunk.clone()
2948 };
2949 if let Some(tx) = event_tx {
2950 let _ = tx.try_send(ToolEvent::OutputChunk {
2952 tool_name: ToolName::new("bash"),
2953 command: code.to_owned(),
2954 chunk: interleaved.clone(),
2955 tool_call_id: tool_call_id.to_owned(),
2956 skill_name: None,
2957 });
2958 }
2959 combined.push_str(&interleaved);
2960 if is_stderr {
2961 stderr_buf.push_str(&chunk);
2962 } else {
2963 stdout_buf.push_str(&chunk);
2964 }
2965 }
2966 None => return BashLoopOutcome::StreamClosed,
2967 }
2968 }
2969 () = tokio::time::sleep_until(deadline) => {
2970 kill_process_tree(child).await;
2971 return BashLoopOutcome::TimedOut;
2972 }
2973 () = async {
2974 match cancel_token {
2975 Some(t) => t.cancelled().await,
2976 None => std::future::pending().await,
2977 }
2978 } => {
2979 kill_process_tree(child).await;
2980 return BashLoopOutcome::Cancelled;
2981 }
2982 }
2983 }
2984}
2985
2986async fn finalize_envelope(
2987 child: &mut tokio::process::Child,
2988 combined: String,
2989 stdout_buf: String,
2990 stderr_buf: String,
2991) -> (ShellOutputEnvelope, String) {
2992 let status = child.wait().await;
2993 let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(1);
2994
2995 if combined.is_empty() {
2996 (
2997 ShellOutputEnvelope {
2998 stdout: String::new(),
2999 stderr: String::new(),
3000 exit_code,
3001 truncated: false,
3002 },
3003 "(no output)".to_string(),
3004 )
3005 } else {
3006 (
3007 ShellOutputEnvelope {
3008 stdout: stdout_buf.trim_end().to_owned(),
3009 stderr: stderr_buf.trim_end().to_owned(),
3010 exit_code,
3011 truncated: false,
3012 },
3013 combined,
3014 )
3015 }
3016}
3017
3018#[cfg(test)]
3019mod tests;