use std::collections::HashSet;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use agent_bridle_core::{Denial, DenialKind, ToolContext};
use brush_core::extensions::{CommandInterceptor, ExecDecision, OpenDecision};
#[derive(Debug)]
pub(crate) struct BrushCancelled;
pub(crate) type DenialSink = Arc<Mutex<Vec<Denial>>>;
pub(crate) type AllowCache = Arc<Mutex<HashSet<String>>>;
#[derive(Debug, Clone, Default)]
pub struct CaveatInterceptor {
cx: Option<ToolContext>,
sink: Option<DenialSink>,
cancel: Option<Arc<AtomicBool>>,
allow_cache: Option<AllowCache>,
}
impl CaveatInterceptor {
#[must_use]
pub(crate) fn new(cx: ToolContext, sink: DenialSink) -> Self {
Self {
cx: Some(cx),
sink: Some(sink),
cancel: None,
allow_cache: Some(Arc::new(Mutex::new(HashSet::new()))),
}
}
fn is_memoized_allow(&self, program: &str) -> bool {
self.allow_cache.as_ref().is_some_and(|c| {
c.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains(program)
})
}
fn memoize_allow(&self, program: &str) {
if let Some(cache) = &self.allow_cache {
cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(program.to_string());
}
}
#[must_use]
pub(crate) fn with_cancel(mut self, cancel: Arc<AtomicBool>) -> Self {
self.cancel = Some(cancel);
self
}
fn is_cancelled(&self) -> bool {
self.cancel
.as_ref()
.is_some_and(|c| c.load(Ordering::SeqCst))
}
fn abort_cancelled(&self, kind: DenialKind, target: impl Into<String>) -> ! {
self.record(kind, target, "run cancelled (timeout or interrupt)");
std::panic::panic_any(BrushCancelled);
}
fn record(&self, kind: DenialKind, target: impl Into<String>, reason: impl Into<String>) {
if let Some(sink) = &self.sink {
let mut guard = sink
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.push(Denial {
kind,
target: target.into(),
reason: reason.into(),
});
}
}
}
impl CommandInterceptor for CaveatInterceptor {
fn before_exec(&self, program: &str, _args: &[String]) -> ExecDecision {
if self.is_cancelled() {
self.abort_cancelled(DenialKind::Exec, program);
}
if self.is_memoized_allow(program) {
return ExecDecision::Allow;
}
match &self.cx {
Some(cx) => match cx.check_exec(program) {
Ok(()) => {
self.memoize_allow(program);
ExecDecision::Allow
}
Err(e) => {
let reason = e.to_string();
self.record(DenialKind::Exec, program, &reason);
ExecDecision::Deny(reason)
}
},
None => {
let reason = "no effective caveats (default interceptor); exec denied".to_string();
self.record(DenialKind::Exec, program, &reason);
ExecDecision::Deny(reason)
}
}
}
fn before_open(&self, path: &Path, write: bool) -> OpenDecision {
if self.is_cancelled() {
self.abort_cancelled(DenialKind::Open, path.to_string_lossy());
}
if write
&& matches!(
path.to_str(),
Some("/dev/null" | "/dev/stdout" | "/dev/stderr")
)
{
return OpenDecision::Allow;
}
let Some(cx) = &self.cx else {
let reason = "no effective caveats (default interceptor); open denied".to_string();
self.record(DenialKind::Open, path.to_string_lossy(), &reason);
return OpenDecision::Deny(reason);
};
let result = if write {
cx.check_path_write(path)
} else {
cx.check_path_read(path)
};
match result {
Ok(()) => OpenDecision::Allow,
Err(e) => {
let reason = e.to_string();
self.record(DenialKind::Open, path.to_string_lossy(), &reason);
OpenDecision::Deny(reason)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_bridle_core::{Caveats, Gate, Scope, Tool, ToolResult};
fn ctx(granted: Caveats) -> ToolContext {
struct AnyTool;
#[async_trait::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: &ToolContext,
) -> ToolResult<serde_json::Value> {
Ok(serde_json::Value::Null)
}
}
Gate::new(0)
.authorize(&AnyTool, &granted)
.expect("authorize")
}
fn interceptor_with_sink(granted: Caveats) -> (CaveatInterceptor, DenialSink) {
let sink: DenialSink = Arc::new(Mutex::new(Vec::new()));
let interceptor = CaveatInterceptor::new(ctx(granted), Arc::clone(&sink));
(interceptor, sink)
}
fn drain(sink: &DenialSink) -> Vec<Denial> {
sink.lock().unwrap().clone()
}
#[test]
fn default_is_fail_closed() {
let interceptor = CaveatInterceptor::default();
assert!(matches!(
interceptor.before_exec("echo", &[]),
ExecDecision::Deny(_)
));
assert!(matches!(
interceptor.before_open(Path::new("/tmp"), false),
OpenDecision::Deny(_)
));
}
#[test]
fn before_exec_allows_in_scope_denies_out_of_scope() {
let (interceptor, sink) = interceptor_with_sink(Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
});
assert!(matches!(
interceptor.before_exec("echo", &[]),
ExecDecision::Allow
));
assert!(matches!(
interceptor.before_exec("rm", &["-rf".to_string()]),
ExecDecision::Deny(_)
));
let recorded = drain(&sink);
assert_eq!(
recorded.len(),
1,
"exactly one denial expected: {recorded:?}"
);
assert_eq!(recorded[0].kind, DenialKind::Exec);
assert_eq!(recorded[0].target, "rm");
assert!(recorded[0].reason.contains("not within the granted"));
}
#[test]
fn before_exec_denies_path_separator_spelled_command() {
let (interceptor, sink) = interceptor_with_sink(Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
});
assert!(matches!(
interceptor.before_exec("/bin/rm", &["-rf".to_string()]),
ExecDecision::Deny(_)
));
let recorded = drain(&sink);
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].kind, DenialKind::Exec);
assert_eq!(recorded[0].target, "/bin/rm");
}
#[test]
fn allow_records_nothing_in_sink() {
let (interceptor, sink) = interceptor_with_sink(Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
});
assert!(matches!(
interceptor.before_exec("echo", &[]),
ExecDecision::Allow
));
assert!(drain(&sink).is_empty(), "an Allow must record nothing");
}
#[test]
fn dev_null_sinks_are_always_writable() {
let i = CaveatInterceptor::default();
assert!(matches!(
i.before_open(Path::new("/dev/null"), true),
OpenDecision::Allow
));
assert!(matches!(
i.before_open(Path::new("/dev/stdout"), true),
OpenDecision::Allow
));
assert!(matches!(
i.before_open(Path::new("/dev/stderr"), true),
OpenDecision::Allow
));
assert!(matches!(
i.before_open(Path::new("/dev/sda"), true),
OpenDecision::Deny(_)
));
assert!(matches!(
i.before_open(Path::new("/etc/passwd"), true),
OpenDecision::Deny(_)
));
}
#[test]
fn before_open_write_uses_fs_write_axis() {
let dir = std::env::temp_dir();
let (interceptor, _sink) = interceptor_with_sink(Caveats {
fs_write: Scope::only([dir.to_string_lossy().into_owned()]),
..Caveats::top()
});
assert!(matches!(
interceptor.before_open(&dir.join("ab-interceptor-ok.txt"), true),
OpenDecision::Allow
));
assert!(matches!(
interceptor.before_open(Path::new("/etc/shadow"), true),
OpenDecision::Deny(_)
));
}
#[test]
fn before_open_denial_is_recorded_as_open_kind() {
let dir = std::env::temp_dir();
let (interceptor, sink) = interceptor_with_sink(Caveats {
fs_write: Scope::only([dir.to_string_lossy().into_owned()]),
..Caveats::top()
});
let _ = interceptor.before_open(Path::new("/etc/shadow"), true);
let recorded = drain(&sink);
assert_eq!(recorded.len(), 1, "one open denial expected: {recorded:?}");
assert_eq!(recorded[0].kind, DenialKind::Open);
assert_eq!(recorded[0].target, "/etc/shadow");
}
#[test]
fn memo_never_changes_a_decision() {
let (interceptor, _sink) = interceptor_with_sink(Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
});
for _ in 0..5 {
assert!(matches!(
interceptor.before_exec("echo", &[]),
ExecDecision::Allow
));
assert!(matches!(
interceptor.before_exec("rm", &[]),
ExecDecision::Deny(_)
));
}
}
#[test]
fn denials_are_recorded_every_time_not_memoized() {
let (interceptor, sink) = interceptor_with_sink(Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
});
for _ in 0..5 {
let _ = interceptor.before_exec("rm", &[]);
}
let recorded = drain(&sink);
assert_eq!(
recorded.len(),
5,
"every denial must still be recorded: {recorded:?}"
);
assert!(recorded.iter().all(|d| d.kind == DenialKind::Exec));
}
#[test]
fn memo_does_not_bleed_across_invocations_with_different_caveats() {
let (permissive, _s1) = interceptor_with_sink(Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
});
assert!(matches!(
permissive.before_exec("echo", &[]),
ExecDecision::Allow
));
let (restrictive, sink) = interceptor_with_sink(Caveats {
exec: Scope::only(["ls".to_string()]),
..Caveats::top()
});
assert!(
matches!(restrictive.before_exec("echo", &[]), ExecDecision::Deny(_)),
"a memoized Allow must never cross into an invocation with different caveats"
);
assert_eq!(drain(&sink).len(), 1, "and the denial is recorded");
}
#[test]
fn clones_share_one_memo_within_an_invocation() {
let (interceptor, _sink) = interceptor_with_sink(Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
});
assert!(matches!(
interceptor.before_exec("echo", &[]),
ExecDecision::Allow
));
let clone = interceptor.clone();
assert!(
clone.is_memoized_allow("echo"),
"a clone must see the memo its sibling warmed"
);
assert!(matches!(
clone.before_exec("echo", &[]),
ExecDecision::Allow
));
}
#[test]
fn default_interceptor_memoizes_nothing() {
let interceptor = CaveatInterceptor::default();
for _ in 0..3 {
assert!(matches!(
interceptor.before_exec("echo", &[]),
ExecDecision::Deny(_)
));
}
assert!(!interceptor.is_memoized_allow("echo"));
}
#[test]
fn memoized_allow_still_aborts_when_cancelled() {
let cancel = Arc::new(AtomicBool::new(false));
let sink: DenialSink = Arc::new(Mutex::new(Vec::new()));
let interceptor = CaveatInterceptor::new(ctx(Caveats::top()), Arc::clone(&sink))
.with_cancel(Arc::clone(&cancel));
assert!(matches!(
interceptor.before_exec("/bin/echo", &[]),
ExecDecision::Allow
));
assert!(interceptor.is_memoized_allow("/bin/echo"));
cancel.store(true, Ordering::SeqCst);
let payload = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
interceptor.before_exec("/bin/echo", &[])
}))
.expect_err("a cancelled run must unwind even for a memoized-Allow program");
assert!(
payload.is::<BrushCancelled>(),
"the abort must be the BrushCancelled sentinel, not another panic"
);
let recorded = drain(&sink);
assert_eq!(recorded.len(), 1, "one cancellation denial: {recorded:?}");
assert_eq!(recorded[0].kind, DenialKind::Exec);
}
#[test]
fn clones_share_one_sink() {
let (interceptor, sink) = interceptor_with_sink(Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
});
let clone = interceptor.clone();
let _ = interceptor.before_exec("rm", &[]);
let _ = clone.before_exec("curl", &[]);
let recorded = drain(&sink);
assert_eq!(
recorded.len(),
2,
"both clones' denials must land: {recorded:?}"
);
let targets: Vec<&str> = recorded.iter().map(|d| d.target.as_str()).collect();
assert!(targets.contains(&"rm"));
assert!(targets.contains(&"curl"));
}
}