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
4use std::path::PathBuf;
5use std::time::{Duration, Instant};
6
7use tokio::process::Command;
8use tokio_util::sync::CancellationToken;
9
10use schemars::JsonSchema;
11use serde::Deserialize;
12
13use std::sync::Arc;
14
15use crate::audit::{AuditEntry, AuditLogger, AuditResult, chrono_now};
16use crate::config::ShellConfig;
17use crate::executor::{
18    ClaimSource, FilterStats, ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput,
19};
20use crate::filter::{OutputFilterRegistry, sanitize_output};
21use crate::permissions::{PermissionAction, PermissionPolicy};
22
23mod transaction;
24use transaction::{TransactionSnapshot, affected_paths, build_scope_matchers, is_write_command};
25
26const DEFAULT_BLOCKED: &[&str] = &[
27    "rm -rf /", "sudo", "mkfs", "dd if=", "curl", "wget", "nc ", "ncat", "netcat", "shutdown",
28    "reboot", "halt",
29];
30
31/// The default list of blocked command patterns used by [`ShellExecutor`].
32///
33/// Exposed so other executors (e.g. `AcpShellExecutor`) can reuse the same
34/// blocklist without duplicating it.
35pub const DEFAULT_BLOCKED_COMMANDS: &[&str] = DEFAULT_BLOCKED;
36
37/// Shell interpreters that may execute arbitrary code via `-c` or positional args.
38pub const SHELL_INTERPRETERS: &[&str] =
39    &["bash", "sh", "zsh", "fish", "dash", "ksh", "csh", "tcsh"];
40
41/// Subshell metacharacters that could embed a blocked command inside a benign wrapper.
42/// Commands containing these sequences are rejected outright because safe static
43/// analysis of nested shell evaluation is not feasible.
44const SUBSHELL_METACHARS: &[&str] = &["$(", "`", "<(", ">("];
45
46/// Check if `command` matches any pattern in `blocklist`.
47///
48/// Returns the matched pattern string if the command is blocked, `None` otherwise.
49/// The check is case-insensitive and handles common shell escape sequences.
50///
51/// Commands containing subshell metacharacters (`$(` or `` ` ``) are always
52/// blocked because nested evaluation cannot be safely analysed statically.
53#[must_use]
54pub fn check_blocklist(command: &str, blocklist: &[String]) -> Option<String> {
55    let lower = command.to_lowercase();
56    // Reject commands that embed subshell constructs to prevent blocklist bypass.
57    for meta in SUBSHELL_METACHARS {
58        if lower.contains(meta) {
59            return Some((*meta).to_owned());
60        }
61    }
62    let cleaned = strip_shell_escapes(&lower);
63    let commands = tokenize_commands(&cleaned);
64    for blocked in blocklist {
65        for cmd_tokens in &commands {
66            if tokens_match_pattern(cmd_tokens, blocked) {
67                return Some(blocked.clone());
68            }
69        }
70    }
71    None
72}
73
74/// Build the effective command string for blocklist evaluation when the binary is a
75/// shell interpreter (bash, sh, zsh, etc.) and args contains a `-c` script.
76///
77/// Returns `None` if the args do not follow the `-c <script>` pattern.
78#[must_use]
79pub fn effective_shell_command<'a>(binary: &str, args: &'a [String]) -> Option<&'a str> {
80    let base = binary.rsplit('/').next().unwrap_or(binary);
81    if !SHELL_INTERPRETERS.contains(&base) {
82        return None;
83    }
84    // Find "-c" and return the next element as the script to check.
85    let pos = args.iter().position(|a| a == "-c")?;
86    args.get(pos + 1).map(String::as_str)
87}
88
89const NETWORK_COMMANDS: &[&str] = &["curl", "wget", "nc ", "ncat", "netcat"];
90
91#[derive(Deserialize, JsonSchema)]
92pub(crate) struct BashParams {
93    /// The bash command to execute
94    command: String,
95}
96
97/// Bash block extraction and execution via `tokio::process::Command`.
98#[derive(Debug)]
99pub struct ShellExecutor {
100    timeout: Duration,
101    blocked_commands: Vec<String>,
102    allowed_paths: Vec<PathBuf>,
103    confirm_patterns: Vec<String>,
104    env_blocklist: Vec<String>,
105    audit_logger: Option<Arc<AuditLogger>>,
106    tool_event_tx: Option<ToolEventTx>,
107    permission_policy: Option<PermissionPolicy>,
108    output_filter_registry: Option<OutputFilterRegistry>,
109    cancel_token: Option<CancellationToken>,
110    skill_env: std::sync::RwLock<Option<std::collections::HashMap<String, String>>>,
111    transactional: bool,
112    auto_rollback: bool,
113    auto_rollback_exit_codes: Vec<i32>,
114    snapshot_required: bool,
115    max_snapshot_bytes: u64,
116    transaction_scope_matchers: Vec<globset::GlobMatcher>,
117}
118
119impl ShellExecutor {
120    #[must_use]
121    pub fn new(config: &ShellConfig) -> Self {
122        let allowed: Vec<String> = config
123            .allowed_commands
124            .iter()
125            .map(|s| s.to_lowercase())
126            .collect();
127
128        let mut blocked: Vec<String> = DEFAULT_BLOCKED
129            .iter()
130            .filter(|s| !allowed.contains(&s.to_lowercase()))
131            .map(|s| (*s).to_owned())
132            .collect();
133        blocked.extend(config.blocked_commands.iter().map(|s| s.to_lowercase()));
134
135        if !config.allow_network {
136            for cmd in NETWORK_COMMANDS {
137                let lower = cmd.to_lowercase();
138                if !blocked.contains(&lower) {
139                    blocked.push(lower);
140                }
141            }
142        }
143
144        blocked.sort();
145        blocked.dedup();
146
147        let allowed_paths = if config.allowed_paths.is_empty() {
148            vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
149        } else {
150            config.allowed_paths.iter().map(PathBuf::from).collect()
151        };
152
153        Self {
154            timeout: Duration::from_secs(config.timeout),
155            blocked_commands: blocked,
156            allowed_paths,
157            confirm_patterns: config.confirm_patterns.clone(),
158            env_blocklist: config.env_blocklist.clone(),
159            audit_logger: None,
160            tool_event_tx: None,
161            permission_policy: None,
162            output_filter_registry: None,
163            cancel_token: None,
164            skill_env: std::sync::RwLock::new(None),
165            transactional: config.transactional,
166            auto_rollback: config.auto_rollback,
167            auto_rollback_exit_codes: config.auto_rollback_exit_codes.clone(),
168            snapshot_required: config.snapshot_required,
169            max_snapshot_bytes: config.max_snapshot_bytes,
170            transaction_scope_matchers: build_scope_matchers(&config.transaction_scope),
171        }
172    }
173
174    /// Set environment variables to inject when executing the active skill's bash blocks.
175    pub fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
176        match self.skill_env.write() {
177            Ok(mut guard) => *guard = env,
178            Err(e) => tracing::error!("skill_env RwLock poisoned: {e}"),
179        }
180    }
181
182    #[must_use]
183    pub fn with_audit(mut self, logger: Arc<AuditLogger>) -> Self {
184        self.audit_logger = Some(logger);
185        self
186    }
187
188    #[must_use]
189    pub fn with_tool_event_tx(mut self, tx: ToolEventTx) -> Self {
190        self.tool_event_tx = Some(tx);
191        self
192    }
193
194    #[must_use]
195    pub fn with_permissions(mut self, policy: PermissionPolicy) -> Self {
196        self.permission_policy = Some(policy);
197        self
198    }
199
200    #[must_use]
201    pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
202        self.cancel_token = Some(token);
203        self
204    }
205
206    #[must_use]
207    pub fn with_output_filters(mut self, registry: OutputFilterRegistry) -> Self {
208        self.output_filter_registry = Some(registry);
209        self
210    }
211
212    /// Execute a bash block bypassing the confirmation check (called after user confirms).
213    ///
214    /// # Errors
215    ///
216    /// Returns `ToolError` on blocked commands, sandbox violations, or execution failures.
217    pub async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
218        self.execute_inner(response, true).await
219    }
220
221    async fn execute_inner(
222        &self,
223        response: &str,
224        skip_confirm: bool,
225    ) -> Result<Option<ToolOutput>, ToolError> {
226        let blocks = extract_bash_blocks(response);
227        if blocks.is_empty() {
228            return Ok(None);
229        }
230
231        let mut outputs = Vec::with_capacity(blocks.len());
232        let mut cumulative_filter_stats: Option<FilterStats> = None;
233        let mut last_envelope: Option<ShellOutputEnvelope> = None;
234        #[allow(clippy::cast_possible_truncation)]
235        let blocks_executed = blocks.len() as u32;
236
237        for block in &blocks {
238            let (output_line, per_block_stats, envelope) =
239                self.execute_block(block, skip_confirm).await?;
240            if let Some(fs) = per_block_stats {
241                let stats = cumulative_filter_stats.get_or_insert_with(FilterStats::default);
242                stats.raw_chars += fs.raw_chars;
243                stats.filtered_chars += fs.filtered_chars;
244                stats.raw_lines += fs.raw_lines;
245                stats.filtered_lines += fs.filtered_lines;
246                stats.confidence = Some(match (stats.confidence, fs.confidence) {
247                    (Some(prev), Some(cur)) => crate::filter::worse_confidence(prev, cur),
248                    (Some(prev), None) => prev,
249                    (None, Some(cur)) => cur,
250                    (None, None) => unreachable!(),
251                });
252                if stats.command.is_none() {
253                    stats.command = fs.command;
254                }
255                if stats.kept_lines.is_empty() && !fs.kept_lines.is_empty() {
256                    stats.kept_lines = fs.kept_lines;
257                }
258            }
259            last_envelope = Some(envelope);
260            outputs.push(output_line);
261        }
262
263        let raw_response = last_envelope
264            .as_ref()
265            .and_then(|e| serde_json::to_value(e).ok());
266
267        Ok(Some(ToolOutput {
268            tool_name: "bash".to_owned(),
269            summary: outputs.join("\n\n"),
270            blocks_executed,
271            filter_stats: cumulative_filter_stats,
272            diff: None,
273            streamed: self.tool_event_tx.is_some(),
274            terminal_id: None,
275            locations: None,
276            raw_response,
277            claim_source: Some(ClaimSource::Shell),
278        }))
279    }
280
281    #[allow(clippy::too_many_lines)]
282    async fn execute_block(
283        &self,
284        block: &str,
285        skip_confirm: bool,
286    ) -> Result<(String, Option<FilterStats>, ShellOutputEnvelope), ToolError> {
287        self.check_permissions(block, skip_confirm).await?;
288        self.validate_sandbox(block)?;
289
290        // Take a transactional snapshot before executing write commands.
291        let mut snapshot_warning: Option<String> = None;
292        let snapshot = if self.transactional && is_write_command(block) {
293            let paths = affected_paths(block, &self.transaction_scope_matchers);
294            if paths.is_empty() {
295                None
296            } else {
297                match TransactionSnapshot::capture(&paths, self.max_snapshot_bytes) {
298                    Ok(snap) => {
299                        tracing::debug!(
300                            files = snap.file_count(),
301                            bytes = snap.total_bytes(),
302                            "transaction snapshot captured"
303                        );
304                        Some(snap)
305                    }
306                    Err(e) if self.snapshot_required => {
307                        return Err(ToolError::SnapshotFailed {
308                            reason: e.to_string(),
309                        });
310                    }
311                    Err(e) => {
312                        tracing::warn!(err = %e, "transaction snapshot failed, proceeding without rollback");
313                        snapshot_warning =
314                            Some(format!("[warn] snapshot failed: {e}; rollback unavailable"));
315                        None
316                    }
317                }
318            }
319        } else {
320            None
321        };
322
323        if let Some(ref tx) = self.tool_event_tx {
324            let _ = tx.send(ToolEvent::Started {
325                tool_name: "bash".to_owned(),
326                command: block.to_owned(),
327            });
328        }
329
330        let start = Instant::now();
331        let skill_env_snapshot: Option<std::collections::HashMap<String, String>> =
332            self.skill_env.read().ok().and_then(|g| g.clone());
333        let (mut envelope, out) = execute_bash(
334            block,
335            self.timeout,
336            self.tool_event_tx.as_ref(),
337            self.cancel_token.as_ref(),
338            skill_env_snapshot.as_ref(),
339            &self.env_blocklist,
340        )
341        .await;
342        let exit_code = envelope.exit_code;
343        if exit_code == 130
344            && self
345                .cancel_token
346                .as_ref()
347                .is_some_and(CancellationToken::is_cancelled)
348        {
349            return Err(ToolError::Cancelled);
350        }
351        #[allow(clippy::cast_possible_truncation)]
352        let duration_ms = start.elapsed().as_millis() as u64;
353
354        // Perform auto-rollback if configured and the exit code qualifies.
355        if let Some(snap) = snapshot {
356            let should_rollback = self.auto_rollback
357                && if self.auto_rollback_exit_codes.is_empty() {
358                    exit_code >= 2
359                } else {
360                    self.auto_rollback_exit_codes.contains(&exit_code)
361                };
362            if should_rollback {
363                match snap.rollback() {
364                    Ok(report) => {
365                        tracing::info!(
366                            restored = report.restored_count,
367                            deleted = report.deleted_count,
368                            "transaction rollback completed"
369                        );
370                        self.log_audit(
371                            block,
372                            AuditResult::Rollback {
373                                restored: report.restored_count,
374                                deleted: report.deleted_count,
375                            },
376                            duration_ms,
377                            None,
378                            Some(exit_code),
379                            false,
380                        )
381                        .await;
382                        if let Some(ref tx) = self.tool_event_tx {
383                            let _ = tx.send(ToolEvent::Rollback {
384                                tool_name: "bash".to_owned(),
385                                command: block.to_owned(),
386                                restored_count: report.restored_count,
387                                deleted_count: report.deleted_count,
388                            });
389                        }
390                    }
391                    Err(e) => {
392                        tracing::error!(err = %e, "transaction rollback failed");
393                    }
394                }
395            }
396            // On success (no rollback): snapshot dropped here; TempDir auto-cleans.
397        }
398
399        let is_timeout = out.contains("[error] command timed out");
400        let audit_result = if is_timeout {
401            AuditResult::Timeout
402        } else if out.contains("[error]") || out.contains("[stderr]") {
403            AuditResult::Error {
404                message: out.clone(),
405            }
406        } else {
407            AuditResult::Success
408        };
409        if is_timeout {
410            self.log_audit(
411                block,
412                audit_result,
413                duration_ms,
414                None,
415                Some(exit_code),
416                false,
417            )
418            .await;
419            self.emit_completed(block, &out, false, None);
420            return Err(ToolError::Timeout {
421                timeout_secs: self.timeout.as_secs(),
422            });
423        }
424
425        if let Some(category) = classify_shell_exit(exit_code, &out) {
426            self.emit_completed(block, &out, false, None);
427            return Err(ToolError::Shell {
428                exit_code,
429                category,
430                message: out.lines().take(3).collect::<Vec<_>>().join("; "),
431            });
432        }
433
434        let sanitized = sanitize_output(&out);
435        let mut per_block_stats: Option<FilterStats> = None;
436        let filtered = if let Some(ref registry) = self.output_filter_registry {
437            match registry.apply(block, &sanitized, exit_code) {
438                Some(fr) => {
439                    tracing::debug!(
440                        command = block,
441                        raw = fr.raw_chars,
442                        filtered = fr.filtered_chars,
443                        savings_pct = fr.savings_pct(),
444                        "output filter applied"
445                    );
446                    per_block_stats = Some(FilterStats {
447                        raw_chars: fr.raw_chars,
448                        filtered_chars: fr.filtered_chars,
449                        raw_lines: fr.raw_lines,
450                        filtered_lines: fr.filtered_lines,
451                        confidence: Some(fr.confidence),
452                        command: Some(block.to_owned()),
453                        kept_lines: fr.kept_lines.clone(),
454                    });
455                    fr.output
456                }
457                None => sanitized,
458            }
459        } else {
460            sanitized
461        };
462
463        self.emit_completed(
464            block,
465            &out,
466            !out.contains("[error]"),
467            per_block_stats.clone(),
468        );
469
470        // Mark truncated if output was shortened during filtering.
471        envelope.truncated = filtered.len() < out.len();
472
473        self.log_audit(
474            block,
475            audit_result,
476            duration_ms,
477            None,
478            Some(exit_code),
479            envelope.truncated,
480        )
481        .await;
482
483        let output_line = if let Some(warn) = snapshot_warning {
484            format!("{warn}\n$ {block}\n{filtered}")
485        } else {
486            format!("$ {block}\n{filtered}")
487        };
488        Ok((output_line, per_block_stats, envelope))
489    }
490
491    fn emit_completed(
492        &self,
493        command: &str,
494        output: &str,
495        success: bool,
496        filter_stats: Option<FilterStats>,
497    ) {
498        if let Some(ref tx) = self.tool_event_tx {
499            let _ = tx.send(ToolEvent::Completed {
500                tool_name: "bash".to_owned(),
501                command: command.to_owned(),
502                output: output.to_owned(),
503                success,
504                filter_stats,
505                diff: None,
506            });
507        }
508    }
509
510    /// Check blocklist, permission policy, and confirmation requirements for `block`.
511    async fn check_permissions(&self, block: &str, skip_confirm: bool) -> Result<(), ToolError> {
512        // Always check the blocklist first — it is a hard security boundary
513        // that must not be bypassed by the PermissionPolicy layer.
514        if let Some(blocked) = self.find_blocked_command(block) {
515            let err = ToolError::Blocked {
516                command: blocked.to_owned(),
517            };
518            self.log_audit(
519                block,
520                AuditResult::Blocked {
521                    reason: format!("blocked command: {blocked}"),
522                },
523                0,
524                Some(&err),
525                None,
526                false,
527            )
528            .await;
529            return Err(err);
530        }
531
532        if let Some(ref policy) = self.permission_policy {
533            match policy.check("bash", block) {
534                PermissionAction::Deny => {
535                    let err = ToolError::Blocked {
536                        command: block.to_owned(),
537                    };
538                    self.log_audit(
539                        block,
540                        AuditResult::Blocked {
541                            reason: "denied by permission policy".to_owned(),
542                        },
543                        0,
544                        Some(&err),
545                        None,
546                        false,
547                    )
548                    .await;
549                    return Err(err);
550                }
551                PermissionAction::Ask if !skip_confirm => {
552                    return Err(ToolError::ConfirmationRequired {
553                        command: block.to_owned(),
554                    });
555                }
556                _ => {}
557            }
558        } else if !skip_confirm && let Some(pattern) = self.find_confirm_command(block) {
559            return Err(ToolError::ConfirmationRequired {
560                command: pattern.to_owned(),
561            });
562        }
563
564        Ok(())
565    }
566
567    fn validate_sandbox(&self, code: &str) -> Result<(), ToolError> {
568        let cwd = std::env::current_dir().unwrap_or_default();
569
570        for token in extract_paths(code) {
571            if has_traversal(&token) {
572                return Err(ToolError::SandboxViolation { path: token });
573            }
574
575            let path = if token.starts_with('/') {
576                PathBuf::from(&token)
577            } else {
578                cwd.join(&token)
579            };
580            let canonical = path
581                .canonicalize()
582                .or_else(|_| std::path::absolute(&path))
583                .unwrap_or(path);
584            if !self
585                .allowed_paths
586                .iter()
587                .any(|allowed| canonical.starts_with(allowed))
588            {
589                return Err(ToolError::SandboxViolation {
590                    path: canonical.display().to_string(),
591                });
592            }
593        }
594        Ok(())
595    }
596
597    /// Scan `code` for commands that match the configured blocklist.
598    ///
599    /// The function normalizes input via [`strip_shell_escapes`] (decoding `$'\xNN'`,
600    /// `$'\NNN'`, backslash escapes, and quote-splitting) and then splits on shell
601    /// metacharacters (`||`, `&&`, `;`, `|`, `\n`) via [`tokenize_commands`].  Each
602    /// resulting token sequence is tested against every entry in `blocked_commands`
603    /// through [`tokens_match_pattern`], which handles transparent prefixes (`env`,
604    /// `command`, `exec`, etc.), absolute paths, and dot-suffixed variants.
605    ///
606    /// # Known limitations
607    ///
608    /// The following constructs are **not** detected by this function:
609    ///
610    /// - **Here-strings** `<<<` with a shell interpreter: the outer command is the
611    ///   shell (`bash`, `sh`), which is not blocked by default; the payload string is
612    ///   opaque to this filter.
613    ///   Example: `bash <<< 'sudo rm -rf /'` — inner payload is not parsed.
614    ///
615    /// - **`eval` and `bash -c` / `sh -c`**: the string argument is not parsed; any
616    ///   blocked command embedded as a string argument passes through undetected.
617    ///   Example: `eval 'sudo rm -rf /'`.
618    ///
619    /// - **Variable expansion**: `strip_shell_escapes` does not resolve variable
620    ///   references, so `cmd=sudo; $cmd rm` bypasses the blocklist.
621    ///
622    /// `$(...)`, backtick, `<(...)`, and `>(...)` substitutions are detected by
623    /// [`extract_subshell_contents`], which extracts the inner command string and
624    /// checks it against the blocklist separately.  The default `confirm_patterns`
625    /// in [`ShellConfig`] additionally include `"$("`, `` "`" ``, `"<("`, `">("`,
626    /// `"<<<"`, and `"eval "`, so those constructs also trigger a confirmation
627    /// request via [`find_confirm_command`] before execution.
628    ///
629    /// For high-security deployments, complement this filter with OS-level sandboxing
630    /// (Linux namespaces, seccomp, or similar) to enforce hard execution boundaries.
631    fn find_blocked_command(&self, code: &str) -> Option<&str> {
632        let cleaned = strip_shell_escapes(&code.to_lowercase());
633        let commands = tokenize_commands(&cleaned);
634        for blocked in &self.blocked_commands {
635            for cmd_tokens in &commands {
636                if tokens_match_pattern(cmd_tokens, blocked) {
637                    return Some(blocked.as_str());
638                }
639            }
640        }
641        // Also check commands embedded inside subshell constructs.
642        for inner in extract_subshell_contents(&cleaned) {
643            let inner_commands = tokenize_commands(&inner);
644            for blocked in &self.blocked_commands {
645                for cmd_tokens in &inner_commands {
646                    if tokens_match_pattern(cmd_tokens, blocked) {
647                        return Some(blocked.as_str());
648                    }
649                }
650            }
651        }
652        None
653    }
654
655    fn find_confirm_command(&self, code: &str) -> Option<&str> {
656        let normalized = code.to_lowercase();
657        for pattern in &self.confirm_patterns {
658            if normalized.contains(pattern.as_str()) {
659                return Some(pattern.as_str());
660            }
661        }
662        None
663    }
664
665    async fn log_audit(
666        &self,
667        command: &str,
668        result: AuditResult,
669        duration_ms: u64,
670        error: Option<&ToolError>,
671        exit_code: Option<i32>,
672        truncated: bool,
673    ) {
674        if let Some(ref logger) = self.audit_logger {
675            let (error_category, error_domain, error_phase) =
676                error.map_or((None, None, None), |e| {
677                    let cat = e.category();
678                    (
679                        Some(cat.label().to_owned()),
680                        Some(cat.domain().label().to_owned()),
681                        Some(cat.phase().label().to_owned()),
682                    )
683                });
684            let entry = AuditEntry {
685                timestamp: chrono_now(),
686                tool: "shell".into(),
687                command: command.into(),
688                result,
689                duration_ms,
690                error_category,
691                error_domain,
692                error_phase,
693                claim_source: Some(ClaimSource::Shell),
694                mcp_server_id: None,
695                injection_flagged: false,
696                embedding_anomalous: false,
697                cross_boundary_mcp_to_acp: false,
698                adversarial_policy_decision: None,
699                exit_code,
700                truncated,
701                caller_id: None,
702                policy_match: None,
703            };
704            logger.log(&entry).await;
705        }
706    }
707}
708
709impl ToolExecutor for ShellExecutor {
710    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
711        self.execute_inner(response, false).await
712    }
713
714    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
715        use crate::registry::{InvocationHint, ToolDef};
716        vec![ToolDef {
717            id: "bash".into(),
718            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(),
719            schema: schemars::schema_for!(BashParams),
720            invocation: InvocationHint::FencedBlock("bash"),
721        }]
722    }
723
724    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
725        if call.tool_id != "bash" {
726            return Ok(None);
727        }
728        let params: BashParams = crate::executor::deserialize_params(&call.params)?;
729        if params.command.is_empty() {
730            return Ok(None);
731        }
732        let command = &params.command;
733        // Wrap as a fenced block so execute_inner can extract and run it
734        let synthetic = format!("```bash\n{command}\n```");
735        self.execute_inner(&synthetic, false).await
736    }
737
738    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
739        ShellExecutor::set_skill_env(self, env);
740    }
741}
742
743/// Strip shell escape sequences that could bypass command detection.
744/// Handles: backslash insertion (`su\do` -> `sudo`), `$'\xNN'` hex and `$'\NNN'` octal
745/// escapes, adjacent quoted segments (`"su""do"` -> `sudo`), backslash-newline continuations.
746pub(crate) fn strip_shell_escapes(input: &str) -> String {
747    let mut out = String::with_capacity(input.len());
748    let bytes = input.as_bytes();
749    let mut i = 0;
750    while i < bytes.len() {
751        // $'...' ANSI-C quoting: decode \xNN hex and \NNN octal escapes
752        if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'\'' {
753            let mut j = i + 2; // points after $'
754            let mut decoded = String::new();
755            let mut valid = false;
756            while j < bytes.len() && bytes[j] != b'\'' {
757                if bytes[j] == b'\\' && j + 1 < bytes.len() {
758                    let next = bytes[j + 1];
759                    if next == b'x' && j + 3 < bytes.len() {
760                        // \xNN hex escape
761                        let hi = (bytes[j + 2] as char).to_digit(16);
762                        let lo = (bytes[j + 3] as char).to_digit(16);
763                        if let (Some(h), Some(l)) = (hi, lo) {
764                            #[allow(clippy::cast_possible_truncation)]
765                            let byte = ((h << 4) | l) as u8;
766                            decoded.push(byte as char);
767                            j += 4;
768                            valid = true;
769                            continue;
770                        }
771                    } else if next.is_ascii_digit() {
772                        // \NNN octal escape (up to 3 digits)
773                        let mut val = u32::from(next - b'0');
774                        let mut len = 2; // consumed \N so far
775                        if j + 2 < bytes.len() && bytes[j + 2].is_ascii_digit() {
776                            val = val * 8 + u32::from(bytes[j + 2] - b'0');
777                            len = 3;
778                            if j + 3 < bytes.len() && bytes[j + 3].is_ascii_digit() {
779                                val = val * 8 + u32::from(bytes[j + 3] - b'0');
780                                len = 4;
781                            }
782                        }
783                        #[allow(clippy::cast_possible_truncation)]
784                        decoded.push((val & 0xFF) as u8 as char);
785                        j += len;
786                        valid = true;
787                        continue;
788                    }
789                    // other \X escape: emit X literally
790                    decoded.push(next as char);
791                    j += 2;
792                } else {
793                    decoded.push(bytes[j] as char);
794                    j += 1;
795                }
796            }
797            if j < bytes.len() && bytes[j] == b'\'' && valid {
798                out.push_str(&decoded);
799                i = j + 1;
800                continue;
801            }
802            // not a decodable $'...' sequence — fall through to handle as regular chars
803        }
804        // backslash-newline continuation: remove both
805        if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
806            i += 2;
807            continue;
808        }
809        // intra-word backslash: skip the backslash, keep next char (e.g. su\do -> sudo)
810        if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] != b'\n' {
811            i += 1;
812            out.push(bytes[i] as char);
813            i += 1;
814            continue;
815        }
816        // quoted segment stripping: collapse adjacent quoted segments
817        if bytes[i] == b'"' || bytes[i] == b'\'' {
818            let quote = bytes[i];
819            i += 1;
820            while i < bytes.len() && bytes[i] != quote {
821                out.push(bytes[i] as char);
822                i += 1;
823            }
824            if i < bytes.len() {
825                i += 1; // skip closing quote
826            }
827            continue;
828        }
829        out.push(bytes[i] as char);
830        i += 1;
831    }
832    out
833}
834
835/// Extract inner command strings from subshell constructs in `s`.
836///
837/// Recognises:
838/// - Backtick: `` `cmd` `` → `cmd`
839/// - Dollar-paren: `$(cmd)` → `cmd`
840/// - Process substitution (lt): `<(cmd)` → `cmd`
841/// - Process substitution (gt): `>(cmd)` → `cmd`
842///
843/// Depth counting handles nested parentheses correctly.
844pub(crate) fn extract_subshell_contents(s: &str) -> Vec<String> {
845    let mut results = Vec::new();
846    let chars: Vec<char> = s.chars().collect();
847    let len = chars.len();
848    let mut i = 0;
849
850    while i < len {
851        // Backtick substitution: `...`
852        if chars[i] == '`' {
853            let start = i + 1;
854            let mut j = start;
855            while j < len && chars[j] != '`' {
856                j += 1;
857            }
858            if j < len {
859                results.push(chars[start..j].iter().collect());
860            }
861            i = j + 1;
862            continue;
863        }
864
865        // $(...), <(...), >(...)
866        let next_is_open_paren = i + 1 < len && chars[i + 1] == '(';
867        let is_paren_subshell = next_is_open_paren && matches!(chars[i], '$' | '<' | '>');
868
869        if is_paren_subshell {
870            let start = i + 2;
871            let mut depth: usize = 1;
872            let mut j = start;
873            while j < len && depth > 0 {
874                match chars[j] {
875                    '(' => depth += 1,
876                    ')' => depth -= 1,
877                    _ => {}
878                }
879                if depth > 0 {
880                    j += 1;
881                } else {
882                    break;
883                }
884            }
885            if depth == 0 {
886                results.push(chars[start..j].iter().collect());
887            }
888            i = j + 1;
889            continue;
890        }
891
892        i += 1;
893    }
894
895    results
896}
897
898/// Split normalized shell code into sub-commands on `|`, `||`, `&&`, `;`, `\n`.
899/// Returns list of sub-commands, each as `Vec<String>` of tokens.
900pub(crate) fn tokenize_commands(normalized: &str) -> Vec<Vec<String>> {
901    // Replace two-char operators with a single separator, then split on single-char separators
902    let replaced = normalized.replace("||", "\n").replace("&&", "\n");
903    replaced
904        .split([';', '|', '\n'])
905        .map(|seg| {
906            seg.split_whitespace()
907                .map(str::to_owned)
908                .collect::<Vec<String>>()
909        })
910        .filter(|tokens| !tokens.is_empty())
911        .collect()
912}
913
914/// Transparent prefix commands that invoke the next argument as a command.
915/// Skipped when determining the "real" command name being invoked.
916const TRANSPARENT_PREFIXES: &[&str] = &["env", "command", "exec", "nice", "nohup", "time", "xargs"];
917
918/// Return the basename of a token (last path component after '/').
919fn cmd_basename(tok: &str) -> &str {
920    tok.rsplit('/').next().unwrap_or(tok)
921}
922
923/// Check if the first tokens of a sub-command match a blocked pattern.
924/// Handles:
925/// - Transparent prefix commands (`env sudo rm` -> checks `sudo`)
926/// - Absolute paths (`/usr/bin/sudo rm` -> basename `sudo` is checked)
927/// - Dot-suffixed variants (`mkfs` matches `mkfs.ext4`)
928/// - Multi-word patterns (`rm -rf /` joined prefix check)
929pub(crate) fn tokens_match_pattern(tokens: &[String], pattern: &str) -> bool {
930    if tokens.is_empty() || pattern.is_empty() {
931        return false;
932    }
933    let pattern = pattern.trim();
934    let pattern_tokens: Vec<&str> = pattern.split_whitespace().collect();
935    if pattern_tokens.is_empty() {
936        return false;
937    }
938
939    // Skip transparent prefix tokens to reach the real command
940    let start = tokens
941        .iter()
942        .position(|t| !TRANSPARENT_PREFIXES.contains(&cmd_basename(t)))
943        .unwrap_or(0);
944    let effective = &tokens[start..];
945    if effective.is_empty() {
946        return false;
947    }
948
949    if pattern_tokens.len() == 1 {
950        let pat = pattern_tokens[0];
951        let base = cmd_basename(&effective[0]);
952        // Exact match OR dot-suffixed variant (e.g. "mkfs" matches "mkfs.ext4")
953        base == pat || base.starts_with(&format!("{pat}."))
954    } else {
955        // Multi-word: join first N tokens (using basename for first) and check prefix
956        let n = pattern_tokens.len().min(effective.len());
957        let mut parts: Vec<&str> = vec![cmd_basename(&effective[0])];
958        parts.extend(effective[1..n].iter().map(String::as_str));
959        let joined = parts.join(" ");
960        if joined.starts_with(pattern) {
961            return true;
962        }
963        if effective.len() > n {
964            let mut parts2: Vec<&str> = vec![cmd_basename(&effective[0])];
965            parts2.extend(effective[1..=n].iter().map(String::as_str));
966            parts2.join(" ").starts_with(pattern)
967        } else {
968            false
969        }
970    }
971}
972
973fn extract_paths(code: &str) -> Vec<String> {
974    let mut result = Vec::new();
975
976    // Tokenize respecting single/double quotes
977    let mut tokens: Vec<String> = Vec::new();
978    let mut current = String::new();
979    let mut chars = code.chars().peekable();
980    while let Some(c) = chars.next() {
981        match c {
982            '"' | '\'' => {
983                let quote = c;
984                while let Some(&nc) = chars.peek() {
985                    if nc == quote {
986                        chars.next();
987                        break;
988                    }
989                    current.push(chars.next().unwrap());
990                }
991            }
992            c if c.is_whitespace() || matches!(c, ';' | '|' | '&') => {
993                if !current.is_empty() {
994                    tokens.push(std::mem::take(&mut current));
995                }
996            }
997            _ => current.push(c),
998        }
999    }
1000    if !current.is_empty() {
1001        tokens.push(current);
1002    }
1003
1004    for token in tokens {
1005        let trimmed = token.trim_end_matches([';', '&', '|']).to_owned();
1006        if trimmed.is_empty() {
1007            continue;
1008        }
1009        if trimmed.starts_with('/')
1010            || trimmed.starts_with("./")
1011            || trimmed.starts_with("../")
1012            || trimmed == ".."
1013            || (trimmed.starts_with('.') && trimmed.contains('/'))
1014            || is_relative_path_token(&trimmed)
1015        {
1016            result.push(trimmed);
1017        }
1018    }
1019    result
1020}
1021
1022/// Returns `true` if `token` looks like a relative path of the form `word/more`
1023/// (contains `/` but does not start with `/` or `.`).
1024///
1025/// Excluded:
1026/// - URL schemes (`scheme://`)
1027/// - Shell variable assignments (`KEY=value`)
1028fn is_relative_path_token(token: &str) -> bool {
1029    // Must contain a slash but not start with `/` (absolute) or `.` (handled above).
1030    if !token.contains('/') || token.starts_with('/') || token.starts_with('.') {
1031        return false;
1032    }
1033    // Reject URLs: anything with `://`
1034    if token.contains("://") {
1035        return false;
1036    }
1037    // Reject shell variable assignments: `IDENTIFIER=...`
1038    if let Some(eq_pos) = token.find('=') {
1039        let key = &token[..eq_pos];
1040        if key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1041            return false;
1042        }
1043    }
1044    // First character must be an identifier-start (letter, digit, or `_`).
1045    token
1046        .chars()
1047        .next()
1048        .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
1049}
1050
1051/// Classify shell exit codes and stderr patterns into `ToolErrorCategory`.
1052///
1053/// Returns `Some(category)` only for well-known failure modes that benefit from
1054/// structured feedback (exit 126/127, recognisable stderr patterns). All other
1055/// non-zero exits are left as `Ok` output so they surface verbatim to the LLM.
1056fn classify_shell_exit(
1057    exit_code: i32,
1058    output: &str,
1059) -> Option<crate::error_taxonomy::ToolErrorCategory> {
1060    use crate::error_taxonomy::ToolErrorCategory;
1061    match exit_code {
1062        // exit 126: command found but not executable (OS-level permission/policy)
1063        126 => Some(ToolErrorCategory::PolicyBlocked),
1064        // exit 127: command not found in PATH
1065        127 => Some(ToolErrorCategory::PermanentFailure),
1066        _ => {
1067            let lower = output.to_lowercase();
1068            if lower.contains("permission denied") {
1069                Some(ToolErrorCategory::PolicyBlocked)
1070            } else if lower.contains("no such file or directory") {
1071                Some(ToolErrorCategory::PermanentFailure)
1072            } else {
1073                None
1074            }
1075        }
1076    }
1077}
1078
1079fn has_traversal(path: &str) -> bool {
1080    path.split('/').any(|seg| seg == "..")
1081}
1082
1083fn extract_bash_blocks(text: &str) -> Vec<&str> {
1084    crate::executor::extract_fenced_blocks(text, "bash")
1085}
1086
1087/// Kill a child process and its descendants.
1088/// On unix, sends SIGKILL to child processes via `pkill -KILL -P <pid>` before
1089/// killing the parent, preventing zombie subprocesses.
1090async fn kill_process_tree(child: &mut tokio::process::Child) {
1091    #[cfg(unix)]
1092    if let Some(pid) = child.id() {
1093        let _ = Command::new("pkill")
1094            .args(["-KILL", "-P", &pid.to_string()])
1095            .status()
1096            .await;
1097    }
1098    let _ = child.kill().await;
1099}
1100
1101/// Structured output from a shell command execution.
1102#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1103pub struct ShellOutputEnvelope {
1104    pub stdout: String,
1105    pub stderr: String,
1106    pub exit_code: i32,
1107    pub truncated: bool,
1108}
1109
1110#[allow(clippy::too_many_lines)]
1111async fn execute_bash(
1112    code: &str,
1113    timeout: Duration,
1114    event_tx: Option<&ToolEventTx>,
1115    cancel_token: Option<&CancellationToken>,
1116    extra_env: Option<&std::collections::HashMap<String, String>>,
1117    env_blocklist: &[String],
1118) -> (ShellOutputEnvelope, String) {
1119    use std::process::Stdio;
1120    use tokio::io::{AsyncBufReadExt, BufReader};
1121
1122    let timeout_secs = timeout.as_secs();
1123
1124    let mut cmd = Command::new("bash");
1125    cmd.arg("-c")
1126        .arg(code)
1127        .stdout(Stdio::piped())
1128        .stderr(Stdio::piped());
1129
1130    for (key, _) in std::env::vars() {
1131        if env_blocklist
1132            .iter()
1133            .any(|prefix| key.starts_with(prefix.as_str()))
1134        {
1135            cmd.env_remove(&key);
1136        }
1137    }
1138
1139    if let Some(env) = extra_env {
1140        cmd.envs(env);
1141    }
1142    let child_result = cmd.spawn();
1143
1144    let mut child = match child_result {
1145        Ok(c) => c,
1146        Err(e) => {
1147            let msg = format!("[error] {e}");
1148            return (
1149                ShellOutputEnvelope {
1150                    stdout: String::new(),
1151                    stderr: msg.clone(),
1152                    exit_code: 1,
1153                    truncated: false,
1154                },
1155                msg,
1156            );
1157        }
1158    };
1159
1160    let stdout = child.stdout.take().expect("stdout piped");
1161    let stderr = child.stderr.take().expect("stderr piped");
1162
1163    // Channel carries (is_stderr, line) so we can accumulate separate buffers
1164    // while still building a combined interleaved string for streaming and LLM context.
1165    let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::<(bool, String)>(64);
1166
1167    let stdout_tx = line_tx.clone();
1168    tokio::spawn(async move {
1169        let mut reader = BufReader::new(stdout);
1170        let mut buf = String::new();
1171        while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
1172            let _ = stdout_tx.send((false, buf.clone())).await;
1173            buf.clear();
1174        }
1175    });
1176
1177    tokio::spawn(async move {
1178        let mut reader = BufReader::new(stderr);
1179        let mut buf = String::new();
1180        while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
1181            let _ = line_tx.send((true, buf.clone())).await;
1182            buf.clear();
1183        }
1184    });
1185
1186    let mut combined = String::new();
1187    let mut stdout_buf = String::new();
1188    let mut stderr_buf = String::new();
1189    let deadline = tokio::time::Instant::now() + timeout;
1190
1191    loop {
1192        tokio::select! {
1193            line = line_rx.recv() => {
1194                match line {
1195                    Some((is_stderr, chunk)) => {
1196                        let interleaved = if is_stderr {
1197                            format!("[stderr] {chunk}")
1198                        } else {
1199                            chunk.clone()
1200                        };
1201                        if let Some(tx) = event_tx {
1202                            let _ = tx.send(ToolEvent::OutputChunk {
1203                                tool_name: "bash".to_owned(),
1204                                command: code.to_owned(),
1205                                chunk: interleaved.clone(),
1206                            });
1207                        }
1208                        combined.push_str(&interleaved);
1209                        if is_stderr {
1210                            stderr_buf.push_str(&chunk);
1211                        } else {
1212                            stdout_buf.push_str(&chunk);
1213                        }
1214                    }
1215                    None => break,
1216                }
1217            }
1218            () = tokio::time::sleep_until(deadline) => {
1219                kill_process_tree(&mut child).await;
1220                let msg = format!("[error] command timed out after {timeout_secs}s");
1221                return (
1222                    ShellOutputEnvelope {
1223                        stdout: stdout_buf,
1224                        stderr: format!("{stderr_buf}command timed out after {timeout_secs}s"),
1225                        exit_code: 1,
1226                        truncated: false,
1227                    },
1228                    msg,
1229                );
1230            }
1231            () = async {
1232                match cancel_token {
1233                    Some(t) => t.cancelled().await,
1234                    None => std::future::pending().await,
1235                }
1236            } => {
1237                kill_process_tree(&mut child).await;
1238                return (
1239                    ShellOutputEnvelope {
1240                        stdout: stdout_buf,
1241                        stderr: format!("{stderr_buf}operation aborted"),
1242                        exit_code: 130,
1243                        truncated: false,
1244                    },
1245                    "[cancelled] operation aborted".to_string(),
1246                );
1247            }
1248        }
1249    }
1250
1251    let status = child.wait().await;
1252    let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(1);
1253
1254    let (envelope, combined) = if combined.is_empty() {
1255        (
1256            ShellOutputEnvelope {
1257                stdout: String::new(),
1258                stderr: String::new(),
1259                exit_code,
1260                truncated: false,
1261            },
1262            "(no output)".to_string(),
1263        )
1264    } else {
1265        (
1266            ShellOutputEnvelope {
1267                stdout: stdout_buf.trim_end().to_owned(),
1268                stderr: stderr_buf.trim_end().to_owned(),
1269                exit_code,
1270                truncated: false,
1271            },
1272            combined,
1273        )
1274    };
1275    (envelope, combined)
1276}
1277
1278#[cfg(test)]
1279mod tests;