Skip to main content

imp_core/tools/
bash.rs

1use std::process::Stdio;
2use std::sync::OnceLock;
3
4use async_trait::async_trait;
5use regex::Regex;
6use serde_json::{json, Value};
7use tokio::io::{AsyncBufReadExt, BufReader};
8use tokio::process::Command;
9
10use imp_llm::auth::{AuthStore, StoredCredential};
11
12use super::{
13    truncate_head, truncate_tail, Tool, ToolContext, ToolOutput, ToolUpdate, TruncationResult,
14};
15use crate::error::{Error, Result};
16
17const DEFAULT_TIMEOUT_SECS: u64 = 30;
18const MAX_OUTPUT_LINES: usize = 2000;
19const MAX_OUTPUT_BYTES: usize = 50 * 1024;
20
21const SECRET_REDACTION: &str = "[REDACTED_SECRET]";
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24struct SecretEnvBinding {
25    env: String,
26    provider: String,
27    field: String,
28}
29
30struct ResolvedSecretEnvBinding {
31    binding: SecretEnvBinding,
32    value: String,
33}
34
35fn parse_secret_env_bindings(value: Option<&Value>) -> Result<Vec<SecretEnvBinding>> {
36    let Some(value) = value else {
37        return Ok(Vec::new());
38    };
39    if value.is_null() {
40        return Ok(Vec::new());
41    }
42    let entries = value
43        .as_array()
44        .ok_or_else(|| Error::Tool("secret_env must be an array".into()))?;
45    let mut bindings = Vec::with_capacity(entries.len());
46    let mut env_names = std::collections::HashSet::new();
47
48    for entry in entries {
49        let object = entry
50            .as_object()
51            .ok_or_else(|| Error::Tool("secret_env entries must be objects".into()))?;
52        let env = required_secret_binding_string(object, "env")?;
53        let provider = required_secret_binding_string(object, "provider")?;
54        let field = required_secret_binding_string(object, "field")?;
55        if !is_valid_env_name(&env) {
56            return Err(Error::Tool(format!(
57                "secret_env env name '{env}' is invalid; use uppercase letters, digits, and underscores, not starting with a digit"
58            )));
59        }
60        if !env_names.insert(env.clone()) {
61            return Err(Error::Tool(format!(
62                "duplicate secret_env binding for env var {env}"
63            )));
64        }
65        bindings.push(SecretEnvBinding {
66            env,
67            provider,
68            field,
69        });
70    }
71
72    Ok(bindings)
73}
74
75fn required_secret_binding_string(
76    object: &serde_json::Map<String, Value>,
77    key: &str,
78) -> Result<String> {
79    object
80        .get(key)
81        .and_then(Value::as_str)
82        .map(str::trim)
83        .filter(|value| !value.is_empty())
84        .map(ToOwned::to_owned)
85        .ok_or_else(|| Error::Tool(format!("secret_env entries require non-empty {key}")))
86}
87
88fn is_valid_env_name(value: &str) -> bool {
89    let mut chars = value.chars();
90    let Some(first) = chars.next() else {
91        return false;
92    };
93    (first == '_' || first.is_ascii_uppercase())
94        && chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
95}
96
97fn binding_descriptor(binding: &SecretEnvBinding) -> String {
98    format!("{}<-{}.{}", binding.env, binding.provider, binding.field)
99}
100
101fn command_secret_binding_allowed(ctx: &ToolContext, binding: &SecretEnvBinding) -> bool {
102    let policy = &ctx.config.secrets.commands;
103    policy.enabled
104        && policy.allowed.iter().any(|allowed| {
105            allowed.env == binding.env
106                && allowed.provider == binding.provider
107                && allowed.field == binding.field
108        })
109}
110
111fn resolve_secret_env_bindings(
112    ctx: &ToolContext,
113    bindings: Vec<SecretEnvBinding>,
114) -> Result<Vec<ResolvedSecretEnvBinding>> {
115    if bindings.is_empty() {
116        return Ok(Vec::new());
117    }
118
119    for binding in &bindings {
120        if !command_secret_binding_allowed(ctx, binding) {
121            return Err(Error::Tool(format!(
122                "secret_env binding {} is not allowed by config policy",
123                binding_descriptor(binding)
124            )));
125        }
126    }
127
128    let auth_path = crate::storage::existing_global_auth_path()
129        .unwrap_or_else(crate::storage::global_auth_path);
130    let auth_store = AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path));
131
132    bindings
133        .into_iter()
134        .map(|binding| {
135            let descriptor = binding_descriptor(&binding);
136            let has_auth_metadata = matches!(
137                auth_store.stored.get(&binding.provider),
138                Some(StoredCredential::SecretFields { fields })
139                    if fields.iter().any(|field| field == &binding.field)
140            );
141            auth_store
142                .resolve_secret_field(&binding.provider, &binding.field)
143                .map(|value| ResolvedSecretEnvBinding { binding, value })
144                .map_err(|error| {
145                    if has_auth_metadata {
146                        Error::Tool(format!(
147                            "missing keychain value for {descriptor}; auth metadata exists but secure storage read failed: {error}"
148                        ))
149                    } else {
150                        Error::Tool(format!("missing secret for {descriptor}: {error}"))
151                    }
152                })
153        })
154        .collect()
155}
156
157fn redact_injected_secrets(text: &str, resolved: &[ResolvedSecretEnvBinding]) -> String {
158    resolved.iter().fold(text.to_string(), |redacted, binding| {
159        if binding.value.is_empty() {
160            redacted
161        } else {
162            redacted.replace(&binding.value, SECRET_REDACTION)
163        }
164    })
165}
166
167fn secret_binding_details(resolved: &[ResolvedSecretEnvBinding]) -> Vec<String> {
168    resolved
169        .iter()
170        .map(|binding| binding_descriptor(&binding.binding))
171        .collect()
172}
173
174/// Check whether the rush backend should be used.
175///
176/// Returns true when the `rush-backend` feature is compiled in AND the env var
177/// `IMP_SHELL_BACKEND` is either unset or set to `"rush"`. Setting
178/// `IMP_SHELL_BACKEND=sh` forces the traditional `sh -c` path even when rush
179/// is available.
180#[cfg(feature = "rush-backend")]
181fn use_rush_backend() -> bool {
182    match std::env::var("IMP_SHELL_BACKEND") {
183        Ok(val) => val.eq_ignore_ascii_case("rush"),
184        // Feature compiled in → rush is the default.
185        Err(_) => true,
186    }
187}
188
189/// Execute a command via rush's in-process library API. Returns `None` if rush
190/// fails so the caller can fall back to `sh`.
191#[cfg(feature = "rush-backend")]
192fn run_via_rush(
193    command: &str,
194    timeout_secs: u64,
195    cwd: &std::path::Path,
196    json_output: bool,
197) -> Option<(String, i32, bool, bool)> {
198    let result = rush::run(
199        command,
200        &rush::RunOptions {
201            cwd: Some(cwd.to_path_buf()),
202            timeout: Some(timeout_secs),
203            json_output,
204            max_output_bytes: Some(MAX_OUTPUT_BYTES),
205            ..Default::default()
206        },
207    );
208
209    match result {
210        Ok(r) => {
211            let mut output = r.stdout;
212            if !r.stderr.is_empty() {
213                if !output.is_empty() && !output.ends_with('\n') {
214                    output.push('\n');
215                }
216                output.push_str(&r.stderr);
217            }
218            Some((output, r.exit_code, r.timed_out, r.truncated))
219        }
220        Err(_) => None,
221    }
222}
223
224/// Detect which shell to use for command execution.
225/// Defaults to bash, with IMP_SHELL and config.shell.command as overrides.
226fn detect_shell(config: &crate::config::ShellConfig) -> String {
227    // IMP_SHELL overrides everything (also used by tests to force sh)
228    if let Ok(shell) = std::env::var("IMP_SHELL") {
229        return shell;
230    }
231
232    if let Some(shell) = config
233        .command
234        .as_deref()
235        .map(str::trim)
236        .filter(|s| !s.is_empty())
237    {
238        return shell.to_string();
239    }
240
241    "bash".to_string()
242}
243
244fn sanitize_output_text(text: &str) -> String {
245    static ANSI_RE: OnceLock<Regex> = OnceLock::new();
246    let re =
247        ANSI_RE.get_or_init(|| Regex::new(r"\x1B\[[0-9;?]*[ -/]*[@-~]").expect("valid ansi regex"));
248    re.replace_all(text, "").replace('\r', "")
249}
250
251fn looks_like_search_command(command: &str) -> bool {
252    let trimmed = command.trim_start();
253    trimmed.starts_with("rg ")
254        || trimmed == "rg"
255        || trimmed.starts_with("grep ")
256        || trimmed.starts_with("grep\n")
257        || trimmed.starts_with("fd ")
258        || trimmed == "fd"
259        || trimmed.starts_with("find ")
260        || trimmed == "find"
261        || trimmed.starts_with("ls ")
262        || trimmed == "ls"
263}
264
265fn no_match_exit_is_success(command: &str, exit_code: i32, output: &str) -> bool {
266    if exit_code != 1 || !output.trim().is_empty() {
267        return false;
268    }
269
270    let trimmed = command.trim_start();
271    trimmed.starts_with("rg ")
272        || trimmed == "rg"
273        || trimmed.starts_with("grep ")
274        || trimmed.starts_with("grep\n")
275}
276
277fn command_failure_hint(command: &str, exit_code: i32, output: &str) -> Option<String> {
278    if no_match_exit_is_success(command, exit_code, output) {
279        return Some("No matches found.".to_string());
280    }
281
282    if exit_code == 127 {
283        return Some(
284            "Command not found. Check the executable name or use an installed alternative."
285                .to_string(),
286        );
287    }
288
289    None
290}
291
292#[cfg(feature = "rush-backend")]
293fn should_try_rush_json(command: &str) -> bool {
294    if command.contains("|")
295        || command.contains("&&")
296        || command.contains("||")
297        || command.contains(';')
298        || command.contains('>')
299        || command.contains('<')
300    {
301        return false;
302    }
303
304    looks_like_search_command(command)
305}
306
307#[cfg(feature = "rush-backend")]
308fn parse_json_lines_to_text(command: &str, output: &str) -> Option<String> {
309    let value: serde_json::Value = serde_json::from_str(output).ok()?;
310    let items = value.as_array()?;
311
312    let mut lines = Vec::new();
313    let is_grep = command.trim_start().starts_with("grep");
314    let is_find = command.trim_start().starts_with("find");
315    let is_ls = command.trim_start().starts_with("ls");
316
317    for item in items {
318        if is_grep {
319            let file = item.get("file").and_then(|v| v.as_str()).unwrap_or("");
320            let line = item
321                .get("line_number")
322                .and_then(|v| v.as_u64())
323                .unwrap_or(0);
324            let full_line = item
325                .get("full_line")
326                .and_then(|v| v.as_str())
327                .unwrap_or("")
328                .trim_end_matches('\n');
329            if !file.is_empty() && line > 0 {
330                lines.push(format!("{file}:{line}:{full_line}"));
331            }
332        } else if is_find {
333            if let Some(path) = item.get("path").and_then(|v| v.as_str()) {
334                lines.push(path.to_string());
335            }
336        } else if is_ls {
337            if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
338                let suffix = match item.get("type").and_then(|v| v.as_str()) {
339                    Some("directory") => "/",
340                    Some("symlink") => "@",
341                    _ => "",
342                };
343                lines.push(format!("{name}{suffix}"));
344            }
345        }
346    }
347
348    if lines.is_empty() {
349        None
350    } else {
351        Some(lines.join("\n"))
352    }
353}
354
355fn truncate_command_output(command: &str, output: &str) -> TruncationResult {
356    if looks_like_search_command(command) {
357        truncate_head(output, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES)
358    } else {
359        truncate_tail(output, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES)
360    }
361}
362
363pub struct BashTool;
364
365impl BashTool {
366    pub fn canonical() -> Self {
367        Self
368    }
369}
370
371#[async_trait]
372impl Tool for BashTool {
373    fn name(&self) -> &str {
374        "shell"
375    }
376    fn label(&self) -> &str {
377        "Shell"
378    }
379    fn description(&self) -> &str {
380        "Run a shell command in the workspace or an optional workdir."
381    }
382    fn parameters(&self) -> serde_json::Value {
383        json!({
384            "type": "object",
385            "properties": {
386                "command": { "type": "string" },
387                "timeout": { "type": "number" },
388                "workdir": { "type": "string" },
389                "secret_env": {
390                    "type": "array",
391                    "description": "Metadata-only stored secret bindings to inject into the child environment. Raw secret values are resolved internally and redacted from output.",
392                    "items": {
393                        "type": "object",
394                        "properties": {
395                            "env": { "type": "string" },
396                            "provider": { "type": "string" },
397                            "field": { "type": "string" }
398                        },
399                        "required": ["env", "provider", "field"]
400                    }
401                }
402            },
403            "required": ["command"]
404        })
405    }
406    fn is_readonly(&self) -> bool {
407        false
408    }
409
410    async fn execute(
411        &self,
412        _call_id: &str,
413        params: serde_json::Value,
414        ctx: ToolContext,
415    ) -> Result<ToolOutput> {
416        let command = params["command"]
417            .as_str()
418            .ok_or_else(|| crate::error::Error::Tool("missing 'command' parameter".into()))?;
419
420        let timeout_secs = params["timeout"].as_u64().unwrap_or(DEFAULT_TIMEOUT_SECS);
421
422        // Support per-command workdir override
423        let ctx = if let Some(workdir) = params["workdir"].as_str() {
424            let wd = super::resolve_path(&ctx.cwd, workdir);
425            if !wd.is_dir() {
426                return Ok(ToolOutput::error(format!(
427                    "workdir not found or not a directory: {}",
428                    wd.display()
429                )));
430            }
431            ToolContext { cwd: wd, ..ctx }
432        } else {
433            ctx
434        };
435
436        let secret_env = parse_secret_env_bindings(params.get("secret_env"))?;
437
438        run_command(command, timeout_secs, &ctx, secret_env).await
439    }
440}
441
442async fn run_command(
443    command: &str,
444    timeout_secs: u64,
445    ctx: &ToolContext,
446    secret_env: Vec<SecretEnvBinding>,
447) -> Result<ToolOutput> {
448    // Check cancellation before spawning.
449    if ctx.is_cancelled() {
450        return Ok(ToolOutput {
451            content: vec![imp_llm::ContentBlock::Text {
452                text: "[Command cancelled]".to_string(),
453            }],
454            details: json!({ "exit_code": -1, "timed_out": false, "cancelled": true, "truncated": false }),
455            is_error: true,
456        });
457    }
458
459    let resolved_secret_env = resolve_secret_env_bindings(ctx, secret_env)?;
460
461    // Try the rush in-process backend when available.
462    #[cfg(feature = "rush-backend")]
463    if use_rush_backend() {
464        let rush_json = should_try_rush_json(command);
465        if let Some((output, exit_code, timed_out, truncated)) =
466            run_via_rush(command, timeout_secs, &ctx.cwd, rush_json)
467        {
468            let transformed = if rush_json {
469                parse_json_lines_to_text(command, &output).unwrap_or(output)
470            } else {
471                output
472            };
473            let sanitized = sanitize_output_text(&transformed);
474            let redacted = redact_injected_secrets(&sanitized, &resolved_secret_env);
475            // Stream the output lines so callers see incremental progress.
476            for line in redacted.lines() {
477                let _ = ctx
478                    .update_tx
479                    .send(ToolUpdate {
480                        content: vec![imp_llm::ContentBlock::Text {
481                            text: line.to_string(),
482                        }],
483                        details: serde_json::Value::Null,
484                    })
485                    .await;
486            }
487
488            let mut result_text = redacted;
489            if let Some(hint) = command_failure_hint(command, exit_code, &result_text) {
490                if !result_text.is_empty() {
491                    result_text.push('\n');
492                }
493                result_text.push_str(&format!("[{hint}]"));
494            }
495            if timed_out {
496                result_text.push_str(&format!("\n[Command timed out after {timeout_secs}s]"));
497            }
498
499            return Ok(ToolOutput {
500                content: vec![imp_llm::ContentBlock::Text { text: result_text }],
501                details: json!({
502                    "exit_code": exit_code,
503                    "timed_out": timed_out,
504                    "cancelled": false,
505                    "truncated": truncated,
506                    "backend": "rush",
507                    "injected_secrets": secret_binding_details(&resolved_secret_env),
508                }),
509                is_error: timed_out
510                    || (exit_code != 0
511                        && !no_match_exit_is_success(command, exit_code, &transformed)),
512            });
513        }
514        // rush failed — fall through to sh.
515    }
516
517    let mut child = {
518        // Use configured shell for standard command execution.
519        let shell = detect_shell(&ctx.config.shell);
520        let mut cmd = Command::new(&shell);
521        cmd.arg("-c")
522            .arg(command)
523            .current_dir(&ctx.cwd)
524            // Tool commands are non-interactive. Keep stdin disconnected so
525            // subprocesses cannot consume raw terminal input (for example SGR
526            // mouse reporting sequences) from the interactive TUI.
527            .stdin(Stdio::null())
528            .stdout(Stdio::piped())
529            .stderr(Stdio::piped());
530
531        for binding in &resolved_secret_env {
532            cmd.env(&binding.binding.env, &binding.value);
533        }
534
535        // Create a new process group so we can kill the entire tree.
536        #[cfg(unix)]
537        unsafe {
538            cmd.pre_exec(|| {
539                libc::setsid();
540                Ok(())
541            });
542        }
543
544        cmd.spawn()
545            .map_err(|e| crate::error::Error::Tool(format!("failed to spawn command: {e}")))?
546    };
547
548    let stdout = child.stdout.take().ok_or_else(|| {
549        crate::error::Error::Tool(
550            "failed to capture child stdout despite stdout being piped".to_string(),
551        )
552    })?;
553    let stderr = child.stderr.take().ok_or_else(|| {
554        crate::error::Error::Tool(
555            "failed to capture child stderr despite stderr being piped".to_string(),
556        )
557    })?;
558
559    // Merge stdout and stderr into a single stream.
560    let mut stdout_reader = BufReader::new(stdout).lines();
561    let mut stderr_reader = BufReader::new(stderr).lines();
562
563    let mut output = String::new();
564    let mut timed_out = false;
565    let mut stdout_done = false;
566    let mut stderr_done = false;
567
568    let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
569
570    while !stdout_done || !stderr_done {
571        tokio::select! {
572            biased;
573
574            _ = tokio::time::sleep_until(deadline) => {
575                timed_out = true;
576                kill_process_group(&child).await;
577                break;
578            }
579
580            _ = wait_for_cancellation(&ctx.cancelled), if !ctx.is_cancelled() => {
581                kill_process_group(&child).await;
582                break;
583            }
584
585            line = stdout_reader.next_line(), if !stdout_done => {
586                match line {
587                    Ok(Some(line)) => {
588                        if !line.bytes().any(|b| b == 0) {
589                            let clean = sanitize_output_text(&line);
590                            let clean = redact_injected_secrets(&clean, &resolved_secret_env);
591                            if !clean.is_empty() {
592                                append_line(&mut output, &clean, &ctx.update_tx).await;
593                            }
594                        }
595                    }
596                    _ => { stdout_done = true; }
597                }
598            }
599
600            line = stderr_reader.next_line(), if !stderr_done => {
601                match line {
602                    Ok(Some(line)) => {
603                        if !line.bytes().any(|b| b == 0) {
604                            let clean = sanitize_output_text(&line);
605                            let clean = redact_injected_secrets(&clean, &resolved_secret_env);
606                            if !clean.is_empty() {
607                                append_line(&mut output, &clean, &ctx.update_tx).await;
608                            }
609                        }
610                    }
611                    _ => { stderr_done = true; }
612                }
613            }
614        }
615    }
616
617    // Wait for child with a timeout — don't hang if process won't exit
618    let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
619        .await
620        .ok()
621        .and_then(|r| r.ok());
622    let exit_code = status.and_then(|s| s.code()).unwrap_or(-1);
623
624    // Truncate from the tail (end matters more for command output).
625    let TruncationResult {
626        content: truncated_output,
627        truncated,
628        output_lines,
629        total_lines,
630        temp_file,
631        ..
632    } = truncate_command_output(command, &output);
633
634    let mut result_text = truncated_output;
635
636    if truncated {
637        let note = if looks_like_search_command(command) {
638            format!(
639                "\n[Output truncated: showing first {output_lines} of {total_lines} lines{}]",
640                temp_file
641                    .as_ref()
642                    .map(|p| format!(". Full output saved to {}", p.display()))
643                    .unwrap_or_default()
644            )
645        } else {
646            format!(
647                "\n[Output truncated: showing last {output_lines} of {total_lines} lines{}]",
648                temp_file
649                    .as_ref()
650                    .map(|p| format!(". Full output saved to {}", p.display()))
651                    .unwrap_or_default()
652            )
653        };
654        result_text.push_str(&note);
655    }
656
657    if timed_out {
658        result_text.push_str(&format!("\n[Command timed out after {timeout_secs}s]"));
659    }
660
661    if let Some(hint) = command_failure_hint(command, exit_code, &output) {
662        if !result_text.is_empty() {
663            result_text.push('\n');
664        }
665        result_text.push_str(&format!("[{hint}]"));
666    }
667
668    let cancelled = ctx.is_cancelled();
669    let details = json!({
670        "exit_code": exit_code,
671        "timed_out": timed_out,
672        "cancelled": cancelled,
673        "truncated": truncated,
674        "command": command,
675        "injected_secrets": secret_binding_details(&resolved_secret_env),
676    });
677
678    Ok(ToolOutput {
679        content: vec![imp_llm::ContentBlock::Text { text: result_text }],
680        details,
681        is_error: cancelled
682            || timed_out
683            || (exit_code != 0 && !no_match_exit_is_success(command, exit_code, &output)),
684    })
685}
686
687async fn wait_for_cancellation(cancelled: &std::sync::atomic::AtomicBool) {
688    while !cancelled.load(std::sync::atomic::Ordering::Relaxed) {
689        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
690    }
691}
692
693async fn append_line(
694    output: &mut String,
695    line: &str,
696    update_tx: &tokio::sync::mpsc::Sender<ToolUpdate>,
697) {
698    if !output.is_empty() {
699        output.push('\n');
700    }
701    output.push_str(line);
702    let _ = update_tx
703        .send(ToolUpdate {
704            content: vec![imp_llm::ContentBlock::Text {
705                text: line.to_string(),
706            }],
707            details: serde_json::Value::Null,
708        })
709        .await;
710}
711
712/// Kill the entire process group. Sends SIGTERM, waits briefly, then SIGKILL.
713#[cfg(unix)]
714async fn kill_process_group(child: &tokio::process::Child) {
715    if let Some(pid) = child.id() {
716        let pgid = pid as i32;
717
718        // SIGTERM the group
719        unsafe {
720            libc::kill(-pgid, libc::SIGTERM);
721        }
722
723        // Brief wait, then force-kill
724        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
725
726        unsafe {
727            libc::kill(-pgid, libc::SIGKILL);
728        }
729    }
730}
731
732#[cfg(not(unix))]
733async fn kill_process_group(_child: &tokio::process::Child) {
734    // Best-effort on non-Unix — nothing we can do portably.
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use crate::config::{CommandSecretsConfig, Config, SecretEnvBindingPolicy, SecretsConfig};
741    use crate::ui::NullInterface;
742    use std::sync::atomic::AtomicBool;
743    use std::sync::Arc;
744
745    // Tests use sh for deterministic behavior (rush has exit code bugs: rush#8)
746    fn ensure_sh() {
747        std::env::set_var("IMP_SHELL", "sh");
748    }
749
750    fn test_ctx(dir: &std::path::Path) -> (ToolContext, tokio::sync::mpsc::Receiver<ToolUpdate>) {
751        ensure_sh();
752        let (tx, rx) = tokio::sync::mpsc::channel(1024);
753        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
754        let ctx = ToolContext {
755            cwd: dir.to_path_buf(),
756            cancelled: Arc::new(AtomicBool::new(false)),
757            update_tx: tx,
758            command_tx: cmd_tx,
759            ui: Arc::new(NullInterface),
760            file_cache: Arc::new(crate::tools::FileCache::new()),
761            checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
762            file_tracker: Arc::new(std::sync::Mutex::new(crate::tools::FileTracker::new())),
763            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
764            lua_tool_loader: None,
765            mode: crate::config::AgentMode::Full,
766            read_max_lines: 500,
767            turn_mana_review: Arc::new(std::sync::Mutex::new(
768                crate::mana_review::TurnManaReviewAccumulator::default(),
769            )),
770            config: Arc::new(crate::config::Config::default()),
771        };
772        (ctx, rx)
773    }
774
775    fn allow_test_secret(ctx: &mut ToolContext) {
776        ctx.config = Arc::new(Config {
777            secrets: SecretsConfig {
778                commands: CommandSecretsConfig {
779                    enabled: true,
780                    allowed: vec![SecretEnvBindingPolicy {
781                        provider: "test-service".to_string(),
782                        field: "api_key".to_string(),
783                        env: "SECRET_TOKEN".to_string(),
784                    }],
785                },
786            },
787            ..Config::default()
788        });
789    }
790
791    #[tokio::test]
792    async fn secret_env_injects_allowed_secret_and_redacts_output() {
793        let tmp = tempfile::tempdir().unwrap();
794        std::env::set_var("TEST_SERVICE_API_KEY", "native-secret-value");
795        let (mut ctx, _rx) = test_ctx(tmp.path());
796        allow_test_secret(&mut ctx);
797
798        let result = run_command(
799            "printf '%s' \"$SECRET_TOKEN\"",
800            DEFAULT_TIMEOUT_SECS,
801            &ctx,
802            vec![SecretEnvBinding {
803                env: "SECRET_TOKEN".to_string(),
804                provider: "test-service".to_string(),
805                field: "api_key".to_string(),
806            }],
807        )
808        .await
809        .unwrap();
810
811        assert!(!result.is_error);
812        let text = result.text_content().unwrap();
813        assert!(text.contains(SECRET_REDACTION));
814        assert!(!text.contains("native-secret-value"));
815        assert_eq!(
816            result.details["injected_secrets"][0].as_str(),
817            Some("SECRET_TOKEN<-test-service.api_key")
818        );
819        assert!(!result.details.to_string().contains("native-secret-value"));
820    }
821
822    #[tokio::test]
823    async fn secret_env_denies_unconfigured_binding() {
824        let tmp = tempfile::tempdir().unwrap();
825        std::env::set_var("TEST_SERVICE_API_KEY", "native-secret-value");
826        let (ctx, _rx) = test_ctx(tmp.path());
827
828        let err = match run_command(
829            "true",
830            DEFAULT_TIMEOUT_SECS,
831            &ctx,
832            vec![SecretEnvBinding {
833                env: "SECRET_TOKEN".to_string(),
834                provider: "test-service".to_string(),
835                field: "api_key".to_string(),
836            }],
837        )
838        .await
839        {
840            Ok(_) => panic!("expected policy denial"),
841            Err(err) => err,
842        };
843
844        let message = err.to_string();
845        assert!(message.contains("not allowed by config policy"));
846        assert!(!message.contains("native-secret-value"));
847    }
848
849    #[tokio::test]
850    async fn secret_env_rejects_duplicate_env_bindings() {
851        let err = parse_secret_env_bindings(Some(&serde_json::json!([
852            {"env":"SECRET_TOKEN", "provider":"one", "field":"api_key"},
853            {"env":"SECRET_TOKEN", "provider":"two", "field":"api_key"}
854        ])))
855        .unwrap_err();
856
857        assert!(err.to_string().contains("duplicate secret_env binding"));
858    }
859
860    #[tokio::test]
861    async fn secret_env_rejects_invalid_env_name() {
862        let err = parse_secret_env_bindings(Some(&serde_json::json!([
863            {"env":"secret-token", "provider":"one", "field":"api_key"}
864        ])))
865        .unwrap_err();
866
867        assert!(err
868            .to_string()
869            .contains("env name 'secret-token' is invalid"));
870    }
871
872    #[tokio::test]
873    async fn bash_simple_command() {
874        let tmp = tempfile::tempdir().unwrap();
875        let (ctx, _rx) = test_ctx(tmp.path());
876
877        let result = run_command("echo hello world", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new())
878            .await
879            .unwrap();
880
881        assert!(!result.is_error);
882        let text = match &result.content[0] {
883            imp_llm::ContentBlock::Text { text } => text.clone(),
884            _ => panic!("expected text"),
885        };
886        assert!(text.contains("hello world"));
887        assert_eq!(result.details["exit_code"], 0);
888    }
889
890    #[tokio::test]
891    async fn bash_exit_code() {
892        let tmp = tempfile::tempdir().unwrap();
893        let (ctx, _rx) = test_ctx(tmp.path());
894
895        let result = run_command("exit 42", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new())
896            .await
897            .unwrap();
898
899        assert!(result.is_error);
900        assert_eq!(result.details["exit_code"], 42);
901    }
902
903    #[tokio::test]
904    async fn bash_timeout() {
905        let tmp = tempfile::tempdir().unwrap();
906        let (ctx, _rx) = test_ctx(tmp.path());
907
908        let result = run_command("sleep 60", 1, &ctx, Vec::new()).await.unwrap();
909
910        assert!(result.details["timed_out"].as_bool().unwrap());
911        let text = match &result.content[0] {
912            imp_llm::ContentBlock::Text { text } => text.clone(),
913            _ => panic!("expected text"),
914        };
915        assert!(text.contains("timed out"));
916    }
917
918    #[tokio::test]
919    async fn bash_cancellation() {
920        let tmp = tempfile::tempdir().unwrap();
921        let (ctx, _rx) = test_ctx(tmp.path());
922
923        // Set cancelled before running — should return immediately.
924        ctx.cancelled
925            .store(true, std::sync::atomic::Ordering::Relaxed);
926
927        let result = run_command("sleep 60", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new())
928            .await
929            .unwrap();
930
931        assert!(result.details["cancelled"].as_bool().unwrap());
932        let text = match &result.content[0] {
933            imp_llm::ContentBlock::Text { text } => text.clone(),
934            _ => panic!("expected text"),
935        };
936        assert!(text.contains("cancelled"));
937    }
938
939    #[tokio::test]
940    async fn bash_cancellation_during_execution() {
941        let tmp = tempfile::tempdir().unwrap();
942        let (ctx, _rx) = test_ctx(tmp.path());
943        let cancelled = Arc::clone(&ctx.cancelled);
944
945        let task = tokio::spawn(async move {
946            run_command("sleep 60", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new()).await
947        });
948        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
949        cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
950
951        let result = task.await.unwrap().unwrap();
952        assert!(result.details["cancelled"].as_bool().unwrap());
953    }
954
955    #[tokio::test]
956    async fn bash_streaming_output() {
957        let tmp = tempfile::tempdir().unwrap();
958        let (ctx, mut rx) = test_ctx(tmp.path());
959
960        let handle = tokio::spawn(async move {
961            run_command(
962                "echo line1; echo line2; echo line3",
963                DEFAULT_TIMEOUT_SECS,
964                &ctx,
965                Vec::new(),
966            )
967            .await
968        });
969
970        // Collect streamed updates
971        let mut updates = Vec::new();
972        while let Some(update) = rx.recv().await {
973            updates.push(update);
974        }
975
976        let result = handle.await.unwrap().unwrap();
977        assert!(!result.is_error);
978        assert!(
979            !updates.is_empty(),
980            "should have received streaming updates"
981        );
982    }
983
984    #[tokio::test]
985    async fn bash_stdout_and_stderr_merged() {
986        let tmp = tempfile::tempdir().unwrap();
987        let (ctx, _rx) = test_ctx(tmp.path());
988
989        let result = run_command(
990            "echo stdout_line; echo stderr_line >&2",
991            DEFAULT_TIMEOUT_SECS,
992            &ctx,
993            Vec::new(),
994        )
995        .await
996        .unwrap();
997
998        // exit code 0 → not an error
999        assert!(!result.is_error);
1000        let text = match &result.content[0] {
1001            imp_llm::ContentBlock::Text { text } => text.clone(),
1002            _ => panic!("expected text"),
1003        };
1004        assert!(text.contains("stdout_line"));
1005        assert!(text.contains("stderr_line"));
1006    }
1007
1008    #[tokio::test]
1009    async fn bash_writes_file_side_effect() {
1010        let tmp = tempfile::tempdir().unwrap();
1011        let (ctx, _rx) = test_ctx(tmp.path());
1012
1013        let result = run_command(
1014            "echo 'side effect content' > side_effect.txt",
1015            DEFAULT_TIMEOUT_SECS,
1016            &ctx,
1017            Vec::new(),
1018        )
1019        .await
1020        .unwrap();
1021
1022        assert!(!result.is_error);
1023        let written = std::fs::read_to_string(tmp.path().join("side_effect.txt")).unwrap();
1024        assert!(written.contains("side effect content"));
1025    }
1026
1027    #[tokio::test]
1028    async fn bash_uses_cwd() {
1029        let tmp = tempfile::tempdir().unwrap();
1030        std::fs::write(tmp.path().join("testfile.txt"), "content").unwrap();
1031        let (ctx, _rx) = test_ctx(tmp.path());
1032
1033        let result = run_command("ls testfile.txt", DEFAULT_TIMEOUT_SECS, &ctx, Vec::new())
1034            .await
1035            .unwrap();
1036
1037        assert!(!result.is_error);
1038        let text = match &result.content[0] {
1039            imp_llm::ContentBlock::Text { text } => text.clone(),
1040            _ => panic!("expected text"),
1041        };
1042        assert!(text.contains("testfile.txt"));
1043    }
1044
1045    #[tokio::test]
1046    async fn bash_strips_ansi_sequences() {
1047        let tmp = tempfile::tempdir().unwrap();
1048        let (ctx, _rx) = test_ctx(tmp.path());
1049
1050        let result = run_command(
1051            "printf '\\033[1;31mred\\033[0m\\n'",
1052            DEFAULT_TIMEOUT_SECS,
1053            &ctx,
1054            Vec::new(),
1055        )
1056        .await
1057        .unwrap();
1058
1059        assert!(!result.is_error);
1060        let text = match &result.content[0] {
1061            imp_llm::ContentBlock::Text { text } => text.clone(),
1062            _ => panic!("expected text"),
1063        };
1064        assert!(text.contains("red"));
1065        assert!(!text.contains("\u{1b}[1;31m"));
1066        assert!(!text.contains("\u{1b}[0m"));
1067    }
1068
1069    #[tokio::test]
1070    async fn bash_workdir_override_executes_in_target_dir() {
1071        let root = tempfile::tempdir().unwrap();
1072        let subdir = root.path().join("subdir");
1073        std::fs::create_dir(&subdir).unwrap();
1074        std::fs::write(subdir.join("inside.txt"), "ok").unwrap();
1075        let tool = BashTool;
1076        let (ctx, _rx) = test_ctx(root.path());
1077
1078        let result = tool
1079            .execute(
1080                "c-workdir",
1081                serde_json::json!({"command": "ls inside.txt", "workdir": "subdir"}),
1082                ctx,
1083            )
1084            .await
1085            .unwrap();
1086
1087        assert!(!result.is_error);
1088        let text = match &result.content[0] {
1089            imp_llm::ContentBlock::Text { text } => text.clone(),
1090            _ => panic!("expected text"),
1091        };
1092        assert!(text.contains("inside.txt"));
1093    }
1094
1095    #[tokio::test]
1096    async fn bash_invalid_workdir_returns_error() {
1097        let root = tempfile::tempdir().unwrap();
1098        let tool = BashTool;
1099        let (ctx, _rx) = test_ctx(root.path());
1100
1101        let result = tool
1102            .execute(
1103                "c-bad-workdir",
1104                serde_json::json!({"command": "pwd", "workdir": "missing-dir"}),
1105                ctx,
1106            )
1107            .await
1108            .unwrap();
1109
1110        assert!(result.is_error);
1111        let text = match &result.content[0] {
1112            imp_llm::ContentBlock::Text { text } => text.clone(),
1113            _ => panic!("expected text"),
1114        };
1115        assert!(text.contains("workdir not found"));
1116    }
1117
1118    #[tokio::test]
1119    async fn bash_treats_rg_no_matches_as_success() {
1120        let tmp = tempfile::tempdir().unwrap();
1121        std::fs::write(tmp.path().join("afile.txt"), "haystack\n").unwrap();
1122        let (ctx, _rx) = test_ctx(tmp.path());
1123
1124        let result = run_command(
1125            "rg definitely_not_present .",
1126            DEFAULT_TIMEOUT_SECS,
1127            &ctx,
1128            Vec::new(),
1129        )
1130        .await
1131        .unwrap();
1132
1133        assert!(!result.is_error);
1134        assert_eq!(result.details["exit_code"], 1);
1135        assert!(result.text_content().unwrap().contains("No matches found"));
1136    }
1137
1138    #[tokio::test]
1139    async fn bash_command_not_found_returns_actionable_hint() {
1140        let tmp = tempfile::tempdir().unwrap();
1141        let (ctx, _rx) = test_ctx(tmp.path());
1142
1143        let result = run_command(
1144            "definitely_not_a_real_command_98765",
1145            DEFAULT_TIMEOUT_SECS,
1146            &ctx,
1147            Vec::new(),
1148        )
1149        .await
1150        .unwrap();
1151
1152        assert!(result.is_error);
1153        assert_eq!(result.details["exit_code"], 127);
1154        assert!(result.text_content().unwrap().contains("Command not found"));
1155    }
1156
1157    // ── rush backend tests ──────────────────────────────────────────
1158    //
1159    // Call run_via_rush directly to avoid env-var races between
1160    // parallel test threads.
1161
1162    #[test]
1163    #[cfg(feature = "rush-backend")]
1164    fn test_rush_backend_echo() {
1165        let tmp = tempfile::tempdir().unwrap();
1166        let (output, exit_code, timed_out, _truncated) =
1167            run_via_rush("echo hello world", DEFAULT_TIMEOUT_SECS, tmp.path(), false)
1168                .expect("rush should succeed");
1169
1170        assert_eq!(exit_code, 0);
1171        assert!(!timed_out);
1172        assert!(output.contains("hello world"), "stdout missing: {output}");
1173    }
1174
1175    #[test]
1176    #[cfg(feature = "rush-backend")]
1177    fn test_rush_backend_builtin() {
1178        let tmp = tempfile::tempdir().unwrap();
1179        std::fs::write(tmp.path().join("afile.txt"), "content").unwrap();
1180
1181        let (output, exit_code, _, _) = run_via_rush("ls", DEFAULT_TIMEOUT_SECS, tmp.path(), false)
1182            .expect("rush should succeed");
1183
1184        assert_eq!(exit_code, 0);
1185        assert!(
1186            output.contains("afile.txt"),
1187            "ls should list file: {output}"
1188        );
1189    }
1190
1191    #[test]
1192    #[cfg(feature = "rush-backend")]
1193    fn test_rush_backend_ls_json_text_transform() {
1194        let tmp = tempfile::tempdir().unwrap();
1195        std::fs::write(tmp.path().join("afile.txt"), "content").unwrap();
1196
1197        let (output, exit_code, _, _) = run_via_rush("ls", DEFAULT_TIMEOUT_SECS, tmp.path(), true)
1198            .expect("rush should succeed");
1199        let text = parse_json_lines_to_text("ls", &output).expect("json should transform");
1200
1201        assert_eq!(exit_code, 0);
1202        assert!(text.contains("afile.txt"));
1203    }
1204
1205    #[test]
1206    #[cfg(feature = "rush-backend")]
1207    fn test_rush_backend_grep_json_text_transform() {
1208        let tmp = tempfile::tempdir().unwrap();
1209        std::fs::write(tmp.path().join("afile.txt"), "hello needle world\n").unwrap();
1210
1211        let (output, exit_code, _, _) =
1212            run_via_rush("grep -r needle .", DEFAULT_TIMEOUT_SECS, tmp.path(), true)
1213                .expect("rush should succeed");
1214        let text =
1215            parse_json_lines_to_text("grep -r needle .", &output).expect("json should transform");
1216
1217        assert_eq!(exit_code, 0);
1218        assert!(text.contains("needle"));
1219        assert!(
1220            text.contains("afile.txt") || text.contains(":1:"),
1221            "unexpected grep text: {text}"
1222        );
1223    }
1224
1225    #[test]
1226    #[cfg(feature = "rush-backend")]
1227    fn test_rush_backend_find_json_text_transform() {
1228        let tmp = tempfile::tempdir().unwrap();
1229        std::fs::write(tmp.path().join("afile.txt"), "content").unwrap();
1230
1231        let (output, exit_code, _, _) = run_via_rush(
1232            "find . -name afile.txt",
1233            DEFAULT_TIMEOUT_SECS,
1234            tmp.path(),
1235            true,
1236        )
1237        .expect("rush should succeed");
1238        let text = parse_json_lines_to_text("find . -name afile.txt", &output)
1239            .expect("json should transform");
1240
1241        assert_eq!(exit_code, 0);
1242        assert!(text.contains("afile.txt"));
1243    }
1244
1245    #[test]
1246    #[cfg(feature = "rush-backend")]
1247    fn test_rush_backend_pipeline() {
1248        let tmp = tempfile::tempdir().unwrap();
1249
1250        let (output, exit_code, _, _) =
1251            run_via_rush("echo foo | cat", DEFAULT_TIMEOUT_SECS, tmp.path(), false)
1252                .expect("rush should succeed");
1253
1254        assert_eq!(exit_code, 0);
1255        assert!(output.contains("foo"), "pipeline output missing: {output}");
1256    }
1257
1258    #[test]
1259    #[cfg(feature = "rush-backend")]
1260    fn test_rush_backend_exit_code() {
1261        let tmp = tempfile::tempdir().unwrap();
1262
1263        let (_, exit_code, _, _) = run_via_rush("exit 42", DEFAULT_TIMEOUT_SECS, tmp.path(), false)
1264            .expect("rush should return result even on non-zero exit");
1265
1266        assert_eq!(exit_code, 42);
1267    }
1268}