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