use std::collections::HashMap;
use std::io::Read;
use std::sync::{Arc, LazyLock, Mutex};
use agent_bridle_core::{
default_exec_path, Denial, Disclosure, SandboxKind, Scope, Tool, ToolContext, ToolEnvelope,
ToolError, ToolResult,
};
use async_trait::async_trait;
use brush_builtins::{default_builtins, BuiltinSet};
use brush_core::builtins::Registration;
use brush_core::extensions::{DefaultErrorFormatter, ShellExtensionsImpl};
use brush_core::openfiles::{OpenFile, OpenFiles};
use brush_core::variables::ShellVariable;
use brush_core::{Shell, ShellFd};
use crate::caveat_interceptor::{CaveatInterceptor, DenialSink};
const ENGINE_NAME: &str = "brush";
const DEFAULT_MAX_OUTPUT: usize = 64 * 1024;
const RESTRICTED_PATH: &str = "/usr/local/bin:/usr/bin:/bin";
const REMOVED_BUILTINS: &[&str] = &["exec"];
type LeashedExtensions = ShellExtensionsImpl<DefaultErrorFormatter, CaveatInterceptor>;
static DEFAULT_SCHEMA: LazyLock<Arc<serde_json::Value>> = LazyLock::new(|| {
Arc::new(
serde_json::from_str(include_str!("host_shell.schema.json"))
.expect("embedded host_shell.schema.json must be valid JSON"),
)
});
#[derive(Clone)]
pub struct BrushShellTool {
max_output: usize,
schema: Arc<serde_json::Value>,
}
impl std::fmt::Debug for BrushShellTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BrushShellTool").finish_non_exhaustive()
}
}
impl Default for BrushShellTool {
fn default() -> Self {
Self::new()
}
}
impl BrushShellTool {
#[must_use]
pub fn new() -> Self {
Self {
max_output: DEFAULT_MAX_OUTPUT,
schema: DEFAULT_SCHEMA.clone(),
}
}
#[must_use]
pub fn with_schema(mut self, schema: serde_json::Value) -> Self {
self.schema = Arc::new(schema);
self
}
fn disclosure(&self) -> Disclosure {
Disclosure {
engine: Some(ENGINE_NAME.to_string()),
..Disclosure::default()
}
}
}
#[async_trait]
impl Tool for BrushShellTool {
fn name(&self) -> &str {
"shell"
}
fn schema(&self) -> serde_json::Value {
(*self.schema).clone()
}
async fn invoke(
&self,
args: serde_json::Value,
cx: &ToolContext,
) -> ToolResult<serde_json::Value> {
let cmd = args
.get("cmd")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| ToolError::denied("brush: missing required `cmd` string"))?
.to_string();
let cwd = args
.get("cwd")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let path_value = if matches!(cx.caveats().exec, Scope::All) {
default_exec_path()
} else {
RESTRICTED_PATH.to_string()
};
let sink: DenialSink = Arc::new(Mutex::new(Vec::new()));
let interceptor = CaveatInterceptor::new(cx.clone(), Arc::clone(&sink));
let max_output = self.max_output;
let captured = tokio::task::spawn_blocking(move || {
run_in_brush(cmd, cwd, path_value, interceptor, max_output)
})
.await
.map_err(|e| ToolError::Exec(std::io::Error::other(format!("join: {e}"))))??;
let denials: Vec<Denial> = sink.lock().map(|g| g.clone()).unwrap_or_default();
Ok(ToolEnvelope::new(SandboxKind::None)
.with_disclosure(self.disclosure())
.with_exit_code(captured.exit_code)
.with_stdout(captured.stdout)
.with_stderr(captured.stderr)
.with_timed_out(false)
.with_denials(denials)
.into_json())
}
}
struct Captured {
exit_code: i32,
stdout: String,
stderr: String,
}
fn run_in_brush(
cmd: String,
cwd: Option<String>,
path_value: String,
interceptor: CaveatInterceptor,
max_output: usize,
) -> ToolResult<Captured> {
let (out_reader, out_writer) =
std::io::pipe().map_err(|e| ToolError::Exec(brush_io("create stdout pipe", &e)))?;
let (err_reader, err_writer) =
std::io::pipe().map_err(|e| ToolError::Exec(brush_io("create stderr pipe", &e)))?;
let out_handle = std::thread::spawn(move || drain(out_reader, max_output));
let err_handle = std::thread::spawn(move || drain(err_reader, max_output));
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| ToolError::Exec(brush_io("build shell runtime", &e)))?;
let working_dir = cwd.map(std::path::PathBuf::from);
let exit_code = rt.block_on(async move {
let mut fds: HashMap<ShellFd, OpenFile> = HashMap::new();
fds.insert(OpenFiles::STDOUT_FD, OpenFile::from(out_writer));
fds.insert(OpenFiles::STDERR_FD, OpenFile::from(err_writer));
let mut shell: Shell<LeashedExtensions> =
Shell::builder_with_extensions::<LeashedExtensions>()
.command_interceptor(interceptor)
.builtins(confined_builtins())
.do_not_inherit_env(true)
.no_editing(true)
.interactive(false)
.fds(fds)
.maybe_working_dir(working_dir)
.build()
.await
.map_err(|e| ToolError::Exec(brush_io("build shell", &e)))?;
#[cfg(feature = "carried-coreutils")]
{
crate::coreutils_dispatch::install_default_providers();
crate::coreutils_dispatch::register_shims(&mut shell);
}
shell
.env_mut()
.set_global("PATH", ShellVariable::new(path_value))
.map_err(|e| ToolError::Exec(brush_io("seed PATH", &e)))?;
#[cfg(windows)]
for key in [
"SystemRoot",
"SystemDrive",
"windir",
"TEMP",
"TMP",
"USERPROFILE",
"NUMBER_OF_PROCESSORS",
] {
if let Ok(val) = std::env::var(key) {
let _ = shell.env_mut().set_global(key, ShellVariable::new(val));
}
}
let result = shell
.run_dash_c_command(cmd)
.await
.map_err(|e| ToolError::Exec(brush_io("run command", &e)))?;
drop(shell);
Ok::<i32, ToolError>(i32::from(u8::from(result.exit_code)))
})?;
let stdout = out_handle
.join()
.map_err(|_| ToolError::denied("stdout reader thread panicked"))??;
let stderr = err_handle
.join()
.map_err(|_| ToolError::denied("stderr reader thread panicked"))??;
Ok(Captured {
exit_code,
stdout,
stderr,
})
}
fn confined_builtins() -> HashMap<String, Registration<LeashedExtensions>> {
let mut builtins = default_builtins::<LeashedExtensions>(BuiltinSet::BashMode);
for name in REMOVED_BUILTINS {
builtins.remove(*name);
}
builtins
}
fn drain(mut reader: std::io::PipeReader, max: usize) -> ToolResult<String> {
let mut buf = Vec::new();
reader
.read_to_end(&mut buf)
.map_err(|e| ToolError::Exec(brush_io("drain pipe", &e)))?;
if buf.len() > max {
buf.truncate(max);
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
fn brush_io(ctx: &str, e: &impl std::fmt::Display) -> std::io::Error {
std::io::Error::other(format!("{ctx}: {e}"))
}