use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
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};
use crate::output_observer::{drain_capped, output_session, OutputEmitter};
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"];
fn default_timeout() -> Duration {
Duration::from_secs(agent_bridle_core::LimitsPolicy::default().default_timeout_secs)
}
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>,
output_observer: Option<Arc<dyn crate::ShellOutputObserver>>,
timeout: Duration,
}
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(),
output_observer: None,
timeout: default_timeout(),
}
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_output_observer(mut self, observer: Arc<dyn crate::ShellOutputObserver>) -> Self {
self.output_observer = Some(observer);
self
}
#[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 env: BTreeMap<String, String> = args
.get("env")
.and_then(serde_json::Value::as_object)
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
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 cancel = Arc::new(AtomicBool::new(false));
let interceptor =
CaveatInterceptor::new(cx.clone(), Arc::clone(&sink)).with_cancel(Arc::clone(&cancel));
let max_output = self.max_output;
let (output_guard, output) = output_session(self.output_observer.clone(), max_output);
let run = tokio::task::spawn_blocking(move || {
run_in_brush(cmd, cwd, path_value, env, interceptor, max_output, output)
});
let timeout = self.timeout;
match tokio::time::timeout(timeout, run).await {
Ok(joined) => {
let captured = joined
.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();
let envelope = 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();
output_guard.finish();
Ok(envelope)
}
Err(_elapsed) => {
cancel.store(true, Ordering::SeqCst);
let denials: Vec<Denial> = sink.lock().map(|g| g.clone()).unwrap_or_default();
drop(output_guard);
Ok(ToolEnvelope::new(SandboxKind::None)
.with_disclosure(self.disclosure())
.with_exit_code(124)
.with_stderr(format!("command timed out after {}s", timeout.as_secs()))
.with_timed_out(true)
.with_denials(denials)
.into_json())
}
}
}
}
#[derive(Debug)]
struct Captured {
exit_code: i32,
stdout: String,
stderr: String,
}
fn run_in_brush(
cmd: String,
cwd: Option<String>,
path_value: String,
env: BTreeMap<String, String>,
interceptor: CaveatInterceptor,
max_output: usize,
output: OutputEmitter,
) -> 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_tx, out_rx) = std::sync::mpsc::channel();
let stdout_output = output.clone();
std::thread::spawn(move || {
let _ = out_tx.send(drain(
out_reader,
max_output,
&stdout_output,
crate::ShellOutputStream::Stdout,
));
});
let (err_tx, err_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = err_tx.send(drain(
err_reader,
max_output,
&output,
crate::ShellOutputStream::Stderr,
));
});
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::STDIN_FD,
brush_core::openfiles::null()
.map_err(|e| ToolError::Exec(brush_io("open /dev/null stdin", &e)))?,
);
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));
}
}
for (key, val) in &env {
shell
.env_mut()
.set_global(key, ShellVariable::new(val.clone()))
.map_err(|e| ToolError::Exec(brush_io("seed env var", &e)))?;
}
let result = shell.run_dash_c_command(cmd).await.map_err(|e| {
if e.is_terminating() {
ToolError::denied("brush run cancelled (timeout or interrupt)")
} else {
ToolError::Exec(brush_io("run command", &e))
}
})?;
drop(shell);
Ok::<i32, ToolError>(i32::from(u8::from(result.exit_code)))
})?;
let stdout = collect_drained(&out_rx, "stdout")?;
let stderr = collect_drained(&err_rx, "stderr")?;
Ok(Captured {
exit_code,
stdout,
stderr,
})
}
const DRAIN_DETACH_DEADLINE: Duration = Duration::from_millis(500);
fn collect_drained(
rx: &std::sync::mpsc::Receiver<ToolResult<String>>,
stream: &str,
) -> ToolResult<String> {
use std::sync::mpsc::RecvTimeoutError;
match rx.recv_timeout(DRAIN_DETACH_DEADLINE) {
Ok(result) => result,
Err(RecvTimeoutError::Timeout) => Ok(String::new()),
Err(RecvTimeoutError::Disconnected) => Err(ToolError::denied(format!(
"{stream} reader thread panicked"
))),
}
}
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(
reader: std::io::PipeReader,
max: usize,
output: &OutputEmitter,
stream: crate::ShellOutputStream,
) -> ToolResult<String> {
let (buf, _truncated) = drain_capped(reader, max, output, stream)
.map_err(|e| ToolError::Exec(brush_io("drain pipe", &e)))?;
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}"))
}
#[cfg(all(test, unix))]
mod cancel_tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use agent_bridle_core::{Caveats, DenialKind, Gate, Scope, Tool, ToolResult};
use crate::caveat_interceptor::DenialSink;
fn ctx(granted: Caveats) -> agent_bridle_core::ToolContext {
struct AnyTool;
#[async_trait]
impl Tool for AnyTool {
fn name(&self) -> &str {
"any"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn invoke(
&self,
_args: serde_json::Value,
_cx: &agent_bridle_core::ToolContext,
) -> ToolResult<serde_json::Value> {
Ok(serde_json::Value::Null)
}
}
Gate::new(0)
.authorize(&AnyTool, &granted)
.expect("authorize")
}
#[test]
fn cancel_flag_aborts_at_the_next_external_command() {
let cancel = Arc::new(AtomicBool::new(true));
let sink: DenialSink = Arc::new(Mutex::new(Vec::new()));
let interceptor = CaveatInterceptor::new(ctx(Caveats::top()), Arc::clone(&sink))
.with_cancel(Arc::clone(&cancel));
let res = run_in_brush(
"/bin/echo hi".to_string(),
None,
RESTRICTED_PATH.to_string(),
BTreeMap::new(),
interceptor,
DEFAULT_MAX_OUTPUT,
OutputEmitter::default(),
);
assert!(
res.is_err(),
"a cancelled run must abort, not complete: {res:?}"
);
let recorded = sink.lock().expect("sink").clone();
assert_eq!(recorded.len(), 1, "one cancellation denial: {recorded:?}");
assert_eq!(recorded[0].kind, DenialKind::Exec);
}
fn assert_loop_is_cancellable(cmd: &str, path: &str, what: &str) -> Vec<Denial> {
let cancel = Arc::new(AtomicBool::new(false));
let sink: DenialSink = Arc::new(Mutex::new(Vec::new()));
let cx = ctx(Caveats {
exec: Scope::only(["__never_in_scope__".to_string()]),
..Caveats::top()
});
let interceptor =
CaveatInterceptor::new(cx, Arc::clone(&sink)).with_cancel(Arc::clone(&cancel));
let (cmd, path) = (cmd.to_string(), path.to_string());
let worker = std::thread::spawn(move || {
run_in_brush(
cmd,
None,
path,
BTreeMap::new(),
interceptor,
DEFAULT_MAX_OUTPUT,
OutputEmitter::default(),
)
});
std::thread::sleep(Duration::from_millis(150));
assert!(
!worker.is_finished(),
"the {what} loop should still be spinning before cancel"
);
cancel.store(true, Ordering::SeqCst);
let deadline = Instant::now() + Duration::from_secs(5);
while !worker.is_finished() {
assert!(
Instant::now() < deadline,
"cancel did not stop the {what} loop — the blocking worker leaked"
);
std::thread::sleep(Duration::from_millis(10));
}
let res = worker
.join()
.expect("the worker must return cleanly, not panic");
assert!(
res.is_err(),
"a cancelled {what} run returns a cancellation error: {res:?}"
);
let recorded = sink.lock().expect("sink").clone();
recorded
}
#[test]
fn tripping_cancel_stops_a_pure_builtin_loop() {
assert_loop_is_cancellable("while true; do :; done", "", "pure-builtin");
}
#[test]
fn tripping_cancel_stops_a_loop_of_an_external_command() {
assert_loop_is_cancellable(
"while true; do /bin/true; done",
RESTRICTED_PATH,
"external",
);
}
#[cfg(feature = "carried-coreutils")]
#[test]
fn tripping_cancel_stops_a_loop_of_a_carried_coreutil() {
let recorded = assert_loop_is_cancellable(
"while true; do cat /etc/hostname; done",
"",
"carried-util",
);
assert!(
recorded.iter().any(|d| d.kind == DenialKind::Exec),
"the carried-util loop must register exec-axis denials: {recorded:?}"
);
}
}
#[cfg(test)]
mod drain_tests {
use super::*;
use std::io::Write;
use std::time::Instant;
#[test]
fn collect_drained_detaches_when_a_writer_stays_open() {
let (reader, writer) = std::io::pipe().expect("pipe");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(drain(
reader,
DEFAULT_MAX_OUTPUT,
&OutputEmitter::default(),
crate::ShellOutputStream::Stdout,
));
});
let start = Instant::now();
let out = collect_drained(&rx, "stdout").expect("no drain error");
let elapsed = start.elapsed();
assert!(
elapsed >= DRAIN_DETACH_DEADLINE
&& elapsed < DRAIN_DETACH_DEADLINE + Duration::from_secs(2),
"must detach ~at the deadline, not block on the held-open writer: {elapsed:?}"
);
assert_eq!(out, "", "detached before EOF → empty captured output");
drop(writer);
}
#[test]
fn collect_drained_returns_full_output_promptly_when_writers_close() {
let (reader, mut writer) = std::io::pipe().expect("pipe");
writer.write_all(b"captured-output").expect("write");
drop(writer);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(drain(
reader,
DEFAULT_MAX_OUTPUT,
&OutputEmitter::default(),
crate::ShellOutputStream::Stdout,
));
});
let start = Instant::now();
let out = collect_drained(&rx, "stdout").expect("no drain error");
assert_eq!(out, "captured-output", "full foreground output is captured");
assert!(
start.elapsed() < DRAIN_DETACH_DEADLINE,
"must return as soon as the drain EOFs, not wait out the detach deadline"
);
}
}