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