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