Skip to main content

zeph_tools/shell/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Shell executor that parses and runs bash blocks from LLM responses.
5//!
6//! [`ShellExecutor`] is the primary tool backend for Zeph. It handles both legacy
7//! fenced bash blocks and structured `bash` tool calls. Security controls enforced
8//! before every command:
9//!
10//! - **Blocklist** — commands matching any entry in `blocked_commands` (or the built-in
11//!   [`DEFAULT_BLOCKED_COMMANDS`]) are rejected with [`ToolError::Blocked`].
12//! - **Subshell metacharacters** — `$(`, `` ` ``, `<(`, and `>(` are always blocked
13//!   because nested evaluation cannot be safely analysed statically.
14//! - **Path sandbox** — the working directory and any file arguments must reside under
15//!   the configured `allowed_paths`.
16//! - **Confirmation gate** — commands matching `confirm_patterns` are held for user
17//!   approval before execution (bypassed by `execute_confirmed`).
18//! - **Environment blocklist** — variables in `env_blocklist` are stripped from the
19//!   subprocess environment before launch.
20//! - **Transactional rollback** — when enabled, file snapshots are taken before execution
21//!   and restored on failure or on non-zero exit codes in `auto_rollback_exit_codes`.
22
23use std::collections::HashMap;
24use std::path::PathBuf;
25use std::sync::Arc;
26use std::sync::atomic::AtomicBool;
27use std::time::{Duration, Instant};
28
29use tokio::process::Command;
30use tokio_util::sync::CancellationToken;
31
32use schemars::JsonSchema;
33use serde::Deserialize;
34
35use arc_swap::ArcSwap;
36use parking_lot::{Mutex, RwLock};
37
38use zeph_common::security::is_path_within;
39use zeph_common::{TaskSupervisor, ToolName};
40
41use crate::audit::{AuditEntry, AuditLogger, AuditResult, chrono_now};
42use crate::config::ShellConfig;
43use crate::execution_context::ExecutionContext;
44use crate::executor::{
45    ClaimSource, FilterStats, ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput,
46};
47use crate::filter::{OutputFilterRegistry, sanitize_output};
48use crate::permissions::{PermissionAction, PermissionPolicy};
49use crate::sandbox::{Sandbox, SandboxPolicy};
50
51pub mod background;
52pub use background::BackgroundRunSnapshot;
53use background::{BackgroundCompletion, BackgroundHandle, RunId};
54
55pub mod deobfuscate;
56pub use deobfuscate::deobfuscate as deobfuscate_command;
57
58pub mod safe_fix;
59pub use safe_fix::SafeFixSuggestion;
60
61mod checkpoint;
62use checkpoint::{Checkpoint, CheckpointStack};
63
64mod transaction;
65use transaction::{TransactionSnapshot, affected_paths, build_scope_matchers, is_write_command};
66
67use crate::risk_chain::RiskChainAccumulator;
68
69const DEFAULT_BLOCKED: &[&str] = &[
70    "rm -rf /", "sudo", "mkfs", "dd if=", "curl", "wget", "nc ", "ncat", "netcat", "shutdown",
71    "reboot", "halt",
72];
73
74/// Returns `true` if `cmd` is an `rm` invocation with both recursive and force flags
75/// that targets `.git/worktrees`, regardless of flag ordering or bundling style.
76///
77/// Blocks variants like `-rf`, `-fr`, `-rfd`, `-rfv`, `--recursive --force`, etc.
78/// A plain `rm -r .git/worktrees` (no force) is intentionally allowed.
79///
80/// # Examples
81///
82/// ```
83/// use zeph_tools::shell::is_blocked_rm_worktrees;
84/// assert!(is_blocked_rm_worktrees("rm -rf .git/worktrees"));
85/// assert!(is_blocked_rm_worktrees("rm -fr .git/worktrees"));
86/// assert!(is_blocked_rm_worktrees("rm -rfd .git/worktrees"));
87/// assert!(is_blocked_rm_worktrees("rm --recursive --force .git/worktrees"));
88/// assert!(!is_blocked_rm_worktrees("rm -r .git/worktrees")); // no force
89/// assert!(!is_blocked_rm_worktrees("rm -rf /tmp/other")); // no worktrees path
90/// ```
91#[must_use]
92pub fn is_blocked_rm_worktrees(cmd: &str) -> bool {
93    let lower = cmd.to_lowercase();
94    let tokens: Vec<&str> = lower.split_whitespace().collect();
95
96    // First token must be `rm` (or path-qualified, e.g. `/usr/bin/rm`).
97    let Some(first) = tokens.first() else {
98        return false;
99    };
100    if first.rsplit('/').next().unwrap_or(first) != "rm" {
101        return false;
102    }
103
104    if !lower.contains(".git/worktrees") {
105        return false;
106    }
107
108    let mut has_recursive = false;
109    let mut has_force = false;
110
111    for token in &tokens[1..] {
112        if *token == "--recursive" {
113            has_recursive = true;
114        } else if *token == "--force" {
115            has_force = true;
116        } else if let Some(flags) = token.strip_prefix('-').filter(|f| !f.starts_with('-')) {
117            // Short flag bundle like `-rfd` or `-fr`.
118            if flags.contains('r') || flags.contains('R') {
119                has_recursive = true;
120            }
121            if flags.contains('f') {
122                has_force = true;
123            }
124        }
125    }
126
127    has_recursive && has_force
128}
129
130/// Graceful period between SIGTERM and SIGKILL during process escalation.
131#[cfg(unix)]
132const GRACEFUL_TERM_MS: Duration = Duration::from_millis(250);
133
134/// The default list of blocked command patterns used by [`ShellExecutor`].
135///
136/// Includes highly destructive commands (`rm -rf /`, `mkfs`, `dd if=`), privilege
137/// escalation (`sudo`), and network egress tools (`curl`, `wget`, `nc`, `netcat`).
138/// Network commands can be re-enabled via [`ShellConfig::allow_network`].
139///
140/// `rm` commands targeting `.git/worktrees` with recursive+force flags are blocked
141/// semantically via [`is_blocked_rm_worktrees`] regardless of flag ordering or bundling,
142/// so they do not appear as literal entries in this list.
143///
144/// Exposed so other executors (e.g. `AcpShellExecutor`) can reuse the same
145/// blocklist without duplicating it.
146pub const DEFAULT_BLOCKED_COMMANDS: &[&str] = DEFAULT_BLOCKED;
147
148/// Shell interpreters that may execute arbitrary code via `-c` or positional args.
149///
150/// When [`check_blocklist`] receives a command whose binary matches one of these
151/// names, the `-c <script>` argument is extracted and checked against the blocklist
152/// instead of the binary name.
153pub const SHELL_INTERPRETERS: &[&str] =
154    &["bash", "sh", "zsh", "fish", "dash", "ksh", "csh", "tcsh"];
155
156/// Subshell metacharacters that could embed a blocked command inside a benign wrapper.
157/// Commands containing these sequences are rejected outright because safe static
158/// analysis of nested shell evaluation is not feasible.
159const SUBSHELL_METACHARS: &[&str] = &["$(", "`", "<(", ">("];
160
161/// Check if `command` matches any pattern in `blocklist`.
162///
163/// Returns the matched pattern string if the command is blocked, `None` otherwise.
164/// The check is case-insensitive and handles common shell escape sequences.
165///
166/// Commands containing subshell metacharacters (`$(` or `` ` ``) are always
167/// blocked because nested evaluation cannot be safely analysed statically.
168#[must_use]
169pub fn check_blocklist(command: &str, blocklist: &[String]) -> Option<String> {
170    let lower = command.to_lowercase();
171    // Reject commands that embed subshell constructs to prevent blocklist bypass.
172    for meta in SUBSHELL_METACHARS {
173        if lower.contains(meta) {
174            return Some((*meta).to_owned());
175        }
176    }
177    let cleaned = strip_shell_escapes(&lower);
178    let commands = tokenize_commands(&cleaned);
179    for cmd_tokens in &commands {
180        let joined = cmd_tokens.join(" ");
181        if is_blocked_rm_worktrees(&joined) {
182            return Some("rm --recursive --force .git/worktrees".to_owned());
183        }
184    }
185    for blocked in blocklist {
186        for cmd_tokens in &commands {
187            if tokens_match_pattern(cmd_tokens, blocked) {
188                return Some(blocked.clone());
189            }
190        }
191    }
192    None
193}
194
195/// Build the effective command string for blocklist evaluation when the binary is a
196/// shell interpreter (bash, sh, zsh, etc.) and args contains a `-c` script.
197///
198/// Returns `None` if the args do not follow the `-c <script>` pattern.
199#[must_use]
200pub fn effective_shell_command<'a>(binary: &str, args: &'a [String]) -> Option<&'a str> {
201    let base = binary.rsplit('/').next().unwrap_or(binary);
202    if !SHELL_INTERPRETERS.contains(&base) {
203        return None;
204    }
205    // Find "-c" and return the next element as the script to check.
206    let pos = args.iter().position(|a| a == "-c")?;
207    args.get(pos + 1).map(String::as_str)
208}
209
210/// Shell commands that perform outbound network egress.
211///
212/// Used both to derive [`ShellConfig::allow_network`]'s effective blocklist entries here,
213/// and by [`check_blocklist`] callers outside this module (e.g. `zeph-subagent`'s
214/// `NetworkDenyToolExecutor`) that need to block network egress for a single sub-agent
215/// spawn without mutating the shared executor's global policy.
216pub const NETWORK_COMMANDS: &[&str] = &["curl", "wget", "nc ", "ncat", "netcat"];
217
218/// Effective command-restriction policy held inside a `ShellExecutor`.
219///
220/// Swapped atomically on hot-reload via [`ShellPolicyHandle`].
221#[derive(Debug)]
222pub(crate) struct ShellPolicy {
223    pub(crate) blocked_commands: Vec<String>,
224}
225
226/// Clonable handle for live policy rebuilds on hot-reload.
227///
228/// Obtained from [`ShellExecutor::policy_handle`] at construction time and stored
229/// on the agent. Call [`ShellPolicyHandle::rebuild`] to atomically replace the
230/// effective `blocked_commands` list without recreating the executor. Reads on
231/// the dispatch path are lock-free via `ArcSwap::load_full`.
232#[derive(Clone, Debug)]
233pub struct ShellPolicyHandle {
234    inner: Arc<ArcSwap<ShellPolicy>>,
235}
236
237impl ShellPolicyHandle {
238    /// Atomically install a new effective blocklist derived from `config`.
239    ///
240    /// # Rebuild contract
241    ///
242    /// `config` must be the **already-overlay-merged** `ShellConfig` (i.e. the
243    /// value produced by `load_config_with_overlay`). Plugin contributions are
244    /// already present in `config.blocked_commands` at this point; this method
245    /// does NOT re-apply overlays.
246    pub fn rebuild(&self, config: &crate::config::ShellConfig) {
247        let policy = Arc::new(ShellPolicy {
248            blocked_commands: compute_blocked_commands(config),
249        });
250        self.inner.store(policy);
251    }
252
253    /// Snapshot of the current effective blocklist.
254    #[must_use]
255    pub fn snapshot_blocked(&self) -> Vec<String> {
256        self.inner.load().blocked_commands.clone()
257    }
258}
259
260/// Compute the effective blocklist from an already-overlay-merged `ShellConfig`.
261///
262/// Invariant: identical to the logic in `ShellExecutor::new`.
263pub(crate) fn compute_blocked_commands(config: &crate::config::ShellConfig) -> Vec<String> {
264    let allowed: Vec<String> = config
265        .allowed_commands
266        .iter()
267        .map(|s| s.to_lowercase())
268        .collect();
269    let mut blocked: Vec<String> = DEFAULT_BLOCKED
270        .iter()
271        .filter(|s| !allowed.contains(&s.to_lowercase()))
272        .map(|s| (*s).to_owned())
273        .collect();
274    blocked.extend(config.blocked_commands.iter().map(|s| s.to_lowercase()));
275    if !config.allow_network {
276        for cmd in NETWORK_COMMANDS {
277            let lower = cmd.to_lowercase();
278            if !blocked.contains(&lower) {
279                blocked.push(lower);
280            }
281        }
282    }
283    blocked.sort();
284    blocked.dedup();
285    blocked
286}
287
288#[derive(Deserialize, JsonSchema)]
289pub(crate) struct BashParams {
290    /// The bash command to execute.
291    command: String,
292    /// When `true`, spawn the command in the background and return immediately.
293    ///
294    /// The agent receives a `run_id` in the synchronous tool result. When the
295    /// command finishes, a synthetic user-role message is injected at the start
296    /// of the next turn carrying the exit code and output.
297    #[serde(default)]
298    background: bool,
299}
300
301/// Bash block extraction and execution via `tokio::process::Command`.
302///
303/// Parses ` ```bash ` fenced blocks from LLM responses (legacy path) and handles
304/// structured `bash` tool calls (modern path). Use [`ShellExecutor::new`] with a
305/// [`ShellConfig`] and chain optional builder methods to attach audit logging,
306/// event streaming, permission policies, and cancellation.
307///
308/// # Example
309///
310/// ```rust,no_run
311/// use zeph_tools::{ShellExecutor, ToolExecutor, ShellConfig};
312///
313/// # async fn example() {
314/// let executor = ShellExecutor::new(&ShellConfig::default());
315///
316/// // Execute a fenced bash block.
317/// let response = "```bash\npwd\n```";
318/// if let Ok(Some(output)) = executor.execute(response).await {
319///     println!("{}", output.summary);
320/// }
321/// # }
322/// ```
323#[derive(Debug)]
324#[allow(clippy::struct_excessive_bools)]
325pub struct ShellExecutor {
326    timeout: Duration,
327    policy: Arc<ArcSwap<ShellPolicy>>,
328    confirm_patterns: Vec<String>,
329    env_blocklist: Vec<String>,
330    audit_logger: Option<Arc<AuditLogger>>,
331    tool_event_tx: Option<ToolEventTx>,
332    permission_policy: Option<PermissionPolicy>,
333    output_filter_registry: Option<OutputFilterRegistry>,
334    cancel_token: Option<CancellationToken>,
335    skill_env: RwLock<Option<std::collections::HashMap<String, String>>>,
336    transactional: bool,
337    auto_rollback: bool,
338    auto_rollback_exit_codes: Vec<i32>,
339    snapshot_required: bool,
340    max_snapshot_bytes: u64,
341    transaction_scope_matchers: Vec<globset::GlobMatcher>,
342    /// Session-scoped undo/redo checkpoint stack.
343    checkpoint_stack: Arc<Mutex<CheckpointStack>>,
344    /// Whether checkpoint capture is enabled (from config).
345    checkpoints_enabled: bool,
346    sandbox: Option<Arc<dyn Sandbox>>,
347    sandbox_policy: Option<SandboxPolicy>,
348    /// Registry of in-flight background runs. Bounded by `max_background_runs`.
349    background_runs: Arc<Mutex<HashMap<RunId, BackgroundHandle>>>,
350    /// Maximum number of concurrent background runs.
351    max_background_runs: usize,
352    /// Timeout applied to each background run.
353    background_timeout: Duration,
354    /// Set to `true` during shutdown to prevent new background spawns.
355    shutting_down: Arc<AtomicBool>,
356    /// Dedicated sender used to forward [`BackgroundCompletion`]s to the agent
357    /// (bypasses the UI-facing [`ToolEventTx`] channel). `None` when the agent
358    /// has not wired a background completion receiver.
359    background_completion_tx: Option<tokio::sync::mpsc::Sender<BackgroundCompletion>>,
360    /// Named execution environment registry built from `[execution]` config.
361    /// Keys are case-sensitive environment names; values are trusted `ExecutionContext`s.
362    environments: Arc<HashMap<String, ExecutionContext>>,
363    /// Pre-canonicalized `allowed_paths`. Built once at construction to avoid TOCTOU
364    /// between the canonicalize call and the prefix check at `resolve_context` time.
365    allowed_paths_canonical: Vec<PathBuf>,
366    /// Optional default environment name (from `[execution] default_env`).
367    default_env: Option<String>,
368    /// Optional per-turn risk chain accumulator for multi-step attack detection.
369    risk_chain: Option<Arc<RiskChainAccumulator>>,
370    /// Cumulative score threshold above which the risk chain blocks execution.
371    risk_chain_threshold: f32,
372    /// Optional supervisor for background shell run tasks.
373    ///
374    /// When set, each background run task is registered under its `RunId` so it is
375    /// visible in TUI status panels and aborted on supervisor shutdown.
376    task_supervisor: Option<DebugIgnored<TaskSupervisor>>,
377}
378
379/// Wrapper that implements `Debug` by omitting the inner value.
380///
381/// Used for fields whose types do not implement `Debug` but are held on structs that
382/// derive it. The wrapper is transparent for all other trait implementations.
383struct DebugIgnored<T>(T);
384
385impl<T> std::fmt::Debug for DebugIgnored<T> {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        f.write_str("<...>")
388    }
389}
390
391impl<T> std::ops::Deref for DebugIgnored<T> {
392    type Target = T;
393    fn deref(&self) -> &T {
394        &self.0
395    }
396}
397
398/// Fully resolved execution context for a single shell invocation.
399///
400/// Produced by [`ShellExecutor::resolve_context`] and passed to the inner execute
401/// functions. The canonical `cwd` is what `cmd.current_dir` receives — identical to
402/// the path that was validated against `allowed_paths`.
403#[derive(Debug)]
404pub(crate) struct ResolvedContext {
405    /// Canonical absolute working directory (follows all symlinks).
406    pub(crate) cwd: PathBuf,
407    /// Final merged environment (post-blocklist filter).
408    pub(crate) env: HashMap<String, String>,
409    /// Resolved environment name, for logs and audit entries.
410    pub(crate) name: Option<String>,
411    /// Whether the context originated from a trusted source (operator TOML).
412    /// Reserved for future audit log enrichment.
413    #[allow(dead_code)]
414    pub(crate) trusted: bool,
415}
416
417impl ShellExecutor {
418    /// Create a new `ShellExecutor` from configuration.
419    ///
420    /// Merges the built-in [`DEFAULT_BLOCKED_COMMANDS`] with any additional blocked
421    /// commands from `config`, then subtracts any explicitly allowed commands.
422    /// No subprocess is spawned at construction time.
423    #[must_use]
424    pub fn new(config: &ShellConfig) -> Self {
425        let policy = Arc::new(ArcSwap::from_pointee(ShellPolicy {
426            blocked_commands: compute_blocked_commands(config),
427        }));
428
429        let allowed_paths: Vec<PathBuf> = if config.allowed_paths.is_empty() {
430            vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
431        } else {
432            config.allowed_paths.iter().map(PathBuf::from).collect()
433        };
434        let allowed_paths_canonical: Vec<PathBuf> = allowed_paths
435            .iter()
436            .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
437            .collect();
438
439        Self {
440            timeout: Duration::from_secs(config.timeout),
441            policy,
442            confirm_patterns: config.confirm_patterns.clone(),
443            env_blocklist: config.env_blocklist.clone(),
444            audit_logger: None,
445            tool_event_tx: None,
446            permission_policy: None,
447            output_filter_registry: None,
448            cancel_token: None,
449            skill_env: RwLock::new(None),
450            transactional: config.transactional,
451            auto_rollback: config.auto_rollback,
452            auto_rollback_exit_codes: config.auto_rollback_exit_codes.clone(),
453            snapshot_required: config.snapshot_required,
454            max_snapshot_bytes: config.max_snapshot_bytes,
455            transaction_scope_matchers: build_scope_matchers(&config.transaction_scope),
456            checkpoint_stack: Arc::new(Mutex::new(CheckpointStack::new(config.max_checkpoints))),
457            checkpoints_enabled: config.checkpoints_enabled,
458            sandbox: None,
459            sandbox_policy: None,
460            background_runs: Arc::new(Mutex::new(HashMap::new())),
461            max_background_runs: config.max_background_runs,
462            background_timeout: Duration::from_secs(config.background_timeout_secs),
463            shutting_down: Arc::new(AtomicBool::new(false)),
464            background_completion_tx: None,
465            environments: Arc::new(HashMap::new()),
466            allowed_paths_canonical,
467            default_env: None,
468            risk_chain: None,
469            risk_chain_threshold: config.risk_chain_threshold.unwrap_or(0.7),
470            task_supervisor: None::<DebugIgnored<TaskSupervisor>>,
471        }
472    }
473
474    /// Attach an OS-level sandbox backend and a pre-snapshotted policy.
475    ///
476    /// The policy is snapshotted at construction and never re-resolved per call (no TOCTOU).
477    /// If a different policy is needed, create a new `ShellExecutor` via the builder chain.
478    #[must_use]
479    pub fn with_sandbox(mut self, sandbox: Arc<dyn Sandbox>, policy: SandboxPolicy) -> Self {
480        self.sandbox = Some(sandbox);
481        self.sandbox_policy = Some(policy);
482        self
483    }
484
485    /// Attach a per-turn risk chain accumulator for multi-step attack detection.
486    ///
487    /// When set, each command is recorded into the accumulator. If the cumulative
488    /// risk score exceeds `threshold`, the command is blocked before execution.
489    #[must_use]
490    pub fn with_risk_chain(mut self, accumulator: Arc<RiskChainAccumulator>) -> Self {
491        self.risk_chain = Some(accumulator);
492        self
493    }
494
495    /// Build the environment registry from `[execution]` config and wire it in one step.
496    ///
497    /// Convenience wrapper for agent startup. Converts [`zeph_config::ExecutionConfig`]
498    /// entries into trusted [`ExecutionContext`] instances and passes them to
499    /// [`Self::with_environments`].
500    ///
501    /// # Errors
502    ///
503    /// Returns an error string when any registry entry's `cwd` cannot be canonicalized
504    /// or escapes `allowed_paths`.
505    pub fn with_execution_config(
506        self,
507        config: &zeph_config::ExecutionConfig,
508    ) -> Result<Self, String> {
509        let registry: HashMap<String, ExecutionContext> = config
510            .environments
511            .iter()
512            .map(|e| {
513                let ctx = ExecutionContext::trusted_from_parts(
514                    Some(e.name.clone()),
515                    Some(std::path::PathBuf::from(&e.cwd)),
516                    e.env.clone(),
517                );
518                (e.name.clone(), ctx)
519            })
520            .collect();
521        self.with_environments(registry, config.default_env.clone())
522    }
523
524    /// Wire the named execution environment registry from `[execution]` config.
525    ///
526    /// Builds trusted [`ExecutionContext`] instances from the operator-authored TOML
527    /// entries and canonicalizes their `cwd` paths at construction time.
528    ///
529    /// # Errors
530    ///
531    /// Returns an error string (surfaced at agent startup) when a registry entry's
532    /// `cwd` path does not exist, cannot be canonicalized, or escapes `allowed_paths`.
533    pub fn with_environments(
534        mut self,
535        environments: HashMap<String, ExecutionContext>,
536        default_env: Option<String>,
537    ) -> Result<Self, String> {
538        // Validate that all registered cwds exist and are under allowed_paths.
539        for (name, ctx) in &environments {
540            if let Some(cwd) = ctx.cwd() {
541                let canonical = cwd.canonicalize().map_err(|e| {
542                    format!(
543                        "execution environment '{name}': cwd '{}' cannot be canonicalized: {e}",
544                        cwd.display()
545                    )
546                })?;
547                if !self
548                    .allowed_paths_canonical
549                    .iter()
550                    .any(|p| canonical.starts_with(p))
551                {
552                    return Err(format!(
553                        "execution environment '{name}': cwd '{}' is outside allowed_paths",
554                        cwd.display()
555                    ));
556                }
557            }
558        }
559        self.environments = Arc::new(environments);
560        self.default_env = default_env;
561        Ok(self)
562    }
563
564    /// Set environment variables to inject when executing the active skill's bash blocks.
565    pub fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
566        *self.skill_env.write() = env;
567    }
568
569    /// Attach an audit logger. Each shell invocation will emit an [`AuditEntry`].
570    #[must_use]
571    pub fn with_audit(mut self, logger: Arc<AuditLogger>) -> Self {
572        self.audit_logger = Some(logger);
573        self
574    }
575
576    /// Attach a tool-event sender for streaming output to the TUI or channel adapter.
577    ///
578    /// When set, [`ToolEvent::Started`], [`ToolEvent::OutputChunk`], and
579    /// [`ToolEvent::Completed`] events are sent on `tx` during execution.
580    #[must_use]
581    pub fn with_tool_event_tx(mut self, tx: ToolEventTx) -> Self {
582        self.tool_event_tx = Some(tx);
583        self
584    }
585
586    /// Attach a dedicated sender for routing [`BackgroundCompletion`] payloads to the agent.
587    ///
588    /// This channel is separate from [`ToolEventTx`] (which goes to the TUI). The agent holds
589    /// the receiver end and drains it at the start of each turn to inject deferred completions
590    /// into the message history as a single merged user-role block.
591    #[must_use]
592    pub fn with_background_completion_tx(
593        mut self,
594        tx: tokio::sync::mpsc::Sender<BackgroundCompletion>,
595    ) -> Self {
596        self.background_completion_tx = Some(tx);
597        self
598    }
599
600    /// Attach a [`TaskSupervisor`] so background shell run tasks are registered and observable.
601    ///
602    /// When set, each `spawn_background_with_context` call registers the run task under its
603    /// `RunId` in the supervisor, making it visible to TUI status panels and gracefully
604    /// aborted on supervisor shutdown.
605    #[must_use]
606    pub fn with_task_supervisor(mut self, supervisor: TaskSupervisor) -> Self {
607        self.task_supervisor = Some(DebugIgnored(supervisor));
608        self
609    }
610
611    /// Attach a permission policy for confirmation-gate enforcement.
612    ///
613    /// Commands matching the policy's rules may require user approval before
614    /// execution proceeds.
615    #[must_use]
616    pub fn with_permissions(mut self, policy: PermissionPolicy) -> Self {
617        self.permission_policy = Some(policy);
618        self
619    }
620
621    /// Attach a cancellation token. When the token is cancelled, the running subprocess
622    /// is killed and the executor returns [`ToolError::Cancelled`].
623    #[must_use]
624    pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
625        self.cancel_token = Some(token);
626        self
627    }
628
629    /// Attach an output filter registry. Filters are applied to stdout+stderr before
630    /// the summary is stored in [`ToolOutput`] and sent to the LLM.
631    #[must_use]
632    pub fn with_output_filters(mut self, registry: OutputFilterRegistry) -> Self {
633        self.output_filter_registry = Some(registry);
634        self
635    }
636
637    /// Snapshot all in-flight background runs.
638    ///
639    /// Acquires the lock once, maps each `BackgroundHandle` to a
640    /// [`BackgroundRunSnapshot`], then drops the guard before returning.
641    /// Safe to call from any thread.
642    #[must_use]
643    pub fn background_runs_snapshot(&self) -> Vec<background::BackgroundRunSnapshot> {
644        let runs = self.background_runs.lock();
645        runs.iter()
646            .map(|(id, h)| {
647                #[allow(clippy::cast_possible_truncation)]
648                let elapsed_ms = h.elapsed().as_millis() as u64;
649                background::BackgroundRunSnapshot {
650                    run_id: id.to_string(),
651                    command: h.command.clone(),
652                    elapsed_ms,
653                }
654            })
655            .collect()
656    }
657
658    /// Return a clonable handle for live policy rebuilds on hot-reload.
659    ///
660    /// Clone the handle out at construction time and store it on the agent.
661    /// Calling [`ShellPolicyHandle::rebuild`] atomically swaps the effective
662    /// `blocked_commands` without recreating the executor.
663    #[must_use]
664    pub fn policy_handle(&self) -> ShellPolicyHandle {
665        ShellPolicyHandle {
666            inner: Arc::clone(&self.policy),
667        }
668    }
669
670    /// Execute a bash block bypassing the confirmation check (called after user confirms).
671    ///
672    /// # Errors
673    ///
674    /// Returns `ToolError` on blocked commands, sandbox violations, or execution failures.
675    #[cfg_attr(
676        feature = "profiling",
677        tracing::instrument(name = "tools.shell.execute", skip_all, fields(exit_code = tracing::field::Empty, duration_ms = tracing::field::Empty))
678    )]
679    pub async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
680        self.execute_inner(response, true).await
681    }
682
683    async fn execute_inner(
684        &self,
685        response: &str,
686        skip_confirm: bool,
687    ) -> Result<Option<ToolOutput>, ToolError> {
688        let blocks = extract_bash_blocks(response);
689        if blocks.is_empty() {
690            return Ok(None);
691        }
692
693        // Resolve with no call-site context so legacy path gets the same CWD/env
694        // treatment as the structured-tool-call path (default_env, skill_env, blocklist).
695        let resolved = self.resolve_context(None)?;
696
697        let mut outputs = Vec::with_capacity(blocks.len());
698        let mut cumulative_filter_stats: Option<FilterStats> = None;
699        let mut last_envelope: Option<ShellOutputEnvelope> = None;
700        #[allow(clippy::cast_possible_truncation)]
701        let blocks_executed = blocks.len() as u32;
702
703        for block in &blocks {
704            let (output_line, per_block_stats, envelope) =
705                self.execute_block(block, skip_confirm, &resolved).await?;
706            if let Some(fs) = per_block_stats {
707                let stats = cumulative_filter_stats.get_or_insert_with(FilterStats::default);
708                stats.raw_chars += fs.raw_chars;
709                stats.filtered_chars += fs.filtered_chars;
710                stats.raw_lines += fs.raw_lines;
711                stats.filtered_lines += fs.filtered_lines;
712                stats.confidence = Some(match (stats.confidence, fs.confidence) {
713                    (Some(prev), Some(cur)) => crate::filter::worse_confidence(prev, cur),
714                    (Some(prev), None) => prev,
715                    (None, Some(cur)) => cur,
716                    (None, None) => unreachable!(),
717                });
718                if stats.command.is_none() {
719                    stats.command = fs.command;
720                }
721                if stats.kept_lines.is_empty() && !fs.kept_lines.is_empty() {
722                    stats.kept_lines = fs.kept_lines;
723                }
724            }
725            last_envelope = Some(envelope);
726            outputs.push(output_line);
727        }
728
729        let raw_response = last_envelope
730            .as_ref()
731            .and_then(|e| serde_json::to_value(e).ok());
732
733        Ok(Some(ToolOutput {
734            tool_name: ToolName::new("bash"),
735            summary: outputs.join("\n\n"),
736            blocks_executed,
737            filter_stats: cumulative_filter_stats,
738            diff: None,
739            streamed: self.tool_event_tx.is_some(),
740            terminal_id: None,
741            locations: None,
742            raw_response,
743            claim_source: Some(ClaimSource::Shell),
744            ..Default::default()
745        }))
746    }
747
748    async fn execute_block(
749        &self,
750        block: &str,
751        skip_confirm: bool,
752        resolved: &ResolvedContext,
753    ) -> Result<(String, Option<FilterStats>, ShellOutputEnvelope), ToolError> {
754        self.check_permissions(block, skip_confirm).await?;
755        self.validate_sandbox_with_cwd(block, &resolved.cwd)?;
756
757        let (snapshot, snapshot_warning, snap_paths) = self.capture_snapshot_for(block)?;
758
759        if let Some(ref tx) = self.tool_event_tx {
760            let sandbox_profile = self
761                .sandbox_policy
762                .as_ref()
763                .map(|p| format!("{:?}", p.profile));
764            // Non-terminal streaming event: use try_send (drop on full).
765            let _ = tx.try_send(ToolEvent::Started {
766                tool_name: ToolName::new("bash"),
767                command: block.to_owned(),
768                sandbox_profile,
769                resolved_cwd: Some(resolved.cwd.display().to_string()),
770                execution_env: resolved.name.clone(),
771            });
772        }
773
774        let start = Instant::now();
775        let sandbox_pair = self
776            .sandbox
777            .as_ref()
778            .zip(self.sandbox_policy.as_ref())
779            .map(|(sb, pol)| (sb.as_ref(), pol));
780        let (mut envelope, out) = execute_bash_with_context(
781            block,
782            self.timeout,
783            self.tool_event_tx.as_ref(),
784            "",
785            self.cancel_token.as_ref(),
786            resolved,
787            sandbox_pair,
788        )
789        .await;
790        let exit_code = envelope.exit_code;
791        if exit_code == 130
792            && self
793                .cancel_token
794                .as_ref()
795                .is_some_and(CancellationToken::is_cancelled)
796        {
797            return Err(ToolError::Cancelled);
798        }
799        #[allow(clippy::cast_possible_truncation)]
800        let duration_ms = start.elapsed().as_millis() as u64;
801
802        if let Some(snap) = snapshot
803            && let Some(surviving) = self
804                .maybe_rollback(snap, block, exit_code, duration_ms)
805                .await
806            && self.checkpoints_enabled
807        {
808            self.record_checkpoint(surviving, block, snap_paths);
809        }
810
811        if let Some(err) = self
812            .classify_and_audit(block, &out, exit_code, duration_ms)
813            .await
814        {
815            self.emit_completed(block, &out, false, None, None).await;
816            return Err(err);
817        }
818
819        let (filtered, per_block_stats) = self.apply_output_filter(block, &out, exit_code);
820
821        self.emit_completed(
822            block,
823            &out,
824            !out.contains("[error]"),
825            per_block_stats.clone(),
826            None,
827        )
828        .await;
829
830        // Mark truncated if output was shortened during filtering.
831        envelope.truncated = filtered.len() < out.len();
832
833        let audit_result = if out.contains("[error]") || out.contains("[stderr]") {
834            AuditResult::Error {
835                message: out.clone(),
836            }
837        } else {
838            AuditResult::Success
839        };
840        self.log_audit_with_context(
841            block,
842            audit_result,
843            duration_ms,
844            None,
845            Some(exit_code),
846            envelope.truncated,
847            resolved,
848        )
849        .await;
850
851        let output_line = match snapshot_warning {
852            Some(warn) => format!("{warn}\n$ {block}\n{filtered}"),
853            None => format!("$ {block}\n{filtered}"),
854        };
855        Ok((output_line, per_block_stats, envelope))
856    }
857
858    /// Execute `command` using a pre-resolved [`ResolvedContext`] (from `resolve_context`).
859    ///
860    /// This is the structured-tool-call path — it uses the resolved CWD and env directly
861    /// instead of re-reading process state on every call.
862    #[allow(clippy::too_many_lines)]
863    #[tracing::instrument(name = "tools.shell.execute_block", skip(self, resolved), level = "info",
864        fields(cwd = %resolved.cwd.display(), env_name = resolved.name.as_deref().unwrap_or("")))]
865    async fn execute_block_with_context(
866        &self,
867        command: &str,
868        skip_confirm: bool,
869        resolved: &ResolvedContext,
870        tool_call_id: &str,
871    ) -> Result<Option<ToolOutput>, ToolError> {
872        self.check_permissions(command, skip_confirm).await?;
873        self.validate_sandbox_with_cwd(command, &resolved.cwd)?;
874
875        let (snapshot, snapshot_warning, snap_paths) = self.capture_snapshot_for(command)?;
876
877        if let Some(ref tx) = self.tool_event_tx {
878            let sandbox_profile = self
879                .sandbox_policy
880                .as_ref()
881                .map(|p| format!("{:?}", p.profile));
882            let _ = tx.try_send(ToolEvent::Started {
883                tool_name: ToolName::new("bash"),
884                command: command.to_owned(),
885                sandbox_profile,
886                resolved_cwd: Some(resolved.cwd.display().to_string()),
887                execution_env: resolved.name.clone(),
888            });
889        }
890
891        let start = Instant::now();
892        let sandbox_pair = self
893            .sandbox
894            .as_ref()
895            .zip(self.sandbox_policy.as_ref())
896            .map(|(sb, pol)| (sb.as_ref(), pol));
897        let (mut envelope, out) = execute_bash_with_context(
898            command,
899            self.timeout,
900            self.tool_event_tx.as_ref(),
901            tool_call_id,
902            self.cancel_token.as_ref(),
903            resolved,
904            sandbox_pair,
905        )
906        .await;
907        let exit_code = envelope.exit_code;
908        if exit_code == 130
909            && self
910                .cancel_token
911                .as_ref()
912                .is_some_and(CancellationToken::is_cancelled)
913        {
914            return Err(ToolError::Cancelled);
915        }
916        #[allow(clippy::cast_possible_truncation)]
917        let duration_ms = start.elapsed().as_millis() as u64;
918
919        if let Some(snap) = snapshot
920            && let Some(surviving) = self
921                .maybe_rollback(snap, command, exit_code, duration_ms)
922                .await
923            && self.checkpoints_enabled
924        {
925            self.record_checkpoint(surviving, command, snap_paths);
926        }
927
928        if let Some(err) = self
929            .classify_and_audit(command, &out, exit_code, duration_ms)
930            .await
931        {
932            self.emit_completed(command, &out, false, None, None).await;
933            return Err(err);
934        }
935
936        let (filtered, per_block_stats) = self.apply_output_filter(command, &out, exit_code);
937
938        self.emit_completed(
939            command,
940            &out,
941            !out.contains("[error]"),
942            per_block_stats.clone(),
943            None,
944        )
945        .await;
946
947        envelope.truncated = filtered.len() < out.len();
948
949        let audit_result = if out.contains("[error]") || out.contains("[stderr]") {
950            AuditResult::Error {
951                message: out.clone(),
952            }
953        } else {
954            AuditResult::Success
955        };
956        self.log_audit_with_context(
957            command,
958            audit_result,
959            duration_ms,
960            None,
961            Some(exit_code),
962            envelope.truncated,
963            resolved,
964        )
965        .await;
966
967        let output_line = match snapshot_warning {
968            Some(warn) => format!("{warn}\n$ {command}\n{filtered}"),
969            None => format!("$ {command}\n{filtered}"),
970        };
971        Ok(Some(ToolOutput {
972            tool_name: ToolName::new("bash"),
973            summary: output_line,
974            blocks_executed: 1,
975            filter_stats: per_block_stats,
976            diff: None,
977            streamed: false,
978            terminal_id: None,
979            locations: None,
980            raw_response: None,
981            claim_source: Some(ClaimSource::Shell),
982            ..Default::default()
983        }))
984    }
985
986    #[allow(clippy::type_complexity)]
987    fn capture_snapshot_for(
988        &self,
989        block: &str,
990    ) -> Result<
991        (
992            Option<TransactionSnapshot>,
993            Option<String>,
994            Vec<std::path::PathBuf>,
995        ),
996        ToolError,
997    > {
998        if !(self.transactional || self.checkpoints_enabled) || !is_write_command(block) {
999            return Ok((None, None, Vec::new()));
1000        }
1001        let raw_paths = affected_paths(block, &self.transaction_scope_matchers);
1002        if raw_paths.is_empty() {
1003            return Ok((None, None, Vec::new()));
1004        }
1005        // Filter out paths that would escape the sandbox before capturing.
1006        // `affected_paths()` strips redirect operators (`>`, `>>`, `2>`) and yields bare path
1007        // strings; glued redirect tokens (e.g. `>../../etc/foo`) can produce out-of-sandbox
1008        // paths that `validate_sandbox_with_cwd` never saw.  Reject any path with traversal
1009        // sequences or that falls outside `allowed_paths_canonical`.
1010        let paths: Vec<std::path::PathBuf> = raw_paths
1011            .into_iter()
1012            .filter(|p| {
1013                let s = p.to_string_lossy();
1014                if has_traversal(&s) {
1015                    tracing::warn!(
1016                        path = %p.display(),
1017                        "checkpoint: skipping path with traversal sequence"
1018                    );
1019                    return false;
1020                }
1021                if !self.allowed_paths_canonical.is_empty() {
1022                    let canonical = canonicalize_or_nearest_ancestor(p);
1023                    if !self
1024                        .allowed_paths_canonical
1025                        .iter()
1026                        .any(|a| canonical.starts_with(a))
1027                    {
1028                        tracing::warn!(
1029                            path = %p.display(),
1030                            "checkpoint: skipping out-of-sandbox path"
1031                        );
1032                        return false;
1033                    }
1034                }
1035                true
1036            })
1037            .collect();
1038        if paths.is_empty() {
1039            return Ok((None, None, Vec::new()));
1040        }
1041        match TransactionSnapshot::capture(&paths, self.max_snapshot_bytes) {
1042            Ok(snap) => {
1043                tracing::debug!(
1044                    files = snap.file_count(),
1045                    bytes = snap.total_bytes(),
1046                    "transaction snapshot captured"
1047                );
1048                Ok((Some(snap), None, paths))
1049            }
1050            Err(e) if self.snapshot_required => Err(ToolError::SnapshotFailed {
1051                reason: e.to_string(),
1052            }),
1053            Err(e) => {
1054                tracing::warn!(err = %e, "transaction snapshot failed, proceeding without rollback");
1055                Ok((
1056                    None,
1057                    Some(format!("[warn] snapshot failed: {e}; rollback unavailable")),
1058                    Vec::new(),
1059                ))
1060            }
1061        }
1062    }
1063
1064    /// Perform auto-rollback if conditions are met, consuming the snapshot.
1065    ///
1066    /// Returns `Some(snap)` when the snapshot survived (no rollback fired) — the caller
1067    /// must then either record it as a checkpoint or drop it. Returns `None` when rollback
1068    /// consumed the snapshot. This design ensures exactly one consumer per snapshot (S1 fix).
1069    async fn maybe_rollback(
1070        &self,
1071        snap: TransactionSnapshot,
1072        block: &str,
1073        exit_code: i32,
1074        duration_ms: u64,
1075    ) -> Option<TransactionSnapshot> {
1076        let should_rollback = self.auto_rollback
1077            && if self.auto_rollback_exit_codes.is_empty() {
1078                exit_code >= 2
1079            } else {
1080                self.auto_rollback_exit_codes.contains(&exit_code)
1081            };
1082        if !should_rollback {
1083            // Snapshot survives — return to caller for optional checkpoint recording.
1084            return Some(snap);
1085        }
1086        match snap.rollback() {
1087            Ok(report) => {
1088                tracing::info!(
1089                    restored = report.restored_count,
1090                    deleted = report.deleted_count,
1091                    "transaction rollback completed"
1092                );
1093                self.log_audit(
1094                    block,
1095                    AuditResult::Rollback {
1096                        restored: report.restored_count,
1097                        deleted: report.deleted_count,
1098                    },
1099                    duration_ms,
1100                    None,
1101                    Some(exit_code),
1102                    false,
1103                )
1104                .await;
1105                if let Some(ref tx) = self.tool_event_tx {
1106                    // Terminal event: must deliver. Use send().await.
1107                    let _ = tx
1108                        .send(ToolEvent::Rollback {
1109                            tool_name: ToolName::new("bash"),
1110                            command: block.to_owned(),
1111                            restored_count: report.restored_count,
1112                            deleted_count: report.deleted_count,
1113                        })
1114                        .await;
1115                }
1116            }
1117            Err(e) => {
1118                tracing::error!(err = %e, "transaction rollback failed");
1119            }
1120        }
1121        None
1122    }
1123
1124    /// Record a checkpoint for the given command if checkpoints are enabled.
1125    ///
1126    /// Called after `maybe_rollback` returns `Some` (snapshot survived). The snapshot
1127    /// is consumed into the checkpoint stack; the redo stack is cleared per the standard
1128    /// undo/redo invariant.
1129    fn record_checkpoint(
1130        &self,
1131        snap: TransactionSnapshot,
1132        command: &str,
1133        paths: Vec<std::path::PathBuf>,
1134    ) {
1135        use std::time::{SystemTime, UNIX_EPOCH};
1136        let captured_at_secs = SystemTime::now()
1137            .duration_since(UNIX_EPOCH)
1138            .unwrap_or_default()
1139            .as_secs();
1140        let mut stack = self.checkpoint_stack.lock();
1141        stack.record(Checkpoint {
1142            before_snapshot: snap,
1143            command: command.to_owned(),
1144            paths,
1145            captured_at_secs,
1146        });
1147    }
1148
1149    async fn classify_and_audit(
1150        &self,
1151        block: &str,
1152        out: &str,
1153        exit_code: i32,
1154        duration_ms: u64,
1155    ) -> Option<ToolError> {
1156        if out.contains("[error] command timed out") {
1157            self.log_audit(
1158                block,
1159                AuditResult::Timeout,
1160                duration_ms,
1161                None,
1162                Some(exit_code),
1163                false,
1164            )
1165            .await;
1166            return Some(ToolError::Timeout {
1167                timeout_secs: self.timeout.as_secs(),
1168            });
1169        }
1170
1171        if let Some(category) = classify_shell_exit(exit_code, out) {
1172            return Some(ToolError::Shell {
1173                exit_code,
1174                category,
1175                message: out.lines().take(3).collect::<Vec<_>>().join("; "),
1176            });
1177        }
1178
1179        None
1180    }
1181
1182    fn apply_output_filter(
1183        &self,
1184        block: &str,
1185        out: &str,
1186        exit_code: i32,
1187    ) -> (String, Option<FilterStats>) {
1188        let sanitized = sanitize_output(out);
1189        if let Some(ref registry) = self.output_filter_registry {
1190            match registry.apply(block, &sanitized, exit_code) {
1191                Some(fr) => {
1192                    tracing::debug!(
1193                        command = block,
1194                        raw = fr.raw_chars,
1195                        filtered = fr.filtered_chars,
1196                        savings_pct = fr.savings_pct(),
1197                        "output filter applied"
1198                    );
1199                    let stats = FilterStats {
1200                        raw_chars: fr.raw_chars,
1201                        filtered_chars: fr.filtered_chars,
1202                        raw_lines: fr.raw_lines,
1203                        filtered_lines: fr.filtered_lines,
1204                        confidence: Some(fr.confidence),
1205                        command: Some(block.to_owned()),
1206                        kept_lines: fr.kept_lines.clone(),
1207                    };
1208                    (fr.output, Some(stats))
1209                }
1210                None => (sanitized, None),
1211            }
1212        } else {
1213            (sanitized, None)
1214        }
1215    }
1216
1217    async fn emit_completed(
1218        &self,
1219        command: &str,
1220        output: &str,
1221        success: bool,
1222        filter_stats: Option<FilterStats>,
1223        run_id: Option<RunId>,
1224    ) {
1225        if let Some(ref tx) = self.tool_event_tx {
1226            // Terminal event: must deliver. Use send().await (never dropped).
1227            let _ = tx
1228                .send(ToolEvent::Completed {
1229                    tool_name: ToolName::new("bash"),
1230                    command: command.to_owned(),
1231                    output: output.to_owned(),
1232                    success,
1233                    filter_stats,
1234                    diff: None,
1235                    run_id,
1236                })
1237                .await;
1238        }
1239    }
1240
1241    /// Check blocklist, permission policy, and confirmation requirements for `block`.
1242    #[allow(clippy::too_many_lines)]
1243    async fn check_permissions(&self, block: &str, skip_confirm: bool) -> Result<(), ToolError> {
1244        // Deobfuscate before any policy check to prevent bypass via encoding tricks.
1245        let normalized = deobfuscate::deobfuscate(block);
1246        let effective = normalized.as_str();
1247
1248        // Always check the blocklist first — it is a hard security boundary
1249        // that must not be bypassed by the PermissionPolicy layer.
1250        // Check both the original block (handles subshell metachar detection) and the
1251        // normalized form (handles hex/octal bypass). First match wins.
1252        let blocked_cmd = self
1253            .find_blocked_command(block)
1254            .or_else(|| self.find_blocked_command(effective));
1255        if let Some(blocked) = blocked_cmd {
1256            let fix = safe_fix::suggest_fix(effective);
1257            let err = if let Some(suggestion) = fix {
1258                let reason = format!("{blocked} — suggestion: {}", suggestion.alternative);
1259                self.log_audit(
1260                    block,
1261                    AuditResult::Blocked {
1262                        reason: format!("blocked command: {reason}"),
1263                    },
1264                    0,
1265                    None,
1266                    None,
1267                    false,
1268                )
1269                .await;
1270                ToolError::BlockedWithFix {
1271                    command: blocked,
1272                    suggestion: Some(suggestion),
1273                }
1274            } else {
1275                self.log_audit(
1276                    block,
1277                    AuditResult::Blocked {
1278                        reason: format!("blocked command: {blocked}"),
1279                    },
1280                    0,
1281                    None,
1282                    None,
1283                    false,
1284                )
1285                .await;
1286                ToolError::Blocked { command: blocked }
1287            };
1288            return Err(err);
1289        }
1290
1291        if let Some(ref policy) = self.permission_policy {
1292            match policy.check("bash", effective) {
1293                PermissionAction::Deny => {
1294                    let err = match safe_fix::suggest_fix(effective) {
1295                        Some(suggestion) => ToolError::BlockedWithFix {
1296                            command: effective.to_owned(),
1297                            suggestion: Some(suggestion),
1298                        },
1299                        None => ToolError::Blocked {
1300                            command: effective.to_owned(),
1301                        },
1302                    };
1303                    self.log_audit(
1304                        block,
1305                        AuditResult::Blocked {
1306                            reason: "denied by permission policy".to_owned(),
1307                        },
1308                        0,
1309                        None,
1310                        None,
1311                        false,
1312                    )
1313                    .await;
1314                    return Err(err);
1315                }
1316                PermissionAction::Ask if !skip_confirm => {
1317                    return Err(ToolError::ConfirmationRequired {
1318                        command: effective.to_owned(),
1319                    });
1320                }
1321                _ => {}
1322            }
1323        } else if !skip_confirm {
1324            // Check original block first (catches subshell metacharacters like `` ` ``),
1325            // then normalized form (catches obfuscated confirmation-required patterns).
1326            let confirm_pattern = self
1327                .find_confirm_command(block)
1328                .or_else(|| self.find_confirm_command(effective));
1329            if let Some(pattern) = confirm_pattern {
1330                return Err(ToolError::ConfirmationRequired {
1331                    command: pattern.to_owned(),
1332                });
1333            }
1334        }
1335
1336        // Risk chain check — record the call and block if threshold exceeded.
1337        if let Some(ref chain) = self.risk_chain {
1338            let verdict = chain.record("bash", effective, self.risk_chain_threshold);
1339            if verdict.should_block {
1340                let chain_name = verdict
1341                    .chain_pattern
1342                    .unwrap_or_else(|| "unknown".to_owned());
1343                tracing::warn!(
1344                    chain = chain_name,
1345                    score = verdict.cumulative_score,
1346                    "risk chain threshold exceeded"
1347                );
1348                return Err(ToolError::Blocked {
1349                    command: format!(
1350                        "risk chain blocked: {} (score {:.2})",
1351                        chain_name, verdict.cumulative_score
1352                    ),
1353                });
1354            }
1355        }
1356
1357        Ok(())
1358    }
1359
1360    /// Resolve the effective `(cwd, env, name, trusted)` for a single tool call.
1361    ///
1362    /// Implements the 6-step merge defined in the per-turn env spec:
1363    /// 1. Base = inherited process env.
1364    /// 2. Filter `env_blocklist`.
1365    /// 3. Apply `skill_env` overrides.
1366    /// 4. If `ctx` or `default_env` points to a named registry entry, apply its overrides.
1367    /// 5. Apply call-site `ctx.env_overrides`.
1368    /// 6. If context is untrusted, re-apply `env_blocklist` to strip any re-introduced keys.
1369    ///
1370    /// CWD precedence (highest wins): call-site `ctx.cwd` → named registry `cwd` → `default_env`
1371    /// registry `cwd` → `std::env::current_dir()`.
1372    #[tracing::instrument(name = "tools.shell.resolve_context", skip(self, ctx), level = "info")]
1373    pub(crate) fn resolve_context(
1374        &self,
1375        ctx: Option<&ExecutionContext>,
1376    ) -> Result<ResolvedContext, ToolError> {
1377        // Step 1: base env = process env.
1378        let mut env: HashMap<String, String> = std::env::vars().collect();
1379
1380        // Step 2: filter env_blocklist (prefix match, consistent with build_bash_command).
1381        env.retain(|k, _| {
1382            !self
1383                .env_blocklist
1384                .iter()
1385                .any(|prefix| k.starts_with(prefix.as_str()))
1386        });
1387
1388        // Step 3: apply skill_env.
1389        if let Some(skill) = self.skill_env.read().as_ref() {
1390            for (k, v) in skill {
1391                env.insert(k.clone(), v.clone());
1392            }
1393        }
1394
1395        // Determine the resolved name, cwd_override, and trusted flag.
1396        let mut resolved_name: Option<String> = None;
1397        let mut cwd_override: Option<PathBuf> = None;
1398        let mut trusted = false;
1399
1400        // Resolve via default_env registry entry (lowest priority named layer).
1401        if let Some(default_name) = &self.default_env
1402            && let Some(default_ctx) = self.environments.get(default_name.as_str())
1403        {
1404            resolved_name.get_or_insert_with(|| default_name.clone());
1405            if cwd_override.is_none() {
1406                cwd_override = default_ctx.cwd().map(ToOwned::to_owned);
1407            }
1408            trusted = default_ctx.is_trusted();
1409            for (k, v) in default_ctx.env_overrides() {
1410                env.insert(k.clone(), v.clone());
1411            }
1412        }
1413
1414        // Step 4: if call-site ctx names a registry entry, apply its overrides.
1415        if let Some(ctx) = ctx {
1416            if let Some(name) = ctx.name() {
1417                if let Some(reg_ctx) = self.environments.get(name) {
1418                    resolved_name = Some(name.to_owned());
1419                    if let Some(cwd) = reg_ctx.cwd() {
1420                        cwd_override = Some(cwd.to_owned());
1421                    }
1422                    trusted = reg_ctx.is_trusted();
1423                    for (k, v) in reg_ctx.env_overrides() {
1424                        env.insert(k.clone(), v.clone());
1425                    }
1426                } else {
1427                    return Err(ToolError::Execution(std::io::Error::other(format!(
1428                        "unknown execution environment '{name}'"
1429                    ))));
1430                }
1431            }
1432
1433            // Step 5: apply call-site cwd and env overrides (highest priority).
1434            if let Some(cwd) = ctx.cwd() {
1435                cwd_override = Some(cwd.to_owned());
1436            }
1437            if !ctx.is_trusted() {
1438                trusted = false;
1439            }
1440            for (k, v) in ctx.env_overrides() {
1441                env.insert(k.clone(), v.clone());
1442            }
1443        }
1444
1445        // Step 6: re-apply blocklist for untrusted contexts (prefix match).
1446        if !trusted {
1447            env.retain(|k, _| {
1448                !self
1449                    .env_blocklist
1450                    .iter()
1451                    .any(|prefix| k.starts_with(prefix.as_str()))
1452            });
1453        }
1454
1455        // Resolve final CWD: override (canonicalized) or process CWD.
1456        let cwd = if let Some(raw) = cwd_override {
1457            // Make relative paths absolute before canonicalize so they resolve
1458            // correctly regardless of the process working directory.
1459            let raw = if raw.is_absolute() {
1460                raw
1461            } else {
1462                std::env::current_dir()
1463                    .unwrap_or_else(|_| PathBuf::from("."))
1464                    .join(raw)
1465            };
1466            let canonical = raw
1467                .canonicalize()
1468                .map_err(|_| ToolError::SandboxViolation {
1469                    path: raw.display().to_string(),
1470                })?;
1471            // Validate against allowed_paths.
1472            if !self
1473                .allowed_paths_canonical
1474                .iter()
1475                .any(|p| canonical.starts_with(p))
1476            {
1477                return Err(ToolError::SandboxViolation {
1478                    path: canonical.display().to_string(),
1479                });
1480            }
1481            canonical
1482        } else {
1483            self.clamped_process_cwd()
1484        };
1485
1486        Ok(ResolvedContext {
1487            cwd,
1488            env,
1489            name: resolved_name,
1490            trusted,
1491        })
1492    }
1493
1494    /// Resolve the process cwd for the no-`cwd_override` fallback, clamped into the
1495    /// sandbox when it falls outside `allowed_paths` (#6208).
1496    ///
1497    /// Returns the raw process cwd unchanged when no sandbox is configured, or when
1498    /// the (canonicalized) process cwd already lies within `allowed_paths`. Otherwise
1499    /// confines the fallback to the first allowed root (preferring one that is
1500    /// actually a directory) so that path-token-free and bare-filename commands
1501    /// (`ls`, `pwd`, `cat foo`) cannot read/write outside the intended sandbox.
1502    fn clamped_process_cwd(&self) -> PathBuf {
1503        let process_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1504        if self.allowed_paths_canonical.is_empty() {
1505            return process_cwd;
1506        }
1507        let canon = canonicalize_or_nearest_ancestor(&process_cwd);
1508        if is_path_within(&canon, &self.allowed_paths_canonical) {
1509            return canon;
1510        }
1511        self.allowed_paths_canonical
1512            .iter()
1513            .find(|p| p.is_dir())
1514            .unwrap_or(&self.allowed_paths_canonical[0])
1515            .clone()
1516    }
1517
1518    fn validate_sandbox_with_cwd(
1519        &self,
1520        code: &str,
1521        cwd: &std::path::Path,
1522    ) -> Result<(), ToolError> {
1523        for token in extract_paths(code) {
1524            if has_traversal(&token) {
1525                return Err(ToolError::SandboxViolation { path: token });
1526            }
1527
1528            if self.allowed_paths_canonical.is_empty() {
1529                continue;
1530            }
1531
1532            let path = if token.starts_with('/') {
1533                PathBuf::from(&token)
1534            } else {
1535                cwd.join(&token)
1536            };
1537            // For existing paths, canonicalize to resolve symlinks before the prefix
1538            // check — `std::path::absolute` does NOT collapse `..` or follow symlinks.
1539            // For non-existent paths, canonicalize the nearest existing ancestor and
1540            // reattach the suffix: this rejects `allowed/../../etc/shadow` while
1541            // allowing references to not-yet-created files within allowed dirs.
1542            let canonical = canonicalize_or_nearest_ancestor(&path);
1543            if !self
1544                .allowed_paths_canonical
1545                .iter()
1546                .any(|allowed| canonical.starts_with(allowed))
1547            {
1548                return Err(ToolError::SandboxViolation {
1549                    path: canonical.display().to_string(),
1550                });
1551            }
1552        }
1553        Ok(())
1554    }
1555
1556    /// Test-only convenience wrapping [`Self::validate_sandbox_with_cwd`] with the raw
1557    /// process cwd. Production code must resolve cwd via `resolve_context` first.
1558    #[cfg(test)]
1559    fn validate_sandbox(&self, code: &str) -> Result<(), ToolError> {
1560        let cwd = std::env::current_dir().unwrap_or_default();
1561        self.validate_sandbox_with_cwd(code, &cwd)
1562    }
1563
1564    /// Scan `code` for commands that match the configured blocklist.
1565    ///
1566    /// The function normalizes input via [`strip_shell_escapes`] (decoding `$'\xNN'`,
1567    /// `$'\NNN'`, backslash escapes, and quote-splitting) and then splits on shell
1568    /// metacharacters (`||`, `&&`, `;`, `|`, `\n`) via [`tokenize_commands`].  Each
1569    /// resulting token sequence is tested against every entry in `blocked_commands`
1570    /// through [`tokens_match_pattern`], which handles transparent prefixes (`env`,
1571    /// `command`, `exec`, etc.), absolute paths, and dot-suffixed variants.
1572    ///
1573    /// # Known limitations
1574    ///
1575    /// The following constructs are **not** detected by this function:
1576    ///
1577    /// - **Here-strings** `<<<` with a shell interpreter: the outer command is the
1578    ///   shell (`bash`, `sh`), which is not blocked by default; the payload string is
1579    ///   opaque to this filter.
1580    ///   Example: `bash <<< 'sudo rm -rf /'` — inner payload is not parsed.
1581    ///
1582    /// - **`eval` and `bash -c` / `sh -c`**: the string argument is not parsed; any
1583    ///   blocked command embedded as a string argument passes through undetected.
1584    ///   Example: `eval 'sudo rm -rf /'`.
1585    ///
1586    /// - **Variable expansion**: `strip_shell_escapes` does not resolve variable
1587    ///   references, so `cmd=sudo; $cmd rm` bypasses the blocklist.
1588    ///
1589    /// `$(...)`, backtick, `<(...)`, and `>(...)` substitutions are detected by
1590    /// [`extract_subshell_contents`], which extracts the inner command string and
1591    /// checks it against the blocklist separately.  The default `confirm_patterns`
1592    /// in [`ShellConfig`] additionally include `"$("`, `` "`" ``, `"<("`, `">("`,
1593    /// `"<<<"`, and `"eval "`, so those constructs also trigger a confirmation
1594    /// request via [`find_confirm_command`] before execution.
1595    ///
1596    /// For high-security deployments, complement this filter with OS-level sandboxing
1597    /// (Linux namespaces, seccomp, or similar) to enforce hard execution boundaries.
1598    /// Scan `code` for commands that match the configured blocklist.
1599    ///
1600    /// Returns an owned `String` because the backing `Vec<String>` lives inside an
1601    /// `ArcSwap` that may be replaced between calls — borrowing from the snapshot
1602    /// guard would be unsound after the guard drops.
1603    fn find_blocked_command(&self, code: &str) -> Option<String> {
1604        let snapshot = self.policy.load_full();
1605        let cleaned = strip_shell_escapes(&code.to_lowercase());
1606        let commands = tokenize_commands(&cleaned);
1607        for cmd_tokens in &commands {
1608            let joined = cmd_tokens.join(" ");
1609            if is_blocked_rm_worktrees(&joined) {
1610                return Some("rm --recursive --force .git/worktrees".to_owned());
1611            }
1612        }
1613        for blocked in &snapshot.blocked_commands {
1614            for cmd_tokens in &commands {
1615                if tokens_match_pattern(cmd_tokens, blocked) {
1616                    return Some(blocked.clone());
1617                }
1618            }
1619        }
1620        // Also check commands embedded inside subshell constructs.
1621        for inner in extract_subshell_contents(&cleaned) {
1622            let inner_commands = tokenize_commands(&inner);
1623            for cmd_tokens in &inner_commands {
1624                let joined = cmd_tokens.join(" ");
1625                if is_blocked_rm_worktrees(&joined) {
1626                    return Some("rm --recursive --force .git/worktrees".to_owned());
1627                }
1628            }
1629            for blocked in &snapshot.blocked_commands {
1630                for cmd_tokens in &inner_commands {
1631                    if tokens_match_pattern(cmd_tokens, blocked) {
1632                        return Some(blocked.clone());
1633                    }
1634                }
1635            }
1636        }
1637        None
1638    }
1639
1640    fn find_confirm_command(&self, code: &str) -> Option<&str> {
1641        let normalized = code.to_lowercase();
1642        for pattern in &self.confirm_patterns {
1643            if normalized.contains(pattern.as_str()) {
1644                return Some(pattern.as_str());
1645            }
1646        }
1647        None
1648    }
1649
1650    fn build_audit_entry(
1651        command: &str,
1652        result: AuditResult,
1653        duration_ms: u64,
1654        error: Option<&ToolError>,
1655        exit_code: Option<i32>,
1656        truncated: bool,
1657        resolved: Option<&ResolvedContext>,
1658    ) -> AuditEntry {
1659        let (error_category, error_domain, error_phase) = error.map_or((None, None, None), |e| {
1660            let cat = e.category();
1661            (
1662                Some(cat.label().to_owned()),
1663                Some(cat.domain().label().to_owned()),
1664                Some(cat.phase().label().to_owned()),
1665            )
1666        });
1667        AuditEntry {
1668            timestamp: chrono_now(),
1669            tool: "shell".into(),
1670            command: command.into(),
1671            result,
1672            duration_ms,
1673            error_category,
1674            error_domain,
1675            error_phase,
1676            claim_source: Some(ClaimSource::Shell),
1677            mcp_server_id: None,
1678            injection_flagged: false,
1679            embedding_anomalous: false,
1680            cross_boundary_mcp_to_acp: false,
1681            adversarial_policy_decision: None,
1682            exit_code,
1683            truncated,
1684            caller_id: None,
1685            skill_name: None,
1686            policy_match: None,
1687            correlation_id: None,
1688            vigil_risk: None,
1689            execution_env: resolved.and_then(|r| r.name.clone()),
1690            resolved_cwd: resolved.map(|r| r.cwd.display().to_string()),
1691            scope_at_definition: None,
1692            scope_at_dispatch: None,
1693        }
1694    }
1695
1696    async fn log_audit(
1697        &self,
1698        command: &str,
1699        result: AuditResult,
1700        duration_ms: u64,
1701        error: Option<&ToolError>,
1702        exit_code: Option<i32>,
1703        truncated: bool,
1704    ) {
1705        if let Some(ref logger) = self.audit_logger {
1706            let entry = Self::build_audit_entry(
1707                command,
1708                result,
1709                duration_ms,
1710                error,
1711                exit_code,
1712                truncated,
1713                None,
1714            );
1715            logger.log(&entry).await;
1716        }
1717    }
1718
1719    #[allow(clippy::too_many_arguments)]
1720    async fn log_audit_with_context(
1721        &self,
1722        command: &str,
1723        result: AuditResult,
1724        duration_ms: u64,
1725        error: Option<&ToolError>,
1726        exit_code: Option<i32>,
1727        truncated: bool,
1728        resolved: &ResolvedContext,
1729    ) {
1730        if let Some(ref logger) = self.audit_logger {
1731            let entry = Self::build_audit_entry(
1732                command,
1733                result,
1734                duration_ms,
1735                error,
1736                exit_code,
1737                truncated,
1738                Some(resolved),
1739            );
1740            logger.log(&entry).await;
1741        }
1742    }
1743}
1744
1745impl ToolExecutor for ShellExecutor {
1746    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
1747        self.execute_inner(response, false).await
1748    }
1749
1750    // Overrides the trait default (`self.execute(response)`, i.e. no bypass) explicitly:
1751    // the inherent `execute_confirmed` above is a separate method, invisible to callers that
1752    // only hold a `&dyn ToolExecutor`/generic `T: ToolExecutor` handle (e.g. through
1753    // `DynExecutor`'s erasure path). Without this override, dynamic dispatch silently loses
1754    // the confirmation bypass — the exact regression #6012 was filed for.
1755    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
1756        self.execute_inner(response, true).await
1757    }
1758
1759    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1760        use crate::registry::{InvocationHint, ToolDef};
1761        vec![ToolDef {
1762            id: "bash".into(),
1763            description: "Execute a shell command and return stdout/stderr.\n\nParameters: command (string, required) - shell command to run\nReturns: stdout and stderr combined, prefixed with exit code\nErrors: Blocked if command matches security policy; Timeout after configured seconds; SandboxViolation if path outside allowed dirs\nExample: {\"command\": \"ls -la /tmp\"}".into(),
1764            schema: schemars::schema_for!(BashParams),
1765            invocation: InvocationHint::FencedBlock("bash"),
1766            output_schema: None,
1767            server_id: None,
1768        }]
1769    }
1770
1771    #[tracing::instrument(name = "tools.shell.execute_tool_call", skip(self, call), level = "info",
1772        fields(tool_id = %call.tool_id, env = call.context.as_ref().and_then(|c| c.name()).unwrap_or("")))]
1773    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
1774        if call.tool_id != "bash" {
1775            return Ok(None);
1776        }
1777        let params: BashParams = crate::executor::deserialize_params(&call.params)?;
1778        if params.command.is_empty() {
1779            return Ok(None);
1780        }
1781        let command = &params.command;
1782
1783        // Resolve per-turn execution context — done before the background branch so that
1784        // background tasks also receive the correct env and CWD (spec §6).
1785        let resolved = self.resolve_context(call.context.as_ref())?;
1786
1787        if params.background {
1788            let run_id = self
1789                .spawn_background_with_context(command, &resolved)
1790                .await?;
1791            let id_short = &run_id.to_string()[..8];
1792            return Ok(Some(ToolOutput {
1793                tool_name: ToolName::new("bash"),
1794                summary: format!(
1795                    "[background] started run_id={run_id} — command: {command}\n\
1796                     The command is running in the background. When it completes, \
1797                     results will appear at the start of the next turn (run_id_short={id_short})."
1798                ),
1799                blocks_executed: 1,
1800                filter_stats: None,
1801                diff: None,
1802                streamed: true,
1803                terminal_id: None,
1804                locations: None,
1805                raw_response: None,
1806                claim_source: Some(ClaimSource::Shell),
1807                ..Default::default()
1808            }));
1809        }
1810
1811        self.execute_block_with_context(command, false, &resolved, &call.tool_call_id)
1812            .await
1813    }
1814
1815    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
1816        ShellExecutor::set_skill_env(self, env);
1817    }
1818
1819    fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
1820        let result = self
1821            .checkpoint_stack
1822            .lock()
1823            .undo(n, self.max_snapshot_bytes);
1824        crate::executor::CheckpointActionResult {
1825            reverted_commands: result.reverted_commands,
1826            restored: result.restored,
1827            deleted: result.deleted,
1828            supported: true,
1829            message: result.message,
1830        }
1831    }
1832
1833    fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
1834        let result = self.checkpoint_stack.lock().redo(self.max_snapshot_bytes);
1835        crate::executor::CheckpointActionResult {
1836            reverted_commands: result.reverted_commands,
1837            restored: result.restored,
1838            deleted: result.deleted,
1839            supported: true,
1840            message: result.message,
1841        }
1842    }
1843
1844    fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
1845        let stack = self.checkpoint_stack.lock();
1846        let entries = stack
1847            .list_undo()
1848            .into_iter()
1849            .map(|e| crate::executor::CheckpointEntryView {
1850                index: e.index,
1851                command: e.command,
1852                captured_at_secs: e.captured_at_secs,
1853                file_count: e.file_count,
1854            })
1855            .collect();
1856        crate::executor::CheckpointListResult {
1857            entries,
1858            redo_depth: stack.redo_depth(),
1859            supported: true,
1860        }
1861    }
1862
1863    fn requires_confirmation(&self, _call: &ToolCall) -> bool {
1864        false
1865    }
1866
1867    async fn execute_tool_call_confirmed(
1868        &self,
1869        call: &ToolCall,
1870    ) -> Result<Option<ToolOutput>, ToolError> {
1871        self.execute_tool_call(call).await
1872    }
1873
1874    fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
1875        false
1876    }
1877}
1878
1879impl ShellExecutor {
1880    /// Test-only convenience. Production code MUST use the structured tool-call path
1881    /// (`execute_tool_call` -> `resolve_context` -> `spawn_background_with_context`) so the
1882    /// `allowed_paths` sandbox clamp in `resolve_context` always applies. Gated `#[cfg(test)]`
1883    /// so a future production caller fails to compile (regression guard for #6217/#6208).
1884    #[cfg(test)]
1885    async fn spawn_background(&self, command: &str) -> Result<RunId, ToolError> {
1886        let resolved = self.resolve_context(None)?;
1887        self.spawn_background_with_context(command, &resolved).await
1888    }
1889
1890    /// Spawn `command` as a background process using an already-resolved [`ResolvedContext`].
1891    ///
1892    /// The only production entry point for backgrounded shell commands — always call via
1893    /// `resolve_context` first so the `allowed_paths` sandbox clamp applies.
1894    ///
1895    /// All security checks (blocklist, sandbox, permissions) are performed synchronously
1896    /// before spawning. When the cap (`max_background_runs`) is already reached, this
1897    /// returns [`ToolError::Blocked`] immediately without spawning.
1898    ///
1899    /// On completion the spawned task emits a
1900    /// `ToolEvent::Completed { run_id: Some(..), .. }` via `tool_event_tx`.
1901    ///
1902    /// # Errors
1903    ///
1904    /// Returns [`ToolError::Blocked`] when the background run cap is reached or the command
1905    /// is blocked by policy. Returns other [`ToolError`] variants on sandbox/permission
1906    /// failures.
1907    async fn spawn_background_with_context(
1908        &self,
1909        command: &str,
1910        resolved: &ResolvedContext,
1911    ) -> Result<RunId, ToolError> {
1912        use std::sync::atomic::Ordering;
1913
1914        if self.shutting_down.load(Ordering::Acquire) {
1915            return Err(ToolError::Blocked {
1916                command: command.to_owned(),
1917            });
1918        }
1919
1920        self.check_permissions(command, false).await?;
1921        self.validate_sandbox_with_cwd(command, &resolved.cwd)?;
1922
1923        let run_id = RunId::new();
1924        let mut runs = self.background_runs.lock();
1925        if runs.len() >= self.max_background_runs {
1926            return Err(ToolError::Blocked {
1927                command: format!(
1928                    "background run cap reached (max_background_runs={})",
1929                    self.max_background_runs
1930                ),
1931            });
1932        }
1933        let abort = CancellationToken::new();
1934        runs.insert(
1935            run_id,
1936            BackgroundHandle {
1937                command: command.to_owned(),
1938                started_at: std::time::Instant::now(),
1939                abort: abort.clone(),
1940                child_pid: None,
1941            },
1942        );
1943        drop(runs);
1944
1945        let tool_event_tx = self.tool_event_tx.clone();
1946        let background_completion_tx = self.background_completion_tx.clone();
1947        let background_runs = Arc::clone(&self.background_runs);
1948        let timeout = self.background_timeout;
1949        let env = resolved.env.clone();
1950        let cwd = resolved.cwd.clone();
1951        let command_owned = command.to_owned();
1952
1953        if let Some(ref sup) = self.task_supervisor {
1954            let task_name: Arc<str> = Arc::from(format!("shell_bg_{run_id}").as_str());
1955            drop(sup.spawn_oneshot(task_name, move || {
1956                run_background_task_with_env(
1957                    run_id,
1958                    command_owned,
1959                    timeout,
1960                    abort,
1961                    background_runs,
1962                    tool_event_tx,
1963                    background_completion_tx,
1964                    env,
1965                    cwd,
1966                )
1967            }));
1968        } else {
1969            tokio::spawn(run_background_task_with_env(
1970                run_id,
1971                command_owned,
1972                timeout,
1973                abort,
1974                background_runs,
1975                tool_event_tx,
1976                background_completion_tx,
1977                env,
1978                cwd,
1979            ));
1980        }
1981
1982        Ok(run_id)
1983    }
1984
1985    /// Cancel all in-flight background runs.
1986    ///
1987    /// Called during agent shutdown. On Unix, issues SIGTERM/SIGKILL escalation
1988    /// against each captured process ID before cancelling the token. Each cancelled
1989    /// run emits a `ToolEvent::Completed { success: false }` event.
1990    pub async fn shutdown(&self) {
1991        use std::sync::atomic::Ordering;
1992
1993        self.shutting_down.store(true, Ordering::Release);
1994
1995        let handles: Vec<(RunId, String, CancellationToken, Option<u32>)> = {
1996            let runs = self.background_runs.lock();
1997            runs.iter()
1998                .map(|(id, h)| (*id, h.command.clone(), h.abort.clone(), h.child_pid))
1999                .collect()
2000        };
2001
2002        if handles.is_empty() {
2003            return;
2004        }
2005
2006        tracing::info!(
2007            count = handles.len(),
2008            "cancelling background shell runs for shutdown"
2009        );
2010
2011        for (run_id, command, abort, pid_opt) in &handles {
2012            abort.cancel();
2013
2014            #[cfg(unix)]
2015            if let Some(pid) = pid_opt {
2016                send_signal_with_escalation(*pid).await;
2017            }
2018            #[cfg(not(unix))]
2019            let _ = pid_opt;
2020
2021            if let Some(ref tx) = self.tool_event_tx {
2022                let _ = tx
2023                    .send(ToolEvent::Completed {
2024                        tool_name: ToolName::new("bash"),
2025                        command: command.clone(),
2026                        output: "[terminated by shutdown]".to_owned(),
2027                        success: false,
2028                        filter_stats: None,
2029                        diff: None,
2030                        run_id: Some(*run_id),
2031                    })
2032                    .await;
2033            }
2034        }
2035
2036        self.background_runs.lock().clear();
2037    }
2038}
2039
2040/// Drive a background shell run from spawn to completion, using a pre-resolved `env`
2041/// and `cwd` from `resolve_context` instead of reading `skill_env`/process-env at spawn time.
2042#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
2043async fn run_background_task_with_env(
2044    run_id: RunId,
2045    command: String,
2046    timeout: Duration,
2047    abort: CancellationToken,
2048    background_runs: Arc<Mutex<HashMap<RunId, BackgroundHandle>>>,
2049    tool_event_tx: Option<ToolEventTx>,
2050    background_completion_tx: Option<tokio::sync::mpsc::Sender<BackgroundCompletion>>,
2051    env: HashMap<String, String>,
2052    cwd: PathBuf,
2053) {
2054    use std::process::Stdio;
2055
2056    let started_at = std::time::Instant::now();
2057
2058    let mut cmd = build_bash_command_with_context(&command, &env, &cwd);
2059    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2060
2061    let mut child = match cmd.spawn() {
2062        Ok(c) => c,
2063        Err(ref e) => {
2064            let (_, out) = spawn_error_envelope(e);
2065            background_runs.lock().remove(&run_id);
2066            emit_completed(tool_event_tx.as_ref(), &command, out.clone(), false, run_id).await;
2067            if let Some(ref tx) = background_completion_tx {
2068                let _ = tx
2069                    .send(BackgroundCompletion {
2070                        run_id,
2071                        exit_code: 1,
2072                        output: out,
2073                        success: false,
2074                        elapsed_ms: 0,
2075                        command,
2076                    })
2077                    .await;
2078            }
2079            return;
2080        }
2081    };
2082
2083    if let Some(pid) = child.id()
2084        && let Some(handle) = background_runs.lock().get_mut(&run_id)
2085    {
2086        handle.child_pid = Some(pid);
2087    }
2088
2089    let stdout = child.stdout.take().expect("stdout piped");
2090    let stderr = child.stderr.take().expect("stderr piped");
2091    let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2092
2093    let mut combined = String::new();
2094    let mut stdout_buf = String::new();
2095    let mut stderr_buf = String::new();
2096    let deadline = tokio::time::Instant::now() + timeout;
2097    let timeout_secs = timeout.as_secs();
2098
2099    let (_, out) = match run_bash_stream(
2100        &command,
2101        deadline,
2102        Some(&abort),
2103        tool_event_tx.as_ref(),
2104        "",
2105        &mut line_rx,
2106        &mut combined,
2107        &mut stdout_buf,
2108        &mut stderr_buf,
2109        &mut child,
2110    )
2111    .await
2112    {
2113        BashLoopOutcome::TimedOut => (
2114            ShellOutputEnvelope {
2115                stdout: stdout_buf,
2116                stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2117                exit_code: 1,
2118                truncated: false,
2119            },
2120            format!("[error] command timed out after {timeout_secs}s"),
2121        ),
2122        BashLoopOutcome::Cancelled => (
2123            ShellOutputEnvelope {
2124                stdout: stdout_buf,
2125                stderr: stderr_buf,
2126                exit_code: 130,
2127                truncated: false,
2128            },
2129            "[cancelled] operation aborted".to_string(),
2130        ),
2131        BashLoopOutcome::StreamClosed => {
2132            finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2133        }
2134    };
2135
2136    #[allow(clippy::cast_possible_truncation)]
2137    let elapsed_ms = started_at.elapsed().as_millis() as u64;
2138    let success = !out.contains("[error]");
2139    let exit_code = i32::from(!success);
2140    let truncated = crate::executor::truncate_tool_output_at(&out, 4096);
2141
2142    background_runs.lock().remove(&run_id);
2143    emit_completed(
2144        tool_event_tx.as_ref(),
2145        &command,
2146        truncated.clone(),
2147        success,
2148        run_id,
2149    )
2150    .await;
2151
2152    if let Some(ref tx) = background_completion_tx {
2153        let completion = BackgroundCompletion {
2154            run_id,
2155            exit_code,
2156            output: truncated,
2157            success,
2158            elapsed_ms,
2159            command,
2160        };
2161        if tx.send(completion).await.is_err() {
2162            tracing::warn!(
2163                run_id = %run_id,
2164                "background completion channel closed; agent may have shut down"
2165            );
2166        }
2167    }
2168
2169    tracing::debug!(run_id = %run_id, exit_code, elapsed_ms, "background shell run (with context) completed");
2170}
2171
2172/// Emit a `ToolEvent::Completed` to `tool_event_tx` if it is set.
2173async fn emit_completed(
2174    tool_event_tx: Option<&ToolEventTx>,
2175    command: &str,
2176    output: String,
2177    success: bool,
2178    run_id: RunId,
2179) {
2180    if let Some(tx) = tool_event_tx {
2181        let _ = tx
2182            .send(ToolEvent::Completed {
2183                tool_name: ToolName::new("bash"),
2184                command: command.to_owned(),
2185                output,
2186                success,
2187                filter_stats: None,
2188                diff: None,
2189                run_id: Some(run_id),
2190            })
2191            .await;
2192    }
2193}
2194
2195/// Strip shell escape sequences that could bypass command detection.
2196/// Handles: backslash insertion (`su\do` -> `sudo`), `$'\xNN'` hex and `$'\NNN'` octal
2197/// escapes, adjacent quoted segments (`"su""do"` -> `sudo`), backslash-newline continuations.
2198pub(crate) fn strip_shell_escapes(input: &str) -> String {
2199    let mut out = String::with_capacity(input.len());
2200    let bytes = input.as_bytes();
2201    let mut i = 0;
2202    while i < bytes.len() {
2203        // $'...' ANSI-C quoting: decode \xNN hex and \NNN octal escapes
2204        if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'\'' {
2205            let mut j = i + 2; // points after $'
2206            let mut decoded = String::new();
2207            let mut valid = false;
2208            while j < bytes.len() && bytes[j] != b'\'' {
2209                if bytes[j] == b'\\' && j + 1 < bytes.len() {
2210                    let next = bytes[j + 1];
2211                    if next == b'x' && j + 3 < bytes.len() {
2212                        // \xNN hex escape
2213                        let hi = (bytes[j + 2] as char).to_digit(16);
2214                        let lo = (bytes[j + 3] as char).to_digit(16);
2215                        if let (Some(h), Some(l)) = (hi, lo) {
2216                            #[allow(clippy::cast_possible_truncation)]
2217                            let byte = ((h << 4) | l) as u8;
2218                            decoded.push(byte as char);
2219                            j += 4;
2220                            valid = true;
2221                            continue;
2222                        }
2223                    } else if next.is_ascii_digit() {
2224                        // \NNN octal escape (up to 3 digits)
2225                        let mut val = u32::from(next - b'0');
2226                        let mut len = 2; // consumed \N so far
2227                        if j + 2 < bytes.len() && bytes[j + 2].is_ascii_digit() {
2228                            val = val * 8 + u32::from(bytes[j + 2] - b'0');
2229                            len = 3;
2230                            if j + 3 < bytes.len() && bytes[j + 3].is_ascii_digit() {
2231                                val = val * 8 + u32::from(bytes[j + 3] - b'0');
2232                                len = 4;
2233                            }
2234                        }
2235                        #[allow(clippy::cast_possible_truncation)]
2236                        decoded.push((val & 0xFF) as u8 as char);
2237                        j += len;
2238                        valid = true;
2239                        continue;
2240                    }
2241                    // other \X escape: emit X literally
2242                    decoded.push(next as char);
2243                    j += 2;
2244                } else {
2245                    decoded.push(bytes[j] as char);
2246                    j += 1;
2247                }
2248            }
2249            if j < bytes.len() && bytes[j] == b'\'' && valid {
2250                out.push_str(&decoded);
2251                i = j + 1;
2252                continue;
2253            }
2254            // not a decodable $'...' sequence — fall through to handle as regular chars
2255        }
2256        // backslash-newline continuation: remove both
2257        if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
2258            i += 2;
2259            continue;
2260        }
2261        // intra-word backslash: skip the backslash, keep next char (e.g. su\do -> sudo)
2262        if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] != b'\n' {
2263            i += 1;
2264            out.push(bytes[i] as char);
2265            i += 1;
2266            continue;
2267        }
2268        // quoted segment stripping: collapse adjacent quoted segments
2269        if bytes[i] == b'"' || bytes[i] == b'\'' {
2270            let quote = bytes[i];
2271            i += 1;
2272            while i < bytes.len() && bytes[i] != quote {
2273                out.push(bytes[i] as char);
2274                i += 1;
2275            }
2276            if i < bytes.len() {
2277                i += 1; // skip closing quote
2278            }
2279            continue;
2280        }
2281        out.push(bytes[i] as char);
2282        i += 1;
2283    }
2284    out
2285}
2286
2287/// Extract inner command strings from subshell constructs in `s`.
2288///
2289/// Recognises:
2290/// - Backtick: `` `cmd` `` → `cmd`
2291/// - Dollar-paren: `$(cmd)` → `cmd`
2292/// - Process substitution (lt): `<(cmd)` → `cmd`
2293/// - Process substitution (gt): `>(cmd)` → `cmd`
2294///
2295/// Depth counting handles nested parentheses correctly.
2296pub(crate) fn extract_subshell_contents(s: &str) -> Vec<String> {
2297    let mut results = Vec::new();
2298    let chars: Vec<char> = s.chars().collect();
2299    let len = chars.len();
2300    let mut i = 0;
2301
2302    while i < len {
2303        // Backtick substitution: `...`
2304        if chars[i] == '`' {
2305            let start = i + 1;
2306            let mut j = start;
2307            while j < len && chars[j] != '`' {
2308                j += 1;
2309            }
2310            if j < len {
2311                results.push(chars[start..j].iter().collect());
2312            }
2313            i = j + 1;
2314            continue;
2315        }
2316
2317        // $(...), <(...), >(...)
2318        let next_is_open_paren = i + 1 < len && chars[i + 1] == '(';
2319        let is_paren_subshell = next_is_open_paren && matches!(chars[i], '$' | '<' | '>');
2320
2321        if is_paren_subshell {
2322            let start = i + 2;
2323            let mut depth: usize = 1;
2324            let mut j = start;
2325            while j < len && depth > 0 {
2326                match chars[j] {
2327                    '(' => depth += 1,
2328                    ')' => depth -= 1,
2329                    _ => {}
2330                }
2331                if depth > 0 {
2332                    j += 1;
2333                } else {
2334                    break;
2335                }
2336            }
2337            if depth == 0 {
2338                results.push(chars[start..j].iter().collect());
2339            }
2340            i = j + 1;
2341            continue;
2342        }
2343
2344        i += 1;
2345    }
2346
2347    results
2348}
2349
2350/// Split normalized shell code into sub-commands on `|`, `||`, `&&`, `;`, `\n`.
2351/// Returns list of sub-commands, each as `Vec<String>` of tokens.
2352pub(crate) fn tokenize_commands(normalized: &str) -> Vec<Vec<String>> {
2353    // Replace two-char operators with a single separator, then split on single-char separators
2354    let replaced = normalized.replace("||", "\n").replace("&&", "\n");
2355    replaced
2356        .split([';', '|', '\n'])
2357        .map(|seg| {
2358            seg.split_whitespace()
2359                .map(str::to_owned)
2360                .collect::<Vec<String>>()
2361        })
2362        .filter(|tokens| !tokens.is_empty())
2363        .collect()
2364}
2365
2366/// Transparent prefix commands that invoke the next argument as a command.
2367/// Skipped when determining the "real" command name being invoked.
2368const TRANSPARENT_PREFIXES: &[&str] = &["env", "command", "exec", "nice", "nohup", "time", "xargs"];
2369
2370/// Return the basename of a token (last path component after '/').
2371fn cmd_basename(tok: &str) -> &str {
2372    tok.rsplit('/').next().unwrap_or(tok)
2373}
2374
2375/// Check if the first tokens of a sub-command match a blocked pattern.
2376/// Handles:
2377/// - Transparent prefix commands (`env sudo rm` -> checks `sudo`)
2378/// - Absolute paths (`/usr/bin/sudo rm` -> basename `sudo` is checked)
2379/// - Dot-suffixed variants (`mkfs` matches `mkfs.ext4`)
2380/// - Multi-word patterns (`rm -rf /` joined prefix check)
2381pub(crate) fn tokens_match_pattern(tokens: &[String], pattern: &str) -> bool {
2382    if tokens.is_empty() || pattern.is_empty() {
2383        return false;
2384    }
2385    let pattern = pattern.trim();
2386    let pattern_tokens: Vec<&str> = pattern.split_whitespace().collect();
2387    if pattern_tokens.is_empty() {
2388        return false;
2389    }
2390
2391    // Skip transparent prefix tokens to reach the real command
2392    let start = tokens
2393        .iter()
2394        .position(|t| !TRANSPARENT_PREFIXES.contains(&cmd_basename(t)))
2395        .unwrap_or(0);
2396    let effective = &tokens[start..];
2397    if effective.is_empty() {
2398        return false;
2399    }
2400
2401    if pattern_tokens.len() == 1 {
2402        let pat = pattern_tokens[0];
2403        let base = cmd_basename(&effective[0]);
2404        // Exact match OR dot-suffixed variant (e.g. "mkfs" matches "mkfs.ext4")
2405        base == pat || base.starts_with(&format!("{pat}."))
2406    } else {
2407        // Multi-word: join first N tokens (using basename for first) and check prefix
2408        let n = pattern_tokens.len().min(effective.len());
2409        let mut parts: Vec<&str> = vec![cmd_basename(&effective[0])];
2410        parts.extend(effective[1..n].iter().map(String::as_str));
2411        let joined = parts.join(" ");
2412        if joined.starts_with(pattern) {
2413            return true;
2414        }
2415        if effective.len() > n {
2416            let mut parts2: Vec<&str> = vec![cmd_basename(&effective[0])];
2417            parts2.extend(effective[1..=n].iter().map(String::as_str));
2418            parts2.join(" ").starts_with(pattern)
2419        } else {
2420            false
2421        }
2422    }
2423}
2424
2425fn extract_paths(code: &str) -> Vec<String> {
2426    let mut result = Vec::new();
2427
2428    // Tokenize respecting single/double quotes
2429    let mut tokens: Vec<String> = Vec::new();
2430    let mut current = String::new();
2431    let mut chars = code.chars().peekable();
2432    while let Some(c) = chars.next() {
2433        match c {
2434            '"' | '\'' => {
2435                let quote = c;
2436                while let Some(&nc) = chars.peek() {
2437                    if nc == quote {
2438                        chars.next();
2439                        break;
2440                    }
2441                    current.push(chars.next().unwrap());
2442                }
2443            }
2444            c if c.is_whitespace() || matches!(c, ';' | '|' | '&') => {
2445                if !current.is_empty() {
2446                    tokens.push(std::mem::take(&mut current));
2447                }
2448            }
2449            _ => current.push(c),
2450        }
2451    }
2452    if !current.is_empty() {
2453        tokens.push(current);
2454    }
2455
2456    for token in tokens {
2457        let trimmed = token.trim_end_matches([';', '&', '|']).to_owned();
2458        if trimmed.is_empty() {
2459            continue;
2460        }
2461        if trimmed.starts_with('/')
2462            || trimmed.starts_with("./")
2463            || trimmed.starts_with("../")
2464            || trimmed == ".."
2465            || (trimmed.starts_with('.') && trimmed.contains('/'))
2466            || is_relative_path_token(&trimmed)
2467        {
2468            result.push(trimmed);
2469        }
2470    }
2471    result
2472}
2473
2474/// Returns `true` if `token` looks like a relative path of the form `word/more`
2475/// (contains `/` but does not start with `/` or `.`).
2476///
2477/// Excluded:
2478/// - URL schemes (`scheme://`)
2479/// - Shell variable assignments (`KEY=value`)
2480fn is_relative_path_token(token: &str) -> bool {
2481    // Must contain a slash but not start with `/` (absolute) or `.` (handled above).
2482    if !token.contains('/') || token.starts_with('/') || token.starts_with('.') {
2483        return false;
2484    }
2485    // Reject URLs: anything with `://`
2486    if token.contains("://") {
2487        return false;
2488    }
2489    // Reject shell variable assignments: `IDENTIFIER=...`
2490    if let Some(eq_pos) = token.find('=') {
2491        let key = &token[..eq_pos];
2492        if key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
2493            return false;
2494        }
2495    }
2496    // First character must be an identifier-start (letter, digit, or `_`).
2497    token
2498        .chars()
2499        .next()
2500        .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
2501}
2502
2503/// Classify shell exit codes and stderr patterns into `ToolErrorCategory`.
2504///
2505/// Returns `Some(category)` only for well-known failure modes that benefit from
2506/// structured feedback (exit 126/127, recognisable stderr patterns). All other
2507/// non-zero exits are left as `Ok` output so they surface verbatim to the LLM.
2508fn classify_shell_exit(
2509    exit_code: i32,
2510    output: &str,
2511) -> Option<crate::error_taxonomy::ToolErrorCategory> {
2512    use crate::error_taxonomy::ToolErrorCategory;
2513    match exit_code {
2514        // exit 126: command found but not executable (OS-level permission/policy)
2515        126 => Some(ToolErrorCategory::PolicyBlocked),
2516        // exit 127: command not found in PATH
2517        127 => Some(ToolErrorCategory::PermanentFailure),
2518        _ => {
2519            let lower = output.to_lowercase();
2520            if lower.contains("permission denied") {
2521                Some(ToolErrorCategory::PolicyBlocked)
2522            } else if lower.contains("no such file or directory") {
2523                Some(ToolErrorCategory::PermanentFailure)
2524            } else {
2525                None
2526            }
2527        }
2528    }
2529}
2530
2531fn has_traversal(path: &str) -> bool {
2532    path.split(['/', '\\']).any(|seg| seg == "..")
2533}
2534
2535/// Canonicalize `path`, resolving symlinks even when `path` itself does not exist yet.
2536///
2537/// `Path::canonicalize` requires the full path to exist, which fails for a file that is
2538/// about to be created (e.g. a checkpoint capture taken before a write). Falling back to
2539/// `std::path::absolute` in that case does not resolve symlinks, so on macOS a path under
2540/// `/tmp`/`/var` never becomes `/private/tmp`/`/private/var` — silently breaking any
2541/// subsequent `starts_with(allowed_paths_canonical)` containment check (#5999).
2542///
2543/// This walks up from `path` to the nearest existing ancestor, canonicalizes that
2544/// ancestor (resolving its symlinks), and reattaches the non-existent suffix. Falls back
2545/// to `std::path::absolute` (or `path` itself) only when no ancestor can be canonicalized.
2546fn canonicalize_or_nearest_ancestor(path: &std::path::Path) -> std::path::PathBuf {
2547    if let Ok(c) = path.canonicalize() {
2548        return c;
2549    }
2550    let components: Vec<_> = path.components().collect();
2551    let mut base_len = components.len();
2552    let canonical_base = loop {
2553        if base_len == 0 {
2554            break None;
2555        }
2556        let candidate: std::path::PathBuf = components[..base_len].iter().collect();
2557        if let Ok(c) = candidate.canonicalize() {
2558            break Some(c);
2559        }
2560        base_len -= 1;
2561    };
2562    match canonical_base {
2563        Some(base) => components[base_len..]
2564            .iter()
2565            .fold(base, |acc, c| acc.join(c)),
2566        None => std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()),
2567    }
2568}
2569
2570fn extract_bash_blocks(text: &str) -> Vec<&str> {
2571    crate::executor::extract_fenced_blocks(text, "bash")
2572}
2573
2574/// Send SIGTERM to a process, wait [`GRACEFUL_TERM_MS`], then send SIGKILL.
2575///
2576/// `pkill -KILL -P <pid>` is issued before the final SIGKILL to reap any
2577/// child processes that bash may have spawned. Note: `pkill -P` sends SIGKILL
2578/// to the *children* of `pid`, not to `pid` itself.
2579///
2580/// **ESRCH on SIGKILL is safe and expected.** If the process exited voluntarily
2581/// during the grace period, the OS returns `ESRCH` ("no such process") for the
2582/// SIGKILL call; this is silently swallowed and not treated as an error.
2583///
2584/// **PID reuse caveat.** If bash exits during the 250 ms window and the OS
2585/// recycles its PID before `kill(SIGKILL)` is issued, the SIGKILL could
2586/// theoretically reach an unrelated process. In practice the 250 ms window is
2587/// too short for PID recycling under normal load, so this is treated as an
2588/// acceptable trade-off for MVP.
2589#[cfg(unix)]
2590async fn send_signal_with_escalation(pid: u32) {
2591    use nix::errno::Errno;
2592    use nix::sys::signal::{Signal, kill};
2593    use nix::unistd::Pid;
2594
2595    let Ok(pid_i32) = i32::try_from(pid) else {
2596        return;
2597    };
2598    let target = Pid::from_raw(pid_i32);
2599
2600    if let Err(e) = kill(target, Signal::SIGTERM)
2601        && e != Errno::ESRCH
2602    {
2603        tracing::debug!(pid, err = %e, "SIGTERM failed");
2604    }
2605    tokio::time::sleep(GRACEFUL_TERM_MS).await;
2606    // Kill children of pid (not pid itself); ESRCH if none exist is harmless.
2607    let _ = Command::new("pkill")
2608        .args(["-KILL", "-P", &pid.to_string()])
2609        .status()
2610        .await;
2611    if let Err(e) = kill(target, Signal::SIGKILL)
2612        && e != Errno::ESRCH
2613    {
2614        tracing::debug!(pid, err = %e, "SIGKILL failed");
2615    }
2616}
2617
2618/// Kill a child process and its descendants.
2619///
2620/// On Unix, sends SIGTERM first, waits [`GRACEFUL_TERM_MS`], reaps descendants,
2621/// then sends SIGKILL. Always finishes with [`tokio::process::Child::kill`] to
2622/// ensure the `Child` reaper sees the dead process.
2623async fn kill_process_tree(child: &mut tokio::process::Child) {
2624    #[cfg(unix)]
2625    if let Some(pid) = child.id() {
2626        send_signal_with_escalation(pid).await;
2627    }
2628    let _ = child.kill().await;
2629}
2630
2631/// Structured output from a shell command execution.
2632///
2633/// Produced by the internal `execute_bash` function and included in the final
2634/// [`ToolOutput`] and [`AuditEntry`] for the invocation.
2635#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2636pub struct ShellOutputEnvelope {
2637    /// Captured standard output, possibly truncated.
2638    pub stdout: String,
2639    /// Captured standard error, possibly truncated.
2640    pub stderr: String,
2641    /// Process exit code. `0` indicates success by convention.
2642    pub exit_code: i32,
2643    /// `true` when the combined output exceeded the configured max and was truncated.
2644    pub truncated: bool,
2645}
2646
2647// Used only in cfg(test) blocks; dead_code analysis does not see test imports.
2648#[allow(dead_code, clippy::too_many_arguments)]
2649async fn execute_bash(
2650    code: &str,
2651    timeout: Duration,
2652    event_tx: Option<&ToolEventTx>,
2653    cancel_token: Option<&CancellationToken>,
2654    extra_env: Option<&std::collections::HashMap<String, String>>,
2655    env_blocklist: &[String],
2656    sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2657    tool_call_id: &str,
2658) -> (ShellOutputEnvelope, String) {
2659    use std::process::Stdio;
2660
2661    let timeout_secs = timeout.as_secs();
2662    let mut cmd = build_bash_command(code, extra_env, env_blocklist);
2663
2664    if let Err(envelope_err) = apply_sandbox(&mut cmd, sandbox) {
2665        return envelope_err;
2666    }
2667
2668    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2669
2670    let mut child = match cmd.spawn() {
2671        Ok(c) => c,
2672        Err(ref e) => return spawn_error_envelope(e),
2673    };
2674
2675    let stdout = child.stdout.take().expect("stdout piped");
2676    let stderr = child.stderr.take().expect("stderr piped");
2677    let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2678
2679    let mut combined = String::new();
2680    let mut stdout_buf = String::new();
2681    let mut stderr_buf = String::new();
2682    let deadline = tokio::time::Instant::now() + timeout;
2683
2684    match run_bash_stream(
2685        code,
2686        deadline,
2687        cancel_token,
2688        event_tx,
2689        tool_call_id,
2690        &mut line_rx,
2691        &mut combined,
2692        &mut stdout_buf,
2693        &mut stderr_buf,
2694        &mut child,
2695    )
2696    .await
2697    {
2698        BashLoopOutcome::TimedOut => {
2699            let msg = format!("[error] command timed out after {timeout_secs}s");
2700            (
2701                ShellOutputEnvelope {
2702                    stdout: stdout_buf,
2703                    stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2704                    exit_code: 1,
2705                    truncated: false,
2706                },
2707                msg,
2708            )
2709        }
2710        BashLoopOutcome::Cancelled => (
2711            ShellOutputEnvelope {
2712                stdout: stdout_buf,
2713                stderr: format!("{stderr_buf}operation aborted"),
2714                exit_code: 130,
2715                truncated: false,
2716            },
2717            "[cancelled] operation aborted".to_string(),
2718        ),
2719        BashLoopOutcome::StreamClosed => {
2720            finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2721        }
2722    }
2723}
2724
2725fn build_bash_command(
2726    code: &str,
2727    extra_env: Option<&std::collections::HashMap<String, String>>,
2728    env_blocklist: &[String],
2729) -> Command {
2730    let mut cmd = Command::new("bash");
2731    cmd.arg("-c").arg(code);
2732    for (key, _) in std::env::vars() {
2733        if env_blocklist
2734            .iter()
2735            .any(|prefix| key.starts_with(prefix.as_str()))
2736        {
2737            cmd.env_remove(&key);
2738        }
2739    }
2740    if let Some(env) = extra_env {
2741        cmd.envs(env);
2742    }
2743    cmd
2744}
2745
2746/// Build a `Command` using a pre-resolved env map and explicit cwd.
2747///
2748/// Clears the process env and applies only `resolved_env` — no blocklist re-apply needed
2749/// because the caller (`resolve_context`) has already done that.
2750fn build_bash_command_with_context(
2751    code: &str,
2752    resolved_env: &HashMap<String, String>,
2753    cwd: &std::path::Path,
2754) -> Command {
2755    let mut cmd = Command::new("bash");
2756    cmd.arg("-c").arg(code);
2757    cmd.env_clear();
2758    cmd.envs(resolved_env);
2759    cmd.current_dir(cwd);
2760    cmd
2761}
2762
2763/// Execute `code` using a pre-resolved [`ResolvedContext`].
2764///
2765/// Unlike [`execute_bash`], this function receives the *final merged env* from
2766/// `resolve_context` and sets `current_dir` to the resolved CWD.
2767async fn execute_bash_with_context(
2768    code: &str,
2769    timeout: Duration,
2770    event_tx: Option<&ToolEventTx>,
2771    tool_call_id: &str,
2772    cancel_token: Option<&CancellationToken>,
2773    resolved: &ResolvedContext,
2774    sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2775) -> (ShellOutputEnvelope, String) {
2776    use std::process::Stdio;
2777
2778    let timeout_secs = timeout.as_secs();
2779    let mut cmd = build_bash_command_with_context(code, &resolved.env, &resolved.cwd);
2780
2781    if let Err(envelope_err) = apply_sandbox(&mut cmd, sandbox) {
2782        return envelope_err;
2783    }
2784
2785    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2786
2787    let mut child = match cmd.spawn() {
2788        Ok(c) => c,
2789        Err(ref e) => return spawn_error_envelope(e),
2790    };
2791
2792    let stdout = child.stdout.take().expect("stdout piped");
2793    let stderr = child.stderr.take().expect("stderr piped");
2794    let (mut line_rx, _reader_tasks) = spawn_output_readers(stdout, stderr);
2795
2796    let mut combined = String::new();
2797    let mut stdout_buf = String::new();
2798    let mut stderr_buf = String::new();
2799    let deadline = tokio::time::Instant::now() + timeout;
2800
2801    match run_bash_stream(
2802        code,
2803        deadline,
2804        cancel_token,
2805        event_tx,
2806        tool_call_id,
2807        &mut line_rx,
2808        &mut combined,
2809        &mut stdout_buf,
2810        &mut stderr_buf,
2811        &mut child,
2812    )
2813    .await
2814    {
2815        BashLoopOutcome::TimedOut => {
2816            let msg = format!("[error] command timed out after {timeout_secs}s");
2817            (
2818                ShellOutputEnvelope {
2819                    stdout: stdout_buf,
2820                    stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
2821                    exit_code: 1,
2822                    truncated: false,
2823                },
2824                msg,
2825            )
2826        }
2827        BashLoopOutcome::Cancelled => (
2828            ShellOutputEnvelope {
2829                stdout: stdout_buf,
2830                stderr: format!("{stderr_buf}operation aborted"),
2831                exit_code: 130,
2832                truncated: false,
2833            },
2834            "[cancelled] operation aborted".to_string(),
2835        ),
2836        BashLoopOutcome::StreamClosed => {
2837            finalize_envelope(&mut child, combined, stdout_buf, stderr_buf).await
2838        }
2839    }
2840}
2841
2842fn apply_sandbox(
2843    cmd: &mut Command,
2844    sandbox: Option<(&dyn Sandbox, &SandboxPolicy)>,
2845) -> Result<(), (ShellOutputEnvelope, String)> {
2846    // Apply OS sandbox before setting stdio so the rewritten program is sandboxed.
2847    if let Some((sb, policy)) = sandbox
2848        && let Err(err) = sb.wrap(cmd, policy)
2849    {
2850        let msg = format!("[error] sandbox setup failed: {err}");
2851        return Err((
2852            ShellOutputEnvelope {
2853                stdout: String::new(),
2854                stderr: msg.clone(),
2855                exit_code: 1,
2856                truncated: false,
2857            },
2858            msg,
2859        ));
2860    }
2861    Ok(())
2862}
2863
2864fn spawn_error_envelope(e: &std::io::Error) -> (ShellOutputEnvelope, String) {
2865    let msg = format!("[error] {e}");
2866    (
2867        ShellOutputEnvelope {
2868            stdout: String::new(),
2869            stderr: msg.clone(),
2870            exit_code: 1,
2871            truncated: false,
2872        },
2873        msg,
2874    )
2875}
2876
2877// Channel carries (is_stderr, line) so we can accumulate separate buffers
2878// while still building a combined interleaved string for streaming and LLM context.
2879//
2880// Returns the line receiver and a JoinSet holding the two reader tasks. The caller must
2881// keep the JoinSet alive for the duration of the read loop — dropping it aborts the readers.
2882fn spawn_output_readers(
2883    stdout: tokio::process::ChildStdout,
2884    stderr: tokio::process::ChildStderr,
2885) -> (
2886    tokio::sync::mpsc::Receiver<(bool, String)>,
2887    tokio::task::JoinSet<()>,
2888) {
2889    use tokio::io::{AsyncBufReadExt, BufReader};
2890
2891    let (line_tx, line_rx) = tokio::sync::mpsc::channel::<(bool, String)>(64);
2892    let mut readers = tokio::task::JoinSet::new();
2893
2894    let stdout_tx = line_tx.clone();
2895    readers.spawn(async move {
2896        let mut reader = BufReader::new(stdout);
2897        let mut buf = String::new();
2898        while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
2899            let _ = stdout_tx.send((false, buf.clone())).await;
2900            buf.clear();
2901        }
2902    });
2903
2904    readers.spawn(async move {
2905        let mut reader = BufReader::new(stderr);
2906        let mut buf = String::new();
2907        while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
2908            let _ = line_tx.send((true, buf.clone())).await;
2909            buf.clear();
2910        }
2911    });
2912
2913    (line_rx, readers)
2914}
2915
2916/// Terminal condition of the streaming select loop.
2917///
2918/// `kill_process_tree` is called inside this function before returning `TimedOut`
2919/// or `Cancelled`, so the caller's envelope helpers can stay side-effect-free.
2920enum BashLoopOutcome {
2921    StreamClosed,
2922    TimedOut,
2923    Cancelled,
2924}
2925
2926#[allow(clippy::too_many_arguments)]
2927async fn run_bash_stream(
2928    code: &str,
2929    deadline: tokio::time::Instant,
2930    cancel_token: Option<&CancellationToken>,
2931    event_tx: Option<&ToolEventTx>,
2932    tool_call_id: &str,
2933    line_rx: &mut tokio::sync::mpsc::Receiver<(bool, String)>,
2934    combined: &mut String,
2935    stdout_buf: &mut String,
2936    stderr_buf: &mut String,
2937    child: &mut tokio::process::Child,
2938) -> BashLoopOutcome {
2939    loop {
2940        tokio::select! {
2941            line = line_rx.recv() => {
2942                match line {
2943                    Some((is_stderr, chunk)) => {
2944                        let interleaved = if is_stderr {
2945                            format!("[stderr] {chunk}")
2946                        } else {
2947                            chunk.clone()
2948                        };
2949                        if let Some(tx) = event_tx {
2950                            // Non-terminal streaming event: use try_send (drop on full).
2951                            let _ = tx.try_send(ToolEvent::OutputChunk {
2952                                tool_name: ToolName::new("bash"),
2953                                command: code.to_owned(),
2954                                chunk: interleaved.clone(),
2955                                tool_call_id: tool_call_id.to_owned(),
2956                                skill_name: None,
2957                            });
2958                        }
2959                        combined.push_str(&interleaved);
2960                        if is_stderr {
2961                            stderr_buf.push_str(&chunk);
2962                        } else {
2963                            stdout_buf.push_str(&chunk);
2964                        }
2965                    }
2966                    None => return BashLoopOutcome::StreamClosed,
2967                }
2968            }
2969            () = tokio::time::sleep_until(deadline) => {
2970                kill_process_tree(child).await;
2971                return BashLoopOutcome::TimedOut;
2972            }
2973            () = async {
2974                match cancel_token {
2975                    Some(t) => t.cancelled().await,
2976                    None => std::future::pending().await,
2977                }
2978            } => {
2979                kill_process_tree(child).await;
2980                return BashLoopOutcome::Cancelled;
2981            }
2982        }
2983    }
2984}
2985
2986async fn finalize_envelope(
2987    child: &mut tokio::process::Child,
2988    combined: String,
2989    stdout_buf: String,
2990    stderr_buf: String,
2991) -> (ShellOutputEnvelope, String) {
2992    let status = child.wait().await;
2993    let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(1);
2994
2995    if combined.is_empty() {
2996        (
2997            ShellOutputEnvelope {
2998                stdout: String::new(),
2999                stderr: String::new(),
3000                exit_code,
3001                truncated: false,
3002            },
3003            "(no output)".to_string(),
3004        )
3005    } else {
3006        (
3007            ShellOutputEnvelope {
3008                stdout: stdout_buf.trim_end().to_owned(),
3009                stderr: stderr_buf.trim_end().to_owned(),
3010                exit_code,
3011                truncated: false,
3012            },
3013            combined,
3014        )
3015    }
3016}
3017
3018#[cfg(test)]
3019mod tests;