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::{TaskSupervisor, 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 checkpoint;
61use checkpoint::{Checkpoint, CheckpointStack};
62
63mod transaction;
64use transaction::{TransactionSnapshot, affected_paths, build_scope_matchers, is_write_command};
65
66use crate::risk_chain::RiskChainAccumulator;
67
68const DEFAULT_BLOCKED: &[&str] = &[
69 "rm -rf /", "sudo", "mkfs", "dd if=", "curl", "wget", "nc ", "ncat", "netcat", "shutdown",
70 "reboot", "halt",
71];
72
73#[must_use]
91pub fn is_blocked_rm_worktrees(cmd: &str) -> bool {
92 let lower = cmd.to_lowercase();
93 let tokens: Vec<&str> = lower.split_whitespace().collect();
94
95 let Some(first) = tokens.first() else {
97 return false;
98 };
99 if first.rsplit('/').next().unwrap_or(first) != "rm" {
100 return false;
101 }
102
103 if !lower.contains(".git/worktrees") {
104 return false;
105 }
106
107 let mut has_recursive = false;
108 let mut has_force = false;
109
110 for token in &tokens[1..] {
111 if *token == "--recursive" {
112 has_recursive = true;
113 } else if *token == "--force" {
114 has_force = true;
115 } else if let Some(flags) = token.strip_prefix('-').filter(|f| !f.starts_with('-')) {
116 if flags.contains('r') || flags.contains('R') {
118 has_recursive = true;
119 }
120 if flags.contains('f') {
121 has_force = true;
122 }
123 }
124 }
125
126 has_recursive && has_force
127}
128
129#[cfg(unix)]
131const GRACEFUL_TERM_MS: Duration = Duration::from_millis(250);
132
133pub const DEFAULT_BLOCKED_COMMANDS: &[&str] = DEFAULT_BLOCKED;
146
147pub const SHELL_INTERPRETERS: &[&str] =
153 &["bash", "sh", "zsh", "fish", "dash", "ksh", "csh", "tcsh"];
154
155const SUBSHELL_METACHARS: &[&str] = &["$(", "`", "<(", ">("];
159
160#[must_use]
168pub fn check_blocklist(command: &str, blocklist: &[String]) -> Option<String> {
169 let lower = command.to_lowercase();
170 for meta in SUBSHELL_METACHARS {
172 if lower.contains(meta) {
173 return Some((*meta).to_owned());
174 }
175 }
176 let cleaned = strip_shell_escapes(&lower);
177 let commands = tokenize_commands(&cleaned);
178 for cmd_tokens in &commands {
179 let joined = cmd_tokens.join(" ");
180 if is_blocked_rm_worktrees(&joined) {
181 return Some("rm --recursive --force .git/worktrees".to_owned());
182 }
183 }
184 for blocked in blocklist {
185 for cmd_tokens in &commands {
186 if tokens_match_pattern(cmd_tokens, blocked) {
187 return Some(blocked.clone());
188 }
189 }
190 }
191 None
192}
193
194#[must_use]
199pub fn effective_shell_command<'a>(binary: &str, args: &'a [String]) -> Option<&'a str> {
200 let base = binary.rsplit('/').next().unwrap_or(binary);
201 if !SHELL_INTERPRETERS.contains(&base) {
202 return None;
203 }
204 let pos = args.iter().position(|a| a == "-c")?;
206 args.get(pos + 1).map(String::as_str)
207}
208
209const NETWORK_COMMANDS: &[&str] = &["curl", "wget", "nc ", "ncat", "netcat"];
210
211#[derive(Debug)]
215pub(crate) struct ShellPolicy {
216 pub(crate) blocked_commands: Vec<String>,
217}
218
219#[derive(Clone, Debug)]
226pub struct ShellPolicyHandle {
227 inner: Arc<ArcSwap<ShellPolicy>>,
228}
229
230impl ShellPolicyHandle {
231 pub fn rebuild(&self, config: &crate::config::ShellConfig) {
240 let policy = Arc::new(ShellPolicy {
241 blocked_commands: compute_blocked_commands(config),
242 });
243 self.inner.store(policy);
244 }
245
246 #[must_use]
248 pub fn snapshot_blocked(&self) -> Vec<String> {
249 self.inner.load().blocked_commands.clone()
250 }
251}
252
253pub(crate) fn compute_blocked_commands(config: &crate::config::ShellConfig) -> Vec<String> {
257 let allowed: Vec<String> = config
258 .allowed_commands
259 .iter()
260 .map(|s| s.to_lowercase())
261 .collect();
262 let mut blocked: Vec<String> = DEFAULT_BLOCKED
263 .iter()
264 .filter(|s| !allowed.contains(&s.to_lowercase()))
265 .map(|s| (*s).to_owned())
266 .collect();
267 blocked.extend(config.blocked_commands.iter().map(|s| s.to_lowercase()));
268 if !config.allow_network {
269 for cmd in NETWORK_COMMANDS {
270 let lower = cmd.to_lowercase();
271 if !blocked.contains(&lower) {
272 blocked.push(lower);
273 }
274 }
275 }
276 blocked.sort();
277 blocked.dedup();
278 blocked
279}
280
281#[derive(Deserialize, JsonSchema)]
282pub(crate) struct BashParams {
283 command: String,
285 #[serde(default)]
291 background: bool,
292}
293
294#[derive(Debug)]
317#[allow(clippy::struct_excessive_bools)]
318pub struct ShellExecutor {
319 timeout: Duration,
320 policy: Arc<ArcSwap<ShellPolicy>>,
321 confirm_patterns: Vec<String>,
322 env_blocklist: Vec<String>,
323 audit_logger: Option<Arc<AuditLogger>>,
324 tool_event_tx: Option<ToolEventTx>,
325 permission_policy: Option<PermissionPolicy>,
326 output_filter_registry: Option<OutputFilterRegistry>,
327 cancel_token: Option<CancellationToken>,
328 skill_env: RwLock<Option<std::collections::HashMap<String, String>>>,
329 transactional: bool,
330 auto_rollback: bool,
331 auto_rollback_exit_codes: Vec<i32>,
332 snapshot_required: bool,
333 max_snapshot_bytes: u64,
334 transaction_scope_matchers: Vec<globset::GlobMatcher>,
335 checkpoint_stack: Arc<Mutex<CheckpointStack>>,
337 checkpoints_enabled: bool,
339 sandbox: Option<Arc<dyn Sandbox>>,
340 sandbox_policy: Option<SandboxPolicy>,
341 background_runs: Arc<Mutex<HashMap<RunId, BackgroundHandle>>>,
343 max_background_runs: usize,
345 background_timeout: Duration,
347 shutting_down: Arc<AtomicBool>,
349 background_completion_tx: Option<tokio::sync::mpsc::Sender<BackgroundCompletion>>,
353 environments: Arc<HashMap<String, ExecutionContext>>,
356 allowed_paths_canonical: Vec<PathBuf>,
359 default_env: Option<String>,
361 risk_chain: Option<Arc<RiskChainAccumulator>>,
363 risk_chain_threshold: f32,
365 task_supervisor: Option<DebugIgnored<TaskSupervisor>>,
370}
371
372struct DebugIgnored<T>(T);
377
378impl<T> std::fmt::Debug for DebugIgnored<T> {
379 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380 f.write_str("<...>")
381 }
382}
383
384impl<T> std::ops::Deref for DebugIgnored<T> {
385 type Target = T;
386 fn deref(&self) -> &T {
387 &self.0
388 }
389}
390
391#[derive(Debug)]
397pub(crate) struct ResolvedContext {
398 pub(crate) cwd: PathBuf,
400 pub(crate) env: HashMap<String, String>,
402 pub(crate) name: Option<String>,
404 #[allow(dead_code)]
407 pub(crate) trusted: bool,
408}
409
410impl ShellExecutor {
411 #[must_use]
417 pub fn new(config: &ShellConfig) -> Self {
418 let policy = Arc::new(ArcSwap::from_pointee(ShellPolicy {
419 blocked_commands: compute_blocked_commands(config),
420 }));
421
422 let allowed_paths: Vec<PathBuf> = if config.allowed_paths.is_empty() {
423 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
424 } else {
425 config.allowed_paths.iter().map(PathBuf::from).collect()
426 };
427 let allowed_paths_canonical: Vec<PathBuf> = allowed_paths
428 .iter()
429 .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
430 .collect();
431
432 Self {
433 timeout: Duration::from_secs(config.timeout),
434 policy,
435 confirm_patterns: config.confirm_patterns.clone(),
436 env_blocklist: config.env_blocklist.clone(),
437 audit_logger: None,
438 tool_event_tx: None,
439 permission_policy: None,
440 output_filter_registry: None,
441 cancel_token: None,
442 skill_env: RwLock::new(None),
443 transactional: config.transactional,
444 auto_rollback: config.auto_rollback,
445 auto_rollback_exit_codes: config.auto_rollback_exit_codes.clone(),
446 snapshot_required: config.snapshot_required,
447 max_snapshot_bytes: config.max_snapshot_bytes,
448 transaction_scope_matchers: build_scope_matchers(&config.transaction_scope),
449 checkpoint_stack: Arc::new(Mutex::new(CheckpointStack::new(config.max_checkpoints))),
450 checkpoints_enabled: config.checkpoints_enabled,
451 sandbox: None,
452 sandbox_policy: None,
453 background_runs: Arc::new(Mutex::new(HashMap::new())),
454 max_background_runs: config.max_background_runs,
455 background_timeout: Duration::from_secs(config.background_timeout_secs),
456 shutting_down: Arc::new(AtomicBool::new(false)),
457 background_completion_tx: None,
458 environments: Arc::new(HashMap::new()),
459 allowed_paths_canonical,
460 default_env: None,
461 risk_chain: None,
462 risk_chain_threshold: config.risk_chain_threshold.unwrap_or(0.7),
463 task_supervisor: None::<DebugIgnored<TaskSupervisor>>,
464 }
465 }
466
467 #[must_use]
472 pub fn with_sandbox(mut self, sandbox: Arc<dyn Sandbox>, policy: SandboxPolicy) -> Self {
473 self.sandbox = Some(sandbox);
474 self.sandbox_policy = Some(policy);
475 self
476 }
477
478 #[must_use]
483 pub fn with_risk_chain(mut self, accumulator: Arc<RiskChainAccumulator>) -> Self {
484 self.risk_chain = Some(accumulator);
485 self
486 }
487
488 pub fn with_execution_config(
499 self,
500 config: &zeph_config::ExecutionConfig,
501 ) -> Result<Self, String> {
502 let registry: HashMap<String, ExecutionContext> = config
503 .environments
504 .iter()
505 .map(|e| {
506 let ctx = ExecutionContext::trusted_from_parts(
507 Some(e.name.clone()),
508 Some(std::path::PathBuf::from(&e.cwd)),
509 e.env.clone(),
510 );
511 (e.name.clone(), ctx)
512 })
513 .collect();
514 self.with_environments(registry, config.default_env.clone())
515 }
516
517 pub fn with_environments(
527 mut self,
528 environments: HashMap<String, ExecutionContext>,
529 default_env: Option<String>,
530 ) -> Result<Self, String> {
531 for (name, ctx) in &environments {
533 if let Some(cwd) = ctx.cwd() {
534 let canonical = cwd.canonicalize().map_err(|e| {
535 format!(
536 "execution environment '{name}': cwd '{}' cannot be canonicalized: {e}",
537 cwd.display()
538 )
539 })?;
540 if !self
541 .allowed_paths_canonical
542 .iter()
543 .any(|p| canonical.starts_with(p))
544 {
545 return Err(format!(
546 "execution environment '{name}': cwd '{}' is outside allowed_paths",
547 cwd.display()
548 ));
549 }
550 }
551 }
552 self.environments = Arc::new(environments);
553 self.default_env = default_env;
554 Ok(self)
555 }
556
557 pub fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
559 *self.skill_env.write() = env;
560 }
561
562 #[must_use]
564 pub fn with_audit(mut self, logger: Arc<AuditLogger>) -> Self {
565 self.audit_logger = Some(logger);
566 self
567 }
568
569 #[must_use]
574 pub fn with_tool_event_tx(mut self, tx: ToolEventTx) -> Self {
575 self.tool_event_tx = Some(tx);
576 self
577 }
578
579 #[must_use]
585 pub fn with_background_completion_tx(
586 mut self,
587 tx: tokio::sync::mpsc::Sender<BackgroundCompletion>,
588 ) -> Self {
589 self.background_completion_tx = Some(tx);
590 self
591 }
592
593 #[must_use]
599 pub fn with_task_supervisor(mut self, supervisor: TaskSupervisor) -> Self {
600 self.task_supervisor = Some(DebugIgnored(supervisor));
601 self
602 }
603
604 #[must_use]
609 pub fn with_permissions(mut self, policy: PermissionPolicy) -> Self {
610 self.permission_policy = Some(policy);
611 self
612 }
613
614 #[must_use]
617 pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
618 self.cancel_token = Some(token);
619 self
620 }
621
622 #[must_use]
625 pub fn with_output_filters(mut self, registry: OutputFilterRegistry) -> Self {
626 self.output_filter_registry = Some(registry);
627 self
628 }
629
630 #[must_use]
636 pub fn background_runs_snapshot(&self) -> Vec<background::BackgroundRunSnapshot> {
637 let runs = self.background_runs.lock();
638 runs.iter()
639 .map(|(id, h)| {
640 #[allow(clippy::cast_possible_truncation)]
641 let elapsed_ms = h.elapsed().as_millis() as u64;
642 background::BackgroundRunSnapshot {
643 run_id: id.to_string(),
644 command: h.command.clone(),
645 elapsed_ms,
646 }
647 })
648 .collect()
649 }
650
651 #[must_use]
657 pub fn policy_handle(&self) -> ShellPolicyHandle {
658 ShellPolicyHandle {
659 inner: Arc::clone(&self.policy),
660 }
661 }
662
663 #[cfg_attr(
669 feature = "profiling",
670 tracing::instrument(name = "tools.shell.execute", skip_all, fields(exit_code = tracing::field::Empty, duration_ms = tracing::field::Empty))
671 )]
672 pub async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
673 self.execute_inner(response, true).await
674 }
675
676 async fn execute_inner(
677 &self,
678 response: &str,
679 skip_confirm: bool,
680 ) -> Result<Option<ToolOutput>, ToolError> {
681 let blocks = extract_bash_blocks(response);
682 if blocks.is_empty() {
683 return Ok(None);
684 }
685
686 let resolved = self.resolve_context(None)?;
689
690 let mut outputs = Vec::with_capacity(blocks.len());
691 let mut cumulative_filter_stats: Option<FilterStats> = None;
692 let mut last_envelope: Option<ShellOutputEnvelope> = None;
693 #[allow(clippy::cast_possible_truncation)]
694 let blocks_executed = blocks.len() as u32;
695
696 for block in &blocks {
697 let (output_line, per_block_stats, envelope) =
698 self.execute_block(block, skip_confirm, &resolved).await?;
699 if let Some(fs) = per_block_stats {
700 let stats = cumulative_filter_stats.get_or_insert_with(FilterStats::default);
701 stats.raw_chars += fs.raw_chars;
702 stats.filtered_chars += fs.filtered_chars;
703 stats.raw_lines += fs.raw_lines;
704 stats.filtered_lines += fs.filtered_lines;
705 stats.confidence = Some(match (stats.confidence, fs.confidence) {
706 (Some(prev), Some(cur)) => crate::filter::worse_confidence(prev, cur),
707 (Some(prev), None) => prev,
708 (None, Some(cur)) => cur,
709 (None, None) => unreachable!(),
710 });
711 if stats.command.is_none() {
712 stats.command = fs.command;
713 }
714 if stats.kept_lines.is_empty() && !fs.kept_lines.is_empty() {
715 stats.kept_lines = fs.kept_lines;
716 }
717 }
718 last_envelope = Some(envelope);
719 outputs.push(output_line);
720 }
721
722 let raw_response = last_envelope
723 .as_ref()
724 .and_then(|e| serde_json::to_value(e).ok());
725
726 Ok(Some(ToolOutput {
727 tool_name: ToolName::new("bash"),
728 summary: outputs.join("\n\n"),
729 blocks_executed,
730 filter_stats: cumulative_filter_stats,
731 diff: None,
732 streamed: self.tool_event_tx.is_some(),
733 terminal_id: None,
734 locations: None,
735 raw_response,
736 claim_source: Some(ClaimSource::Shell),
737 }))
738 }
739
740 async fn execute_block(
741 &self,
742 block: &str,
743 skip_confirm: bool,
744 resolved: &ResolvedContext,
745 ) -> Result<(String, Option<FilterStats>, ShellOutputEnvelope), ToolError> {
746 self.check_permissions(block, skip_confirm).await?;
747 self.validate_sandbox_with_cwd(block, &resolved.cwd)?;
748
749 let (snapshot, snapshot_warning, snap_paths) = self.capture_snapshot_for(block)?;
750
751 if let Some(ref tx) = self.tool_event_tx {
752 let sandbox_profile = self
753 .sandbox_policy
754 .as_ref()
755 .map(|p| format!("{:?}", p.profile));
756 let _ = tx.try_send(ToolEvent::Started {
758 tool_name: ToolName::new("bash"),
759 command: block.to_owned(),
760 sandbox_profile,
761 resolved_cwd: Some(resolved.cwd.display().to_string()),
762 execution_env: resolved.name.clone(),
763 });
764 }
765
766 let start = Instant::now();
767 let sandbox_pair = self
768 .sandbox
769 .as_ref()
770 .zip(self.sandbox_policy.as_ref())
771 .map(|(sb, pol)| (sb.as_ref(), pol));
772 let (mut envelope, out) = execute_bash_with_context(
773 block,
774 self.timeout,
775 self.tool_event_tx.as_ref(),
776 "",
777 self.cancel_token.as_ref(),
778 resolved,
779 sandbox_pair,
780 )
781 .await;
782 let exit_code = envelope.exit_code;
783 if exit_code == 130
784 && self
785 .cancel_token
786 .as_ref()
787 .is_some_and(CancellationToken::is_cancelled)
788 {
789 return Err(ToolError::Cancelled);
790 }
791 #[allow(clippy::cast_possible_truncation)]
792 let duration_ms = start.elapsed().as_millis() as u64;
793
794 if let Some(snap) = snapshot
795 && let Some(surviving) = self
796 .maybe_rollback(snap, block, exit_code, duration_ms)
797 .await
798 && self.checkpoints_enabled
799 {
800 self.record_checkpoint(surviving, block, snap_paths);
801 }
802
803 if let Some(err) = self
804 .classify_and_audit(block, &out, exit_code, duration_ms)
805 .await
806 {
807 self.emit_completed(block, &out, false, None, None).await;
808 return Err(err);
809 }
810
811 let (filtered, per_block_stats) = self.apply_output_filter(block, &out, exit_code);
812
813 self.emit_completed(
814 block,
815 &out,
816 !out.contains("[error]"),
817 per_block_stats.clone(),
818 None,
819 )
820 .await;
821
822 envelope.truncated = filtered.len() < out.len();
824
825 let audit_result = if out.contains("[error]") || out.contains("[stderr]") {
826 AuditResult::Error {
827 message: out.clone(),
828 }
829 } else {
830 AuditResult::Success
831 };
832 self.log_audit_with_context(
833 block,
834 audit_result,
835 duration_ms,
836 None,
837 Some(exit_code),
838 envelope.truncated,
839 resolved,
840 )
841 .await;
842
843 let output_line = match snapshot_warning {
844 Some(warn) => format!("{warn}\n$ {block}\n{filtered}"),
845 None => format!("$ {block}\n{filtered}"),
846 };
847 Ok((output_line, per_block_stats, envelope))
848 }
849
850 #[allow(clippy::too_many_lines)]
855 #[tracing::instrument(name = "tools.shell.execute_block", skip(self, resolved), level = "info",
856 fields(cwd = %resolved.cwd.display(), env_name = resolved.name.as_deref().unwrap_or("")))]
857 async fn execute_block_with_context(
858 &self,
859 command: &str,
860 skip_confirm: bool,
861 resolved: &ResolvedContext,
862 tool_call_id: &str,
863 ) -> Result<Option<ToolOutput>, ToolError> {
864 self.check_permissions(command, skip_confirm).await?;
865 self.validate_sandbox_with_cwd(command, &resolved.cwd)?;
866
867 let (snapshot, snapshot_warning, snap_paths) = self.capture_snapshot_for(command)?;
868
869 if let Some(ref tx) = self.tool_event_tx {
870 let sandbox_profile = self
871 .sandbox_policy
872 .as_ref()
873 .map(|p| format!("{:?}", p.profile));
874 let _ = tx.try_send(ToolEvent::Started {
875 tool_name: ToolName::new("bash"),
876 command: command.to_owned(),
877 sandbox_profile,
878 resolved_cwd: Some(resolved.cwd.display().to_string()),
879 execution_env: resolved.name.clone(),
880 });
881 }
882
883 let start = Instant::now();
884 let sandbox_pair = self
885 .sandbox
886 .as_ref()
887 .zip(self.sandbox_policy.as_ref())
888 .map(|(sb, pol)| (sb.as_ref(), pol));
889 let (mut envelope, out) = execute_bash_with_context(
890 command,
891 self.timeout,
892 self.tool_event_tx.as_ref(),
893 tool_call_id,
894 self.cancel_token.as_ref(),
895 resolved,
896 sandbox_pair,
897 )
898 .await;
899 let exit_code = envelope.exit_code;
900 if exit_code == 130
901 && self
902 .cancel_token
903 .as_ref()
904 .is_some_and(CancellationToken::is_cancelled)
905 {
906 return Err(ToolError::Cancelled);
907 }
908 #[allow(clippy::cast_possible_truncation)]
909 let duration_ms = start.elapsed().as_millis() as u64;
910
911 if let Some(snap) = snapshot
912 && let Some(surviving) = self
913 .maybe_rollback(snap, command, exit_code, duration_ms)
914 .await
915 && self.checkpoints_enabled
916 {
917 self.record_checkpoint(surviving, command, snap_paths);
918 }
919
920 if let Some(err) = self
921 .classify_and_audit(command, &out, exit_code, duration_ms)
922 .await
923 {
924 self.emit_completed(command, &out, false, None, None).await;
925 return Err(err);
926 }
927
928 let (filtered, per_block_stats) = self.apply_output_filter(command, &out, exit_code);
929
930 self.emit_completed(
931 command,
932 &out,
933 !out.contains("[error]"),
934 per_block_stats.clone(),
935 None,
936 )
937 .await;
938
939 envelope.truncated = filtered.len() < out.len();
940
941 let audit_result = if out.contains("[error]") || out.contains("[stderr]") {
942 AuditResult::Error {
943 message: out.clone(),
944 }
945 } else {
946 AuditResult::Success
947 };
948 self.log_audit_with_context(
949 command,
950 audit_result,
951 duration_ms,
952 None,
953 Some(exit_code),
954 envelope.truncated,
955 resolved,
956 )
957 .await;
958
959 let output_line = match snapshot_warning {
960 Some(warn) => format!("{warn}\n$ {command}\n{filtered}"),
961 None => format!("$ {command}\n{filtered}"),
962 };
963 Ok(Some(ToolOutput {
964 tool_name: ToolName::new("bash"),
965 summary: output_line,
966 blocks_executed: 1,
967 filter_stats: per_block_stats,
968 diff: None,
969 streamed: false,
970 terminal_id: None,
971 locations: None,
972 raw_response: None,
973 claim_source: Some(ClaimSource::Shell),
974 }))
975 }
976
977 #[allow(clippy::type_complexity)]
978 fn capture_snapshot_for(
979 &self,
980 block: &str,
981 ) -> Result<
982 (
983 Option<TransactionSnapshot>,
984 Option<String>,
985 Vec<std::path::PathBuf>,
986 ),
987 ToolError,
988 > {
989 if !(self.transactional || self.checkpoints_enabled) || !is_write_command(block) {
990 return Ok((None, None, Vec::new()));
991 }
992 let raw_paths = affected_paths(block, &self.transaction_scope_matchers);
993 if raw_paths.is_empty() {
994 return Ok((None, None, Vec::new()));
995 }
996 let paths: Vec<std::path::PathBuf> = raw_paths
1002 .into_iter()
1003 .filter(|p| {
1004 let s = p.to_string_lossy();
1005 if has_traversal(&s) {
1006 tracing::warn!(
1007 path = %p.display(),
1008 "checkpoint: skipping path with traversal sequence"
1009 );
1010 return false;
1011 }
1012 if !self.allowed_paths_canonical.is_empty() {
1013 let canonical = p
1014 .canonicalize()
1015 .or_else(|_| std::path::absolute(p))
1016 .unwrap_or_else(|_| p.clone());
1017 if !self
1018 .allowed_paths_canonical
1019 .iter()
1020 .any(|a| canonical.starts_with(a))
1021 {
1022 tracing::warn!(
1023 path = %p.display(),
1024 "checkpoint: skipping out-of-sandbox path"
1025 );
1026 return false;
1027 }
1028 }
1029 true
1030 })
1031 .collect();
1032 if paths.is_empty() {
1033 return Ok((None, None, Vec::new()));
1034 }
1035 match TransactionSnapshot::capture(&paths, self.max_snapshot_bytes) {
1036 Ok(snap) => {
1037 tracing::debug!(
1038 files = snap.file_count(),
1039 bytes = snap.total_bytes(),
1040 "transaction snapshot captured"
1041 );
1042 Ok((Some(snap), None, paths))
1043 }
1044 Err(e) if self.snapshot_required => Err(ToolError::SnapshotFailed {
1045 reason: e.to_string(),
1046 }),
1047 Err(e) => {
1048 tracing::warn!(err = %e, "transaction snapshot failed, proceeding without rollback");
1049 Ok((
1050 None,
1051 Some(format!("[warn] snapshot failed: {e}; rollback unavailable")),
1052 Vec::new(),
1053 ))
1054 }
1055 }
1056 }
1057
1058 async fn maybe_rollback(
1064 &self,
1065 snap: TransactionSnapshot,
1066 block: &str,
1067 exit_code: i32,
1068 duration_ms: u64,
1069 ) -> Option<TransactionSnapshot> {
1070 let should_rollback = self.auto_rollback
1071 && if self.auto_rollback_exit_codes.is_empty() {
1072 exit_code >= 2
1073 } else {
1074 self.auto_rollback_exit_codes.contains(&exit_code)
1075 };
1076 if !should_rollback {
1077 return Some(snap);
1079 }
1080 match snap.rollback() {
1081 Ok(report) => {
1082 tracing::info!(
1083 restored = report.restored_count,
1084 deleted = report.deleted_count,
1085 "transaction rollback completed"
1086 );
1087 self.log_audit(
1088 block,
1089 AuditResult::Rollback {
1090 restored: report.restored_count,
1091 deleted: report.deleted_count,
1092 },
1093 duration_ms,
1094 None,
1095 Some(exit_code),
1096 false,
1097 )
1098 .await;
1099 if let Some(ref tx) = self.tool_event_tx {
1100 let _ = tx
1102 .send(ToolEvent::Rollback {
1103 tool_name: ToolName::new("bash"),
1104 command: block.to_owned(),
1105 restored_count: report.restored_count,
1106 deleted_count: report.deleted_count,
1107 })
1108 .await;
1109 }
1110 }
1111 Err(e) => {
1112 tracing::error!(err = %e, "transaction rollback failed");
1113 }
1114 }
1115 None
1116 }
1117
1118 fn record_checkpoint(
1124 &self,
1125 snap: TransactionSnapshot,
1126 command: &str,
1127 paths: Vec<std::path::PathBuf>,
1128 ) {
1129 use std::time::{SystemTime, UNIX_EPOCH};
1130 let captured_at_secs = SystemTime::now()
1131 .duration_since(UNIX_EPOCH)
1132 .unwrap_or_default()
1133 .as_secs();
1134 let mut stack = self.checkpoint_stack.lock();
1135 stack.record(Checkpoint {
1136 before_snapshot: snap,
1137 command: command.to_owned(),
1138 paths,
1139 captured_at_secs,
1140 });
1141 }
1142
1143 async fn classify_and_audit(
1144 &self,
1145 block: &str,
1146 out: &str,
1147 exit_code: i32,
1148 duration_ms: u64,
1149 ) -> Option<ToolError> {
1150 if out.contains("[error] command timed out") {
1151 self.log_audit(
1152 block,
1153 AuditResult::Timeout,
1154 duration_ms,
1155 None,
1156 Some(exit_code),
1157 false,
1158 )
1159 .await;
1160 return Some(ToolError::Timeout {
1161 timeout_secs: self.timeout.as_secs(),
1162 });
1163 }
1164
1165 if let Some(category) = classify_shell_exit(exit_code, out) {
1166 return Some(ToolError::Shell {
1167 exit_code,
1168 category,
1169 message: out.lines().take(3).collect::<Vec<_>>().join("; "),
1170 });
1171 }
1172
1173 None
1174 }
1175
1176 fn apply_output_filter(
1177 &self,
1178 block: &str,
1179 out: &str,
1180 exit_code: i32,
1181 ) -> (String, Option<FilterStats>) {
1182 let sanitized = sanitize_output(out);
1183 if let Some(ref registry) = self.output_filter_registry {
1184 match registry.apply(block, &sanitized, exit_code) {
1185 Some(fr) => {
1186 tracing::debug!(
1187 command = block,
1188 raw = fr.raw_chars,
1189 filtered = fr.filtered_chars,
1190 savings_pct = fr.savings_pct(),
1191 "output filter applied"
1192 );
1193 let stats = FilterStats {
1194 raw_chars: fr.raw_chars,
1195 filtered_chars: fr.filtered_chars,
1196 raw_lines: fr.raw_lines,
1197 filtered_lines: fr.filtered_lines,
1198 confidence: Some(fr.confidence),
1199 command: Some(block.to_owned()),
1200 kept_lines: fr.kept_lines.clone(),
1201 };
1202 (fr.output, Some(stats))
1203 }
1204 None => (sanitized, None),
1205 }
1206 } else {
1207 (sanitized, None)
1208 }
1209 }
1210
1211 async fn emit_completed(
1212 &self,
1213 command: &str,
1214 output: &str,
1215 success: bool,
1216 filter_stats: Option<FilterStats>,
1217 run_id: Option<RunId>,
1218 ) {
1219 if let Some(ref tx) = self.tool_event_tx {
1220 let _ = tx
1222 .send(ToolEvent::Completed {
1223 tool_name: ToolName::new("bash"),
1224 command: command.to_owned(),
1225 output: output.to_owned(),
1226 success,
1227 filter_stats,
1228 diff: None,
1229 run_id,
1230 })
1231 .await;
1232 }
1233 }
1234
1235 #[allow(clippy::too_many_lines)]
1237 async fn check_permissions(&self, block: &str, skip_confirm: bool) -> Result<(), ToolError> {
1238 let normalized = deobfuscate::deobfuscate(block);
1240 let effective = normalized.as_str();
1241
1242 let blocked_cmd = self
1247 .find_blocked_command(block)
1248 .or_else(|| self.find_blocked_command(effective));
1249 if let Some(blocked) = blocked_cmd {
1250 let fix = safe_fix::suggest_fix(effective);
1251 let err = if let Some(suggestion) = fix {
1252 let reason = format!("{blocked} — suggestion: {}", suggestion.alternative);
1253 self.log_audit(
1254 block,
1255 AuditResult::Blocked {
1256 reason: format!("blocked command: {reason}"),
1257 },
1258 0,
1259 None,
1260 None,
1261 false,
1262 )
1263 .await;
1264 ToolError::BlockedWithFix {
1265 command: blocked,
1266 suggestion: Some(suggestion),
1267 }
1268 } else {
1269 self.log_audit(
1270 block,
1271 AuditResult::Blocked {
1272 reason: format!("blocked command: {blocked}"),
1273 },
1274 0,
1275 None,
1276 None,
1277 false,
1278 )
1279 .await;
1280 ToolError::Blocked { command: blocked }
1281 };
1282 return Err(err);
1283 }
1284
1285 if let Some(ref policy) = self.permission_policy {
1286 match policy.check("bash", effective) {
1287 PermissionAction::Deny => {
1288 let err = match safe_fix::suggest_fix(effective) {
1289 Some(suggestion) => ToolError::BlockedWithFix {
1290 command: effective.to_owned(),
1291 suggestion: Some(suggestion),
1292 },
1293 None => ToolError::Blocked {
1294 command: effective.to_owned(),
1295 },
1296 };
1297 self.log_audit(
1298 block,
1299 AuditResult::Blocked {
1300 reason: "denied by permission policy".to_owned(),
1301 },
1302 0,
1303 None,
1304 None,
1305 false,
1306 )
1307 .await;
1308 return Err(err);
1309 }
1310 PermissionAction::Ask if !skip_confirm => {
1311 return Err(ToolError::ConfirmationRequired {
1312 command: effective.to_owned(),
1313 });
1314 }
1315 _ => {}
1316 }
1317 } else if !skip_confirm {
1318 let confirm_pattern = self
1321 .find_confirm_command(block)
1322 .or_else(|| self.find_confirm_command(effective));
1323 if let Some(pattern) = confirm_pattern {
1324 return Err(ToolError::ConfirmationRequired {
1325 command: pattern.to_owned(),
1326 });
1327 }
1328 }
1329
1330 if let Some(ref chain) = self.risk_chain {
1332 let verdict = chain.record("bash", effective, self.risk_chain_threshold);
1333 if verdict.should_block {
1334 let chain_name = verdict
1335 .chain_pattern
1336 .unwrap_or_else(|| "unknown".to_owned());
1337 tracing::warn!(
1338 chain = chain_name,
1339 score = verdict.cumulative_score,
1340 "risk chain threshold exceeded"
1341 );
1342 return Err(ToolError::Blocked {
1343 command: format!(
1344 "risk chain blocked: {} (score {:.2})",
1345 chain_name, verdict.cumulative_score
1346 ),
1347 });
1348 }
1349 }
1350
1351 Ok(())
1352 }
1353
1354 #[tracing::instrument(name = "tools.shell.resolve_context", skip(self, ctx), level = "info")]
1367 pub(crate) fn resolve_context(
1368 &self,
1369 ctx: Option<&ExecutionContext>,
1370 ) -> Result<ResolvedContext, ToolError> {
1371 let mut env: HashMap<String, String> = std::env::vars().collect();
1373
1374 env.retain(|k, _| {
1376 !self
1377 .env_blocklist
1378 .iter()
1379 .any(|prefix| k.starts_with(prefix.as_str()))
1380 });
1381
1382 if let Some(skill) = self.skill_env.read().as_ref() {
1384 for (k, v) in skill {
1385 env.insert(k.clone(), v.clone());
1386 }
1387 }
1388
1389 let mut resolved_name: Option<String> = None;
1391 let mut cwd_override: Option<PathBuf> = None;
1392 let mut trusted = false;
1393
1394 if let Some(default_name) = &self.default_env
1396 && let Some(default_ctx) = self.environments.get(default_name.as_str())
1397 {
1398 resolved_name.get_or_insert_with(|| default_name.clone());
1399 if cwd_override.is_none() {
1400 cwd_override = default_ctx.cwd().map(ToOwned::to_owned);
1401 }
1402 trusted = default_ctx.is_trusted();
1403 for (k, v) in default_ctx.env_overrides() {
1404 env.insert(k.clone(), v.clone());
1405 }
1406 }
1407
1408 if let Some(ctx) = ctx {
1410 if let Some(name) = ctx.name() {
1411 if let Some(reg_ctx) = self.environments.get(name) {
1412 resolved_name = Some(name.to_owned());
1413 if let Some(cwd) = reg_ctx.cwd() {
1414 cwd_override = Some(cwd.to_owned());
1415 }
1416 trusted = reg_ctx.is_trusted();
1417 for (k, v) in reg_ctx.env_overrides() {
1418 env.insert(k.clone(), v.clone());
1419 }
1420 } else {
1421 return Err(ToolError::Execution(std::io::Error::other(format!(
1422 "unknown execution environment '{name}'"
1423 ))));
1424 }
1425 }
1426
1427 if let Some(cwd) = ctx.cwd() {
1429 cwd_override = Some(cwd.to_owned());
1430 }
1431 if !ctx.is_trusted() {
1432 trusted = false;
1433 }
1434 for (k, v) in ctx.env_overrides() {
1435 env.insert(k.clone(), v.clone());
1436 }
1437 }
1438
1439 if !trusted {
1441 env.retain(|k, _| {
1442 !self
1443 .env_blocklist
1444 .iter()
1445 .any(|prefix| k.starts_with(prefix.as_str()))
1446 });
1447 }
1448
1449 let cwd = if let Some(raw) = cwd_override {
1451 let raw = if raw.is_absolute() {
1454 raw
1455 } else {
1456 std::env::current_dir()
1457 .unwrap_or_else(|_| PathBuf::from("."))
1458 .join(raw)
1459 };
1460 let canonical = raw
1461 .canonicalize()
1462 .map_err(|_| ToolError::SandboxViolation {
1463 path: raw.display().to_string(),
1464 })?;
1465 if !self
1467 .allowed_paths_canonical
1468 .iter()
1469 .any(|p| canonical.starts_with(p))
1470 {
1471 return Err(ToolError::SandboxViolation {
1472 path: canonical.display().to_string(),
1473 });
1474 }
1475 canonical
1476 } else {
1477 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1478 };
1479
1480 Ok(ResolvedContext {
1481 cwd,
1482 env,
1483 name: resolved_name,
1484 trusted,
1485 })
1486 }
1487
1488 fn validate_sandbox_with_cwd(
1489 &self,
1490 code: &str,
1491 cwd: &std::path::Path,
1492 ) -> Result<(), ToolError> {
1493 for token in extract_paths(code) {
1494 if has_traversal(&token) {
1495 return Err(ToolError::SandboxViolation { path: token });
1496 }
1497
1498 if self.allowed_paths_canonical.is_empty() {
1499 continue;
1500 }
1501
1502 let path = if token.starts_with('/') {
1503 PathBuf::from(&token)
1504 } else {
1505 cwd.join(&token)
1506 };
1507 let canonical = if let Ok(c) = path.canonicalize() {
1513 c
1514 } else {
1515 let components: Vec<_> = path.components().collect();
1517 let mut base_len = components.len();
1518 let canonical_base = loop {
1519 if base_len == 0 {
1520 break PathBuf::new();
1521 }
1522 let candidate: PathBuf = components[..base_len].iter().collect();
1523 if let Ok(c) = candidate.canonicalize() {
1524 break c;
1525 }
1526 base_len -= 1;
1527 };
1528 components[base_len..]
1530 .iter()
1531 .fold(canonical_base, |acc, c| acc.join(c))
1532 };
1533 if !self
1534 .allowed_paths_canonical
1535 .iter()
1536 .any(|allowed| canonical.starts_with(allowed))
1537 {
1538 return Err(ToolError::SandboxViolation {
1539 path: canonical.display().to_string(),
1540 });
1541 }
1542 }
1543 Ok(())
1544 }
1545
1546 fn validate_sandbox(&self, code: &str) -> Result<(), ToolError> {
1547 let cwd = std::env::current_dir().unwrap_or_default();
1548 self.validate_sandbox_with_cwd(code, &cwd)
1549 }
1550
1551 fn find_blocked_command(&self, code: &str) -> Option<String> {
1591 let snapshot = self.policy.load_full();
1592 let cleaned = strip_shell_escapes(&code.to_lowercase());
1593 let commands = tokenize_commands(&cleaned);
1594 for cmd_tokens in &commands {
1595 let joined = cmd_tokens.join(" ");
1596 if is_blocked_rm_worktrees(&joined) {
1597 return Some("rm --recursive --force .git/worktrees".to_owned());
1598 }
1599 }
1600 for blocked in &snapshot.blocked_commands {
1601 for cmd_tokens in &commands {
1602 if tokens_match_pattern(cmd_tokens, blocked) {
1603 return Some(blocked.clone());
1604 }
1605 }
1606 }
1607 for inner in extract_subshell_contents(&cleaned) {
1609 let inner_commands = tokenize_commands(&inner);
1610 for cmd_tokens in &inner_commands {
1611 let joined = cmd_tokens.join(" ");
1612 if is_blocked_rm_worktrees(&joined) {
1613 return Some("rm --recursive --force .git/worktrees".to_owned());
1614 }
1615 }
1616 for blocked in &snapshot.blocked_commands {
1617 for cmd_tokens in &inner_commands {
1618 if tokens_match_pattern(cmd_tokens, blocked) {
1619 return Some(blocked.clone());
1620 }
1621 }
1622 }
1623 }
1624 None
1625 }
1626
1627 fn find_confirm_command(&self, code: &str) -> Option<&str> {
1628 let normalized = code.to_lowercase();
1629 for pattern in &self.confirm_patterns {
1630 if normalized.contains(pattern.as_str()) {
1631 return Some(pattern.as_str());
1632 }
1633 }
1634 None
1635 }
1636
1637 fn build_audit_entry(
1638 command: &str,
1639 result: AuditResult,
1640 duration_ms: u64,
1641 error: Option<&ToolError>,
1642 exit_code: Option<i32>,
1643 truncated: bool,
1644 resolved: Option<&ResolvedContext>,
1645 ) -> AuditEntry {
1646 let (error_category, error_domain, error_phase) = error.map_or((None, None, None), |e| {
1647 let cat = e.category();
1648 (
1649 Some(cat.label().to_owned()),
1650 Some(cat.domain().label().to_owned()),
1651 Some(cat.phase().label().to_owned()),
1652 )
1653 });
1654 AuditEntry {
1655 timestamp: chrono_now(),
1656 tool: "shell".into(),
1657 command: command.into(),
1658 result,
1659 duration_ms,
1660 error_category,
1661 error_domain,
1662 error_phase,
1663 claim_source: Some(ClaimSource::Shell),
1664 mcp_server_id: None,
1665 injection_flagged: false,
1666 embedding_anomalous: false,
1667 cross_boundary_mcp_to_acp: false,
1668 adversarial_policy_decision: None,
1669 exit_code,
1670 truncated,
1671 caller_id: None,
1672 skill_name: None,
1673 policy_match: None,
1674 correlation_id: None,
1675 vigil_risk: None,
1676 execution_env: resolved.and_then(|r| r.name.clone()),
1677 resolved_cwd: resolved.map(|r| r.cwd.display().to_string()),
1678 scope_at_definition: None,
1679 scope_at_dispatch: None,
1680 }
1681 }
1682
1683 async fn log_audit(
1684 &self,
1685 command: &str,
1686 result: AuditResult,
1687 duration_ms: u64,
1688 error: Option<&ToolError>,
1689 exit_code: Option<i32>,
1690 truncated: bool,
1691 ) {
1692 if let Some(ref logger) = self.audit_logger {
1693 let entry = Self::build_audit_entry(
1694 command,
1695 result,
1696 duration_ms,
1697 error,
1698 exit_code,
1699 truncated,
1700 None,
1701 );
1702 logger.log(&entry).await;
1703 }
1704 }
1705
1706 #[allow(clippy::too_many_arguments)]
1707 async fn log_audit_with_context(
1708 &self,
1709 command: &str,
1710 result: AuditResult,
1711 duration_ms: u64,
1712 error: Option<&ToolError>,
1713 exit_code: Option<i32>,
1714 truncated: bool,
1715 resolved: &ResolvedContext,
1716 ) {
1717 if let Some(ref logger) = self.audit_logger {
1718 let entry = Self::build_audit_entry(
1719 command,
1720 result,
1721 duration_ms,
1722 error,
1723 exit_code,
1724 truncated,
1725 Some(resolved),
1726 );
1727 logger.log(&entry).await;
1728 }
1729 }
1730}
1731
1732impl ToolExecutor for std::sync::Arc<ShellExecutor> {
1733 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
1734 self.as_ref().execute(response).await
1735 }
1736
1737 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1738 self.as_ref().tool_definitions()
1739 }
1740
1741 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
1742 self.as_ref().execute_tool_call(call).await
1743 }
1744
1745 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
1746 self.as_ref().set_skill_env(env);
1747 }
1748}
1749
1750impl ToolExecutor for ShellExecutor {
1751 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
1752 self.execute_inner(response, false).await
1753 }
1754
1755 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1756 use crate::registry::{InvocationHint, ToolDef};
1757 vec![ToolDef {
1758 id: "bash".into(),
1759 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(),
1760 schema: schemars::schema_for!(BashParams),
1761 invocation: InvocationHint::FencedBlock("bash"),
1762 output_schema: None,
1763 server_id: None,
1764 }]
1765 }
1766
1767 #[tracing::instrument(name = "tools.shell.execute_tool_call", skip(self, call), level = "info",
1768 fields(tool_id = %call.tool_id, env = call.context.as_ref().and_then(|c| c.name()).unwrap_or("")))]
1769 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
1770 if call.tool_id != "bash" {
1771 return Ok(None);
1772 }
1773 let params: BashParams = crate::executor::deserialize_params(&call.params)?;
1774 if params.command.is_empty() {
1775 return Ok(None);
1776 }
1777 let command = ¶ms.command;
1778
1779 let resolved = self.resolve_context(call.context.as_ref())?;
1782
1783 if params.background {
1784 let run_id = self
1785 .spawn_background_with_context(command, &resolved)
1786 .await?;
1787 let id_short = &run_id.to_string()[..8];
1788 return Ok(Some(ToolOutput {
1789 tool_name: ToolName::new("bash"),
1790 summary: format!(
1791 "[background] started run_id={run_id} — command: {command}\n\
1792 The command is running in the background. When it completes, \
1793 results will appear at the start of the next turn (run_id_short={id_short})."
1794 ),
1795 blocks_executed: 1,
1796 filter_stats: None,
1797 diff: None,
1798 streamed: true,
1799 terminal_id: None,
1800 locations: None,
1801 raw_response: None,
1802 claim_source: Some(ClaimSource::Shell),
1803 }));
1804 }
1805
1806 self.execute_block_with_context(command, false, &resolved, &call.tool_call_id)
1807 .await
1808 }
1809
1810 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
1811 ShellExecutor::set_skill_env(self, env);
1812 }
1813
1814 fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
1815 let result = self
1816 .checkpoint_stack
1817 .lock()
1818 .undo(n, self.max_snapshot_bytes);
1819 crate::executor::CheckpointActionResult {
1820 reverted_commands: result.reverted_commands,
1821 restored: result.restored,
1822 deleted: result.deleted,
1823 supported: true,
1824 message: result.message,
1825 }
1826 }
1827
1828 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
1829 let result = self.checkpoint_stack.lock().redo(self.max_snapshot_bytes);
1830 crate::executor::CheckpointActionResult {
1831 reverted_commands: result.reverted_commands,
1832 restored: result.restored,
1833 deleted: result.deleted,
1834 supported: true,
1835 message: result.message,
1836 }
1837 }
1838
1839 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
1840 let stack = self.checkpoint_stack.lock();
1841 let entries = stack
1842 .list_undo()
1843 .into_iter()
1844 .map(|e| crate::executor::CheckpointEntryView {
1845 index: e.index,
1846 command: e.command,
1847 captured_at_secs: e.captured_at_secs,
1848 file_count: e.file_count,
1849 })
1850 .collect();
1851 crate::executor::CheckpointListResult {
1852 entries,
1853 redo_depth: stack.redo_depth(),
1854 supported: true,
1855 }
1856 }
1857}
1858
1859impl ShellExecutor {
1860 pub async fn spawn_background(&self, command: &str) -> Result<RunId, ToolError> {
1875 use std::sync::atomic::Ordering;
1876
1877 if self.shutting_down.load(Ordering::Acquire) {
1879 return Err(ToolError::Blocked {
1880 command: command.to_owned(),
1881 });
1882 }
1883
1884 self.check_permissions(command, false).await?;
1886 self.validate_sandbox(command)?;
1887
1888 let run_id = RunId::new();
1890 let mut runs = self.background_runs.lock();
1891 if runs.len() >= self.max_background_runs {
1892 return Err(ToolError::Blocked {
1893 command: format!(
1894 "background run cap reached (max_background_runs={})",
1895 self.max_background_runs
1896 ),
1897 });
1898 }
1899 let abort = CancellationToken::new();
1900 runs.insert(
1901 run_id,
1902 BackgroundHandle {
1903 command: command.to_owned(),
1904 started_at: std::time::Instant::now(),
1905 abort: abort.clone(),
1906 child_pid: None,
1907 },
1908 );
1909 drop(runs);
1910
1911 let tool_event_tx = self.tool_event_tx.clone();
1912 let background_completion_tx = self.background_completion_tx.clone();
1913 let background_runs = Arc::clone(&self.background_runs);
1914 let timeout = self.background_timeout;
1915 let env_blocklist = self.env_blocklist.clone();
1916 let skill_env_snapshot: Option<std::collections::HashMap<String, String>> =
1917 self.skill_env.read().clone();
1918 let command_owned = command.to_owned();
1919
1920 if let Some(ref sup) = self.task_supervisor {
1921 let task_name: Arc<str> = Arc::from(format!("shell_bg_{run_id}").as_str());
1922 drop(sup.spawn_oneshot(task_name, move || {
1926 run_background_task(
1927 run_id,
1928 command_owned,
1929 timeout,
1930 abort,
1931 background_runs,
1932 tool_event_tx,
1933 background_completion_tx,
1934 skill_env_snapshot,
1935 env_blocklist,
1936 )
1937 }));
1938 } else {
1939 tokio::spawn(run_background_task(
1940 run_id,
1941 command_owned,
1942 timeout,
1943 abort,
1944 background_runs,
1945 tool_event_tx,
1946 background_completion_tx,
1947 skill_env_snapshot,
1948 env_blocklist,
1949 ));
1950 }
1951
1952 Ok(run_id)
1953 }
1954
1955 async fn spawn_background_with_context(
1964 &self,
1965 command: &str,
1966 resolved: &ResolvedContext,
1967 ) -> Result<RunId, ToolError> {
1968 use std::sync::atomic::Ordering;
1969
1970 if self.shutting_down.load(Ordering::Acquire) {
1971 return Err(ToolError::Blocked {
1972 command: command.to_owned(),
1973 });
1974 }
1975
1976 self.check_permissions(command, false).await?;
1977 self.validate_sandbox_with_cwd(command, &resolved.cwd)?;
1978
1979 let run_id = RunId::new();
1980 let mut runs = self.background_runs.lock();
1981 if runs.len() >= self.max_background_runs {
1982 return Err(ToolError::Blocked {
1983 command: format!(
1984 "background run cap reached (max_background_runs={})",
1985 self.max_background_runs
1986 ),
1987 });
1988 }
1989 let abort = CancellationToken::new();
1990 runs.insert(
1991 run_id,
1992 BackgroundHandle {
1993 command: command.to_owned(),
1994 started_at: std::time::Instant::now(),
1995 abort: abort.clone(),
1996 child_pid: None,
1997 },
1998 );
1999 drop(runs);
2000
2001 let tool_event_tx = self.tool_event_tx.clone();
2002 let background_completion_tx = self.background_completion_tx.clone();
2003 let background_runs = Arc::clone(&self.background_runs);
2004 let timeout = self.background_timeout;
2005 let env = resolved.env.clone();
2006 let cwd = resolved.cwd.clone();
2007 let command_owned = command.to_owned();
2008
2009 if let Some(ref sup) = self.task_supervisor {
2010 let task_name: Arc<str> = Arc::from(format!("shell_bg_{run_id}").as_str());
2011 drop(sup.spawn_oneshot(task_name, move || {
2012 run_background_task_with_env(
2013 run_id,
2014 command_owned,
2015 timeout,
2016 abort,
2017 background_runs,
2018 tool_event_tx,
2019 background_completion_tx,
2020 env,
2021 cwd,
2022 )
2023 }));
2024 } else {
2025 tokio::spawn(run_background_task_with_env(
2026 run_id,
2027 command_owned,
2028 timeout,
2029 abort,
2030 background_runs,
2031 tool_event_tx,
2032 background_completion_tx,
2033 env,
2034 cwd,
2035 ));
2036 }
2037
2038 Ok(run_id)
2039 }
2040
2041 pub async fn shutdown(&self) {
2047 use std::sync::atomic::Ordering;
2048
2049 self.shutting_down.store(true, Ordering::Release);
2050
2051 let handles: Vec<(RunId, String, CancellationToken, Option<u32>)> = {
2052 let runs = self.background_runs.lock();
2053 runs.iter()
2054 .map(|(id, h)| (*id, h.command.clone(), h.abort.clone(), h.child_pid))
2055 .collect()
2056 };
2057
2058 if handles.is_empty() {
2059 return;
2060 }
2061
2062 tracing::info!(
2063 count = handles.len(),
2064 "cancelling background shell runs for shutdown"
2065 );
2066
2067 for (run_id, command, abort, pid_opt) in &handles {
2068 abort.cancel();
2069
2070 #[cfg(unix)]
2071 if let Some(pid) = pid_opt {
2072 send_signal_with_escalation(*pid).await;
2073 }
2074 #[cfg(not(unix))]
2075 let _ = pid_opt;
2076
2077 if let Some(ref tx) = self.tool_event_tx {
2078 let _ = tx
2079 .send(ToolEvent::Completed {
2080 tool_name: ToolName::new("bash"),
2081 command: command.clone(),
2082 output: "[terminated by shutdown]".to_owned(),
2083 success: false,
2084 filter_stats: None,
2085 diff: None,
2086 run_id: Some(*run_id),
2087 })
2088 .await;
2089 }
2090 }
2091
2092 self.background_runs.lock().clear();
2093 }
2094}
2095
2096#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
2106async fn run_background_task(
2107 run_id: RunId,
2108 command: String,
2109 timeout: Duration,
2110 abort: CancellationToken,
2111 background_runs: Arc<Mutex<HashMap<RunId, BackgroundHandle>>>,
2112 tool_event_tx: Option<ToolEventTx>,
2113 background_completion_tx: Option<tokio::sync::mpsc::Sender<BackgroundCompletion>>,
2114 skill_env_snapshot: Option<std::collections::HashMap<String, String>>,
2115 env_blocklist: Vec<String>,
2116) {
2117 use std::process::Stdio;
2118
2119 let started_at = std::time::Instant::now();
2120
2121 let mut cmd = build_bash_command(&command, skill_env_snapshot.as_ref(), &env_blocklist);
2126 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2127
2128 let mut child = match cmd.spawn() {
2129 Ok(c) => c,
2130 Err(ref e) => {
2131 let (_, out) = spawn_error_envelope(e);
2132 background_runs.lock().remove(&run_id);
2133 emit_completed(tool_event_tx.as_ref(), &command, out.clone(), false, run_id).await;
2134 if let Some(ref tx) = background_completion_tx {
2135 let _ = tx
2136 .send(BackgroundCompletion {
2137 run_id,
2138 exit_code: 1,
2139 output: out,
2140 success: false,
2141 elapsed_ms: 0,
2142 command,
2143 })
2144 .await;
2145 }
2146 return;
2147 }
2148 };
2149
2150 if let Some(pid) = child.id()
2152 && let Some(handle) = background_runs.lock().get_mut(&run_id)
2153 {
2154 handle.child_pid = Some(pid);
2155 }
2156
2157 let stdout = child.stdout.take().expect("stdout piped");
2159 let stderr = child.stderr.take().expect("stderr piped");
2160 let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2161
2162 let mut combined = String::new();
2163 let mut stdout_buf = String::new();
2164 let mut stderr_buf = String::new();
2165 let deadline = tokio::time::Instant::now() + timeout;
2166 let timeout_secs = timeout.as_secs();
2167
2168 let (_, out) = match run_bash_stream(
2169 &command,
2170 deadline,
2171 Some(&abort),
2172 tool_event_tx.as_ref(),
2173 "",
2174 &mut line_rx,
2175 &mut combined,
2176 &mut stdout_buf,
2177 &mut stderr_buf,
2178 &mut child,
2179 )
2180 .await
2181 {
2182 BashLoopOutcome::TimedOut => (
2183 ShellOutputEnvelope {
2184 stdout: stdout_buf,
2185 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2186 exit_code: 1,
2187 truncated: false,
2188 },
2189 format!("[error] command timed out after {timeout_secs}s"),
2190 ),
2191 BashLoopOutcome::Cancelled => (
2192 ShellOutputEnvelope {
2193 stdout: stdout_buf,
2194 stderr: format!("{stderr_buf}operation aborted"),
2195 exit_code: 130,
2196 truncated: false,
2197 },
2198 "[cancelled] operation aborted".to_string(),
2199 ),
2200 BashLoopOutcome::StreamClosed => {
2201 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2202 }
2203 };
2204
2205 #[allow(clippy::cast_possible_truncation)]
2206 let elapsed_ms = started_at.elapsed().as_millis() as u64;
2207 let success = !out.contains("[error]");
2208 let exit_code = i32::from(!success);
2209 let truncated = crate::executor::truncate_tool_output_at(&out, 4096);
2210
2211 background_runs.lock().remove(&run_id);
2212 emit_completed(
2213 tool_event_tx.as_ref(),
2214 &command,
2215 truncated.clone(),
2216 success,
2217 run_id,
2218 )
2219 .await;
2220
2221 if let Some(ref tx) = background_completion_tx {
2222 let completion = BackgroundCompletion {
2223 run_id,
2224 exit_code,
2225 output: truncated,
2226 success,
2227 elapsed_ms,
2228 command,
2229 };
2230 if tx.send(completion).await.is_err() {
2231 tracing::warn!(
2232 run_id = %run_id,
2233 "background completion channel closed; agent may have shut down"
2234 );
2235 }
2236 }
2237
2238 tracing::debug!(run_id = %run_id, exit_code, elapsed_ms, "background shell run completed");
2239}
2240
2241#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
2244async fn run_background_task_with_env(
2245 run_id: RunId,
2246 command: String,
2247 timeout: Duration,
2248 abort: CancellationToken,
2249 background_runs: Arc<Mutex<HashMap<RunId, BackgroundHandle>>>,
2250 tool_event_tx: Option<ToolEventTx>,
2251 background_completion_tx: Option<tokio::sync::mpsc::Sender<BackgroundCompletion>>,
2252 env: HashMap<String, String>,
2253 cwd: PathBuf,
2254) {
2255 use std::process::Stdio;
2256
2257 let started_at = std::time::Instant::now();
2258
2259 let mut cmd = build_bash_command_with_context(&command, &env, &cwd);
2260 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2261
2262 let mut child = match cmd.spawn() {
2263 Ok(c) => c,
2264 Err(ref e) => {
2265 let (_, out) = spawn_error_envelope(e);
2266 background_runs.lock().remove(&run_id);
2267 emit_completed(tool_event_tx.as_ref(), &command, out.clone(), false, run_id).await;
2268 if let Some(ref tx) = background_completion_tx {
2269 let _ = tx
2270 .send(BackgroundCompletion {
2271 run_id,
2272 exit_code: 1,
2273 output: out,
2274 success: false,
2275 elapsed_ms: 0,
2276 command,
2277 })
2278 .await;
2279 }
2280 return;
2281 }
2282 };
2283
2284 if let Some(pid) = child.id()
2285 && let Some(handle) = background_runs.lock().get_mut(&run_id)
2286 {
2287 handle.child_pid = Some(pid);
2288 }
2289
2290 let stdout = child.stdout.take().expect("stdout piped");
2291 let stderr = child.stderr.take().expect("stderr piped");
2292 let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2293
2294 let mut combined = String::new();
2295 let mut stdout_buf = String::new();
2296 let mut stderr_buf = String::new();
2297 let deadline = tokio::time::Instant::now() + timeout;
2298 let timeout_secs = timeout.as_secs();
2299
2300 let (_, out) = match run_bash_stream(
2301 &command,
2302 deadline,
2303 Some(&abort),
2304 tool_event_tx.as_ref(),
2305 "",
2306 &mut line_rx,
2307 &mut combined,
2308 &mut stdout_buf,
2309 &mut stderr_buf,
2310 &mut child,
2311 )
2312 .await
2313 {
2314 BashLoopOutcome::TimedOut => (
2315 ShellOutputEnvelope {
2316 stdout: stdout_buf,
2317 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2318 exit_code: 1,
2319 truncated: false,
2320 },
2321 format!("[error] command timed out after {timeout_secs}s"),
2322 ),
2323 BashLoopOutcome::Cancelled => (
2324 ShellOutputEnvelope {
2325 stdout: stdout_buf,
2326 stderr: stderr_buf,
2327 exit_code: 130,
2328 truncated: false,
2329 },
2330 "[cancelled] operation aborted".to_string(),
2331 ),
2332 BashLoopOutcome::StreamClosed => {
2333 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2334 }
2335 };
2336
2337 #[allow(clippy::cast_possible_truncation)]
2338 let elapsed_ms = started_at.elapsed().as_millis() as u64;
2339 let success = !out.contains("[error]");
2340 let exit_code = i32::from(!success);
2341 let truncated = crate::executor::truncate_tool_output_at(&out, 4096);
2342
2343 background_runs.lock().remove(&run_id);
2344 emit_completed(
2345 tool_event_tx.as_ref(),
2346 &command,
2347 truncated.clone(),
2348 success,
2349 run_id,
2350 )
2351 .await;
2352
2353 if let Some(ref tx) = background_completion_tx {
2354 let completion = BackgroundCompletion {
2355 run_id,
2356 exit_code,
2357 output: truncated,
2358 success,
2359 elapsed_ms,
2360 command,
2361 };
2362 if tx.send(completion).await.is_err() {
2363 tracing::warn!(
2364 run_id = %run_id,
2365 "background completion channel closed; agent may have shut down"
2366 );
2367 }
2368 }
2369
2370 tracing::debug!(run_id = %run_id, exit_code, elapsed_ms, "background shell run (with context) completed");
2371}
2372
2373async fn emit_completed(
2375 tool_event_tx: Option<&ToolEventTx>,
2376 command: &str,
2377 output: String,
2378 success: bool,
2379 run_id: RunId,
2380) {
2381 if let Some(tx) = tool_event_tx {
2382 let _ = tx
2383 .send(ToolEvent::Completed {
2384 tool_name: ToolName::new("bash"),
2385 command: command.to_owned(),
2386 output,
2387 success,
2388 filter_stats: None,
2389 diff: None,
2390 run_id: Some(run_id),
2391 })
2392 .await;
2393 }
2394}
2395
2396pub(crate) fn strip_shell_escapes(input: &str) -> String {
2400 let mut out = String::with_capacity(input.len());
2401 let bytes = input.as_bytes();
2402 let mut i = 0;
2403 while i < bytes.len() {
2404 if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'\'' {
2406 let mut j = i + 2; let mut decoded = String::new();
2408 let mut valid = false;
2409 while j < bytes.len() && bytes[j] != b'\'' {
2410 if bytes[j] == b'\\' && j + 1 < bytes.len() {
2411 let next = bytes[j + 1];
2412 if next == b'x' && j + 3 < bytes.len() {
2413 let hi = (bytes[j + 2] as char).to_digit(16);
2415 let lo = (bytes[j + 3] as char).to_digit(16);
2416 if let (Some(h), Some(l)) = (hi, lo) {
2417 #[allow(clippy::cast_possible_truncation)]
2418 let byte = ((h << 4) | l) as u8;
2419 decoded.push(byte as char);
2420 j += 4;
2421 valid = true;
2422 continue;
2423 }
2424 } else if next.is_ascii_digit() {
2425 let mut val = u32::from(next - b'0');
2427 let mut len = 2; if j + 2 < bytes.len() && bytes[j + 2].is_ascii_digit() {
2429 val = val * 8 + u32::from(bytes[j + 2] - b'0');
2430 len = 3;
2431 if j + 3 < bytes.len() && bytes[j + 3].is_ascii_digit() {
2432 val = val * 8 + u32::from(bytes[j + 3] - b'0');
2433 len = 4;
2434 }
2435 }
2436 #[allow(clippy::cast_possible_truncation)]
2437 decoded.push((val & 0xFF) as u8 as char);
2438 j += len;
2439 valid = true;
2440 continue;
2441 }
2442 decoded.push(next as char);
2444 j += 2;
2445 } else {
2446 decoded.push(bytes[j] as char);
2447 j += 1;
2448 }
2449 }
2450 if j < bytes.len() && bytes[j] == b'\'' && valid {
2451 out.push_str(&decoded);
2452 i = j + 1;
2453 continue;
2454 }
2455 }
2457 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
2459 i += 2;
2460 continue;
2461 }
2462 if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] != b'\n' {
2464 i += 1;
2465 out.push(bytes[i] as char);
2466 i += 1;
2467 continue;
2468 }
2469 if bytes[i] == b'"' || bytes[i] == b'\'' {
2471 let quote = bytes[i];
2472 i += 1;
2473 while i < bytes.len() && bytes[i] != quote {
2474 out.push(bytes[i] as char);
2475 i += 1;
2476 }
2477 if i < bytes.len() {
2478 i += 1; }
2480 continue;
2481 }
2482 out.push(bytes[i] as char);
2483 i += 1;
2484 }
2485 out
2486}
2487
2488pub(crate) fn extract_subshell_contents(s: &str) -> Vec<String> {
2498 let mut results = Vec::new();
2499 let chars: Vec<char> = s.chars().collect();
2500 let len = chars.len();
2501 let mut i = 0;
2502
2503 while i < len {
2504 if chars[i] == '`' {
2506 let start = i + 1;
2507 let mut j = start;
2508 while j < len && chars[j] != '`' {
2509 j += 1;
2510 }
2511 if j < len {
2512 results.push(chars[start..j].iter().collect());
2513 }
2514 i = j + 1;
2515 continue;
2516 }
2517
2518 let next_is_open_paren = i + 1 < len && chars[i + 1] == '(';
2520 let is_paren_subshell = next_is_open_paren && matches!(chars[i], '$' | '<' | '>');
2521
2522 if is_paren_subshell {
2523 let start = i + 2;
2524 let mut depth: usize = 1;
2525 let mut j = start;
2526 while j < len && depth > 0 {
2527 match chars[j] {
2528 '(' => depth += 1,
2529 ')' => depth -= 1,
2530 _ => {}
2531 }
2532 if depth > 0 {
2533 j += 1;
2534 } else {
2535 break;
2536 }
2537 }
2538 if depth == 0 {
2539 results.push(chars[start..j].iter().collect());
2540 }
2541 i = j + 1;
2542 continue;
2543 }
2544
2545 i += 1;
2546 }
2547
2548 results
2549}
2550
2551pub(crate) fn tokenize_commands(normalized: &str) -> Vec<Vec<String>> {
2554 let replaced = normalized.replace("||", "\n").replace("&&", "\n");
2556 replaced
2557 .split([';', '|', '\n'])
2558 .map(|seg| {
2559 seg.split_whitespace()
2560 .map(str::to_owned)
2561 .collect::<Vec<String>>()
2562 })
2563 .filter(|tokens| !tokens.is_empty())
2564 .collect()
2565}
2566
2567const TRANSPARENT_PREFIXES: &[&str] = &["env", "command", "exec", "nice", "nohup", "time", "xargs"];
2570
2571fn cmd_basename(tok: &str) -> &str {
2573 tok.rsplit('/').next().unwrap_or(tok)
2574}
2575
2576pub(crate) fn tokens_match_pattern(tokens: &[String], pattern: &str) -> bool {
2583 if tokens.is_empty() || pattern.is_empty() {
2584 return false;
2585 }
2586 let pattern = pattern.trim();
2587 let pattern_tokens: Vec<&str> = pattern.split_whitespace().collect();
2588 if pattern_tokens.is_empty() {
2589 return false;
2590 }
2591
2592 let start = tokens
2594 .iter()
2595 .position(|t| !TRANSPARENT_PREFIXES.contains(&cmd_basename(t)))
2596 .unwrap_or(0);
2597 let effective = &tokens[start..];
2598 if effective.is_empty() {
2599 return false;
2600 }
2601
2602 if pattern_tokens.len() == 1 {
2603 let pat = pattern_tokens[0];
2604 let base = cmd_basename(&effective[0]);
2605 base == pat || base.starts_with(&format!("{pat}."))
2607 } else {
2608 let n = pattern_tokens.len().min(effective.len());
2610 let mut parts: Vec<&str> = vec![cmd_basename(&effective[0])];
2611 parts.extend(effective[1..n].iter().map(String::as_str));
2612 let joined = parts.join(" ");
2613 if joined.starts_with(pattern) {
2614 return true;
2615 }
2616 if effective.len() > n {
2617 let mut parts2: Vec<&str> = vec![cmd_basename(&effective[0])];
2618 parts2.extend(effective[1..=n].iter().map(String::as_str));
2619 parts2.join(" ").starts_with(pattern)
2620 } else {
2621 false
2622 }
2623 }
2624}
2625
2626fn extract_paths(code: &str) -> Vec<String> {
2627 let mut result = Vec::new();
2628
2629 let mut tokens: Vec<String> = Vec::new();
2631 let mut current = String::new();
2632 let mut chars = code.chars().peekable();
2633 while let Some(c) = chars.next() {
2634 match c {
2635 '"' | '\'' => {
2636 let quote = c;
2637 while let Some(&nc) = chars.peek() {
2638 if nc == quote {
2639 chars.next();
2640 break;
2641 }
2642 current.push(chars.next().unwrap());
2643 }
2644 }
2645 c if c.is_whitespace() || matches!(c, ';' | '|' | '&') => {
2646 if !current.is_empty() {
2647 tokens.push(std::mem::take(&mut current));
2648 }
2649 }
2650 _ => current.push(c),
2651 }
2652 }
2653 if !current.is_empty() {
2654 tokens.push(current);
2655 }
2656
2657 for token in tokens {
2658 let trimmed = token.trim_end_matches([';', '&', '|']).to_owned();
2659 if trimmed.is_empty() {
2660 continue;
2661 }
2662 if trimmed.starts_with('/')
2663 || trimmed.starts_with("./")
2664 || trimmed.starts_with("../")
2665 || trimmed == ".."
2666 || (trimmed.starts_with('.') && trimmed.contains('/'))
2667 || is_relative_path_token(&trimmed)
2668 {
2669 result.push(trimmed);
2670 }
2671 }
2672 result
2673}
2674
2675fn is_relative_path_token(token: &str) -> bool {
2682 if !token.contains('/') || token.starts_with('/') || token.starts_with('.') {
2684 return false;
2685 }
2686 if token.contains("://") {
2688 return false;
2689 }
2690 if let Some(eq_pos) = token.find('=') {
2692 let key = &token[..eq_pos];
2693 if key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
2694 return false;
2695 }
2696 }
2697 token
2699 .chars()
2700 .next()
2701 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
2702}
2703
2704fn classify_shell_exit(
2710 exit_code: i32,
2711 output: &str,
2712) -> Option<crate::error_taxonomy::ToolErrorCategory> {
2713 use crate::error_taxonomy::ToolErrorCategory;
2714 match exit_code {
2715 126 => Some(ToolErrorCategory::PolicyBlocked),
2717 127 => Some(ToolErrorCategory::PermanentFailure),
2719 _ => {
2720 let lower = output.to_lowercase();
2721 if lower.contains("permission denied") {
2722 Some(ToolErrorCategory::PolicyBlocked)
2723 } else if lower.contains("no such file or directory") {
2724 Some(ToolErrorCategory::PermanentFailure)
2725 } else {
2726 None
2727 }
2728 }
2729 }
2730}
2731
2732fn has_traversal(path: &str) -> bool {
2733 path.split(['/', '\\']).any(|seg| seg == "..")
2734}
2735
2736fn extract_bash_blocks(text: &str) -> Vec<&str> {
2737 crate::executor::extract_fenced_blocks(text, "bash")
2738}
2739
2740#[cfg(unix)]
2756async fn send_signal_with_escalation(pid: u32) {
2757 use nix::errno::Errno;
2758 use nix::sys::signal::{Signal, kill};
2759 use nix::unistd::Pid;
2760
2761 let Ok(pid_i32) = i32::try_from(pid) else {
2762 return;
2763 };
2764 let target = Pid::from_raw(pid_i32);
2765
2766 if let Err(e) = kill(target, Signal::SIGTERM)
2767 && e != Errno::ESRCH
2768 {
2769 tracing::debug!(pid, err = %e, "SIGTERM failed");
2770 }
2771 tokio::time::sleep(GRACEFUL_TERM_MS).await;
2772 let _ = Command::new("pkill")
2774 .args(["-KILL", "-P", &pid.to_string()])
2775 .status()
2776 .await;
2777 if let Err(e) = kill(target, Signal::SIGKILL)
2778 && e != Errno::ESRCH
2779 {
2780 tracing::debug!(pid, err = %e, "SIGKILL failed");
2781 }
2782}
2783
2784async fn kill_process_tree(child: &mut tokio::process::Child) {
2790 #[cfg(unix)]
2791 if let Some(pid) = child.id() {
2792 send_signal_with_escalation(pid).await;
2793 }
2794 let _ = child.kill().await;
2795}
2796
2797#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2802pub struct ShellOutputEnvelope {
2803 pub stdout: String,
2805 pub stderr: String,
2807 pub exit_code: i32,
2809 pub truncated: bool,
2811}
2812
2813#[allow(dead_code, clippy::too_many_arguments)]
2815async fn execute_bash(
2816 code: &str,
2817 timeout: Duration,
2818 event_tx: Option<&ToolEventTx>,
2819 cancel_token: Option<&CancellationToken>,
2820 extra_env: Option<&std::collections::HashMap<String, String>>,
2821 env_blocklist: &[String],
2822 sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2823 tool_call_id: &str,
2824) -> (ShellOutputEnvelope, String) {
2825 use std::process::Stdio;
2826
2827 let timeout_secs = timeout.as_secs();
2828 let mut cmd = build_bash_command(code, extra_env, env_blocklist);
2829
2830 if let Err(envelope_err) = apply_sandbox(&mut cmd, sandbox) {
2831 return envelope_err;
2832 }
2833
2834 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2835
2836 let mut child = match cmd.spawn() {
2837 Ok(c) => c,
2838 Err(ref e) => return spawn_error_envelope(e),
2839 };
2840
2841 let stdout = child.stdout.take().expect("stdout piped");
2842 let stderr = child.stderr.take().expect("stderr piped");
2843 let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2844
2845 let mut combined = String::new();
2846 let mut stdout_buf = String::new();
2847 let mut stderr_buf = String::new();
2848 let deadline = tokio::time::Instant::now() + timeout;
2849
2850 match run_bash_stream(
2851 code,
2852 deadline,
2853 cancel_token,
2854 event_tx,
2855 tool_call_id,
2856 &mut line_rx,
2857 &mut combined,
2858 &mut stdout_buf,
2859 &mut stderr_buf,
2860 &mut child,
2861 )
2862 .await
2863 {
2864 BashLoopOutcome::TimedOut => {
2865 let msg = format!("[error] command timed out after {timeout_secs}s");
2866 (
2867 ShellOutputEnvelope {
2868 stdout: stdout_buf,
2869 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2870 exit_code: 1,
2871 truncated: false,
2872 },
2873 msg,
2874 )
2875 }
2876 BashLoopOutcome::Cancelled => (
2877 ShellOutputEnvelope {
2878 stdout: stdout_buf,
2879 stderr: format!("{stderr_buf}operation aborted"),
2880 exit_code: 130,
2881 truncated: false,
2882 },
2883 "[cancelled] operation aborted".to_string(),
2884 ),
2885 BashLoopOutcome::StreamClosed => {
2886 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2887 }
2888 }
2889}
2890
2891fn build_bash_command(
2892 code: &str,
2893 extra_env: Option<&std::collections::HashMap<String, String>>,
2894 env_blocklist: &[String],
2895) -> Command {
2896 let mut cmd = Command::new("bash");
2897 cmd.arg("-c").arg(code);
2898 for (key, _) in std::env::vars() {
2899 if env_blocklist
2900 .iter()
2901 .any(|prefix| key.starts_with(prefix.as_str()))
2902 {
2903 cmd.env_remove(&key);
2904 }
2905 }
2906 if let Some(env) = extra_env {
2907 cmd.envs(env);
2908 }
2909 cmd
2910}
2911
2912fn build_bash_command_with_context(
2917 code: &str,
2918 resolved_env: &HashMap<String, String>,
2919 cwd: &std::path::Path,
2920) -> Command {
2921 let mut cmd = Command::new("bash");
2922 cmd.arg("-c").arg(code);
2923 cmd.env_clear();
2924 cmd.envs(resolved_env);
2925 cmd.current_dir(cwd);
2926 cmd
2927}
2928
2929async fn execute_bash_with_context(
2934 code: &str,
2935 timeout: Duration,
2936 event_tx: Option<&ToolEventTx>,
2937 tool_call_id: &str,
2938 cancel_token: Option<&CancellationToken>,
2939 resolved: &ResolvedContext,
2940 sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2941) -> (ShellOutputEnvelope, String) {
2942 use std::process::Stdio;
2943
2944 let timeout_secs = timeout.as_secs();
2945 let mut cmd = build_bash_command_with_context(code, &resolved.env, &resolved.cwd);
2946
2947 if let Err(envelope_err) = apply_sandbox(&mut cmd, sandbox) {
2948 return envelope_err;
2949 }
2950
2951 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2952
2953 let mut child = match cmd.spawn() {
2954 Ok(c) => c,
2955 Err(ref e) => return spawn_error_envelope(e),
2956 };
2957
2958 let stdout = child.stdout.take().expect("stdout piped");
2959 let stderr = child.stderr.take().expect("stderr piped");
2960 let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2961
2962 let mut combined = String::new();
2963 let mut stdout_buf = String::new();
2964 let mut stderr_buf = String::new();
2965 let deadline = tokio::time::Instant::now() + timeout;
2966
2967 match run_bash_stream(
2968 code,
2969 deadline,
2970 cancel_token,
2971 event_tx,
2972 tool_call_id,
2973 &mut line_rx,
2974 &mut combined,
2975 &mut stdout_buf,
2976 &mut stderr_buf,
2977 &mut child,
2978 )
2979 .await
2980 {
2981 BashLoopOutcome::TimedOut => {
2982 let msg = format!("[error] command timed out after {timeout_secs}s");
2983 (
2984 ShellOutputEnvelope {
2985 stdout: stdout_buf,
2986 stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2987 exit_code: 1,
2988 truncated: false,
2989 },
2990 msg,
2991 )
2992 }
2993 BashLoopOutcome::Cancelled => (
2994 ShellOutputEnvelope {
2995 stdout: stdout_buf,
2996 stderr: format!("{stderr_buf}operation aborted"),
2997 exit_code: 130,
2998 truncated: false,
2999 },
3000 "[cancelled] operation aborted".to_string(),
3001 ),
3002 BashLoopOutcome::StreamClosed => {
3003 finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
3004 }
3005 }
3006}
3007
3008fn apply_sandbox(
3009 cmd: &mut Command,
3010 sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
3011) -> Result<(), (ShellOutputEnvelope, String)> {
3012 if let Some((sb, policy)) = sandbox
3014 && let Err(err) = sb.wrap(cmd, policy)
3015 {
3016 let msg = format!("[error] sandbox setup failed: {err}");
3017 return Err((
3018 ShellOutputEnvelope {
3019 stdout: String::new(),
3020 stderr: msg.clone(),
3021 exit_code: 1,
3022 truncated: false,
3023 },
3024 msg,
3025 ));
3026 }
3027 Ok(())
3028}
3029
3030fn spawn_error_envelope(e: &std::io::Error) -> (ShellOutputEnvelope, String) {
3031 let msg = format!("[error] {e}");
3032 (
3033 ShellOutputEnvelope {
3034 stdout: String::new(),
3035 stderr: msg.clone(),
3036 exit_code: 1,
3037 truncated: false,
3038 },
3039 msg,
3040 )
3041}
3042
3043fn spawn_output_readers(
3049 stdout: tokio::process::ChildStdout,
3050 stderr: tokio::process::ChildStderr,
3051) -> (
3052 tokio::sync::mpsc::Receiver<(bool, String)>,
3053 tokio::task::JoinSet<()>,
3054) {
3055 use tokio::io::{AsyncBufReadExt, BufReader};
3056
3057 let (line_tx, line_rx) = tokio::sync::mpsc::channel::<(bool, String)>(64);
3058 let mut readers = tokio::task::JoinSet::new();
3059
3060 let stdout_tx = line_tx.clone();
3061 readers.spawn(async move {
3062 let mut reader = BufReader::new(stdout);
3063 let mut buf = String::new();
3064 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
3065 let _ = stdout_tx.send((false, buf.clone())).await;
3066 buf.clear();
3067 }
3068 });
3069
3070 readers.spawn(async move {
3071 let mut reader = BufReader::new(stderr);
3072 let mut buf = String::new();
3073 while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
3074 let _ = line_tx.send((true, buf.clone())).await;
3075 buf.clear();
3076 }
3077 });
3078
3079 (line_rx, readers)
3080}
3081
3082enum BashLoopOutcome {
3087 StreamClosed,
3088 TimedOut,
3089 Cancelled,
3090}
3091
3092#[allow(clippy::too_many_arguments)]
3093async fn run_bash_stream(
3094 code: &str,
3095 deadline: tokio::time::Instant,
3096 cancel_token: Option<&CancellationToken>,
3097 event_tx: Option<&ToolEventTx>,
3098 tool_call_id: &str,
3099 line_rx: &mut tokio::sync::mpsc::Receiver<(bool, String)>,
3100 combined: &mut String,
3101 stdout_buf: &mut String,
3102 stderr_buf: &mut String,
3103 child: &mut tokio::process::Child,
3104) -> BashLoopOutcome {
3105 loop {
3106 tokio::select! {
3107 line = line_rx.recv() => {
3108 match line {
3109 Some((is_stderr, chunk)) => {
3110 let interleaved = if is_stderr {
3111 format!("[stderr] {chunk}")
3112 } else {
3113 chunk.clone()
3114 };
3115 if let Some(tx) = event_tx {
3116 let _ = tx.try_send(ToolEvent::OutputChunk {
3118 tool_name: ToolName::new("bash"),
3119 command: code.to_owned(),
3120 chunk: interleaved.clone(),
3121 tool_call_id: tool_call_id.to_owned(),
3122 skill_name: None,
3123 });
3124 }
3125 combined.push_str(&interleaved);
3126 if is_stderr {
3127 stderr_buf.push_str(&chunk);
3128 } else {
3129 stdout_buf.push_str(&chunk);
3130 }
3131 }
3132 None => return BashLoopOutcome::StreamClosed,
3133 }
3134 }
3135 () = tokio::time::sleep_until(deadline) => {
3136 kill_process_tree(child).await;
3137 return BashLoopOutcome::TimedOut;
3138 }
3139 () = async {
3140 match cancel_token {
3141 Some(t) => t.cancelled().await,
3142 None => std::future::pending().await,
3143 }
3144 } => {
3145 kill_process_tree(child).await;
3146 return BashLoopOutcome::Cancelled;
3147 }
3148 }
3149 }
3150}
3151
3152async fn finalize_envelope(
3153 child: &mut tokio::process::Child,
3154 combined: String,
3155 stdout_buf: String,
3156 stderr_buf: String,
3157) -> (ShellOutputEnvelope, String) {
3158 let status = child.wait().await;
3159 let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(1);
3160
3161 if combined.is_empty() {
3162 (
3163 ShellOutputEnvelope {
3164 stdout: String::new(),
3165 stderr: String::new(),
3166 exit_code,
3167 truncated: false,
3168 },
3169 "(no output)".to_string(),
3170 )
3171 } else {
3172 (
3173 ShellOutputEnvelope {
3174 stdout: stdout_buf.trim_end().to_owned(),
3175 stderr: stderr_buf.trim_end().to_owned(),
3176 exit_code,
3177 truncated: false,
3178 },
3179 combined,
3180 )
3181 }
3182}
3183
3184#[cfg(test)]
3185mod tests;