llimorse_tools/bash.rs
1//! Process execution tools.
2
3use super::ToolGate;
4use anyhow::{Result, anyhow};
5use helpers::TruncatedDisplay;
6use llimorse::{Agent, CallableTool};
7use std::fmt;
8#[cfg(unix)]
9use std::os::unix::process::ExitStatusExt;
10use tokio::process::Command;
11
12llimorse::tool! {
13 'name: "bash";
14
15 /// Execute the given command line through a shell.
16 #[derive(Debug)]
17 'params: pub struct BashParams {
18 /// Command line to execute
19 command_line: String,
20 }
21
22 /// Result of the command
23 #[derive(Debug)]
24 'result: pub struct BashResult {
25 /// The integer exit code returned by the command
26 exit_code: isize,
27
28 /// Stdout output
29 stdout: String,
30
31 /// Stderr output
32 #[serde(default, skip_serializing_if = "str::is_empty")]
33 stderr: String,
34 }
35
36 /// Execute commands through a shell. The shell is non-interactive: there is no terminal and no
37 /// stdin, so anything that would prompt (passwords, pagers, ...) fails immediately instead of
38 /// waiting.
39 ///
40 /// The command runs asynchronously: awaiting `execute` does not block the runtime, and
41 /// dropping the future (e.g. when the tool call is interrupted) kills the child.
42 #[derive(Debug)]
43 'state: pub struct Bash<G: ToolGate> {
44 /// Gate for receiving permissions to execute bash commands
45 gate: G,
46 }
47}
48
49impl<G: ToolGate> Bash<G> {
50 /// Create a new bash tool, gated by `gate`
51 pub fn new(gate: G) -> Self {
52 Bash { gate }
53 }
54}
55
56impl fmt::Display for BashParams {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 write!(f, "{}", self.command_line)
59 }
60}
61
62impl fmt::Display for BashResult {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 write!(
65 f,
66 "exit_code={} stdout={:?}",
67 self.exit_code,
68 self.stdout.truncated_display(100)
69 )?;
70 if !self.stderr.is_empty() {
71 write!(f, " stderr={:?}", self.stderr.truncated_display(100))?;
72 }
73 Ok(())
74 }
75}
76
77/// Build the command for executing `command_line` through a shell.
78///
79/// The shell is decidedly non-interactive. The child is placed in a new session (`setsid`), so it
80/// has no controlling terminal: programs that prompt on a tty (git, ssh, ...) open `/dev/tty`
81/// directly, bypassing stdin, and a blocked read on it would stop the whole command with
82/// `SIGTTIN`. With no terminal to prompt on, they fail fast instead. The remaining knobs cover
83/// prompt paths that don't use a tty at all.
84///
85/// `kill_on_drop` is enabled so that dropping the future awaiting the command's output kills the
86/// child, letting an interrupted tool call abort a still-running command.
87fn shell_command(command_line: &str) -> Command {
88 let mut cmd = Command::new("bash");
89 cmd.args(["-c", command_line]);
90 #[cfg(unix)]
91 {
92 // `pre_exec` runs in the child between fork and exec, where only async-signal-safe calls
93 // are allowed; `setsid` is one of them.
94 unsafe {
95 cmd.pre_exec(|| {
96 if libc::setsid() == -1 {
97 return Err(std::io::Error::last_os_error());
98 }
99 Ok(())
100 });
101 }
102 }
103 // git: never prompt for credentials (also gives a clearer error)
104 cmd.env("GIT_TERMINAL_PROMPT", "0");
105 // ssh: never invoke an askpass program
106 cmd.env("SSH_ASKPASS_REQUIRE", "never");
107 // No terminal and no stdin: anything that would prompt fails immediately instead of waiting.
108 // (`tokio::process::Command::output`, unlike its std counterpart, does not null stdin.)
109 cmd.stdin(std::process::Stdio::null());
110 // Dropping the future awaiting the output kills the child
111 cmd.kill_on_drop(true);
112 cmd
113}
114
115impl<G: ToolGate> CallableTool for Bash<G> {
116 async fn execute(&self, _agent: &Agent, params: BashParams) -> Result<BashResult> {
117 self.gate
118 .permitted(¶ms)
119 .await
120 .map_err(|e| anyhow!("Bash tool call rejected: {e}"))?;
121
122 let output = shell_command(¶ms.command_line)
123 .output()
124 .await
125 .map_err(|err| anyhow!("Failed to execute bash: {err}"))?;
126
127 #[cfg(unix)]
128 let exit_code = output
129 .status
130 .code()
131 .or_else(|| output.status.signal().map(|s| -s));
132 #[cfg(not(unix))]
133 let exit_code = output.status.code();
134
135 Ok(BashResult {
136 exit_code: exit_code.unwrap_or(-1) as isize,
137 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
138 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
139 })
140 }
141}