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#[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 Err(_) => true,
186 }
187}
188
189#[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
224fn detect_shell(config: &crate::config::ShellConfig) -> String {
227 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 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 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 #[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 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 }
516
517 let mut child = {
518 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 .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 #[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 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 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 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(¬e);
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#[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 unsafe {
720 libc::kill(-pgid, libc::SIGTERM);
721 }
722
723 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 }
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 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 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 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 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 #[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}