Skip to main content

zeph_tools/shell/
mod.rs

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