Skip to main content

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(&params)
119            .await
120            .map_err(|e| anyhow!("Bash tool call rejected: {e}"))?;
121
122        let output = shell_command(&params.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}