use std::path::Path;
use std::sync::{Arc, Mutex};
use agent_bridle_core::{Denial, DenialKind, ToolContext};
use brush_core::extensions::{CommandInterceptor, ExecDecision, OpenDecision};
pub(crate) type DenialSink = Arc<Mutex<Vec<Denial>>>;
#[derive(Debug, Clone, Default)]
pub struct CaveatInterceptor {
cx: Option<ToolContext>,
sink: Option<DenialSink>,
}
impl CaveatInterceptor {
#[must_use]
pub(crate) fn new(cx: ToolContext, sink: DenialSink) -> Self {
Self {
cx: Some(cx),
sink: Some(sink),
}
}
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 {
match &self.cx {
Some(cx) => match cx.check_exec(program) {
Ok(()) => 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 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 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"));
}
}