use anyhow::{Context, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
#[derive(Debug, Clone)]
pub struct ToolFilter {
allow: Option<GlobSet>,
deny: GlobSet,
is_noop: bool,
}
impl ToolFilter {
pub fn from_cli(allow: &[String], deny: &[String]) -> Result<Self> {
let allow_set = build_set(allow, "--allow-tool")?;
let deny_set = build_set(deny, "--deny-tool")?.unwrap_or_else(GlobSet::empty);
let is_noop = allow_set.is_none() && deny_set.is_empty();
Ok(Self {
allow: allow_set,
deny: deny_set,
is_noop,
})
}
pub fn allow_all() -> Self {
Self {
allow: None,
deny: GlobSet::empty(),
is_noop: true,
}
}
pub fn permits(&self, name: &str) -> bool {
if self.is_noop {
return true;
}
let allowed = match &self.allow {
Some(set) => set.is_match(name),
None => true,
};
allowed && !self.deny.is_match(name)
}
pub fn is_noop(&self) -> bool {
self.is_noop
}
}
impl Default for ToolFilter {
fn default() -> Self {
Self::allow_all()
}
}
fn build_set(raw: &[String], flag: &str) -> Result<Option<GlobSet>> {
let mut builder = GlobSetBuilder::new();
let mut count = 0usize;
for entry in raw {
for piece in entry.split(',') {
let pattern = piece.trim();
if pattern.is_empty() {
continue;
}
let glob = Glob::new(pattern)
.with_context(|| format!("{flag}: invalid glob pattern {pattern:?}"))?;
builder.add(glob);
count += 1;
}
}
if count == 0 {
return Ok(None);
}
let set = builder
.build()
.with_context(|| format!("{flag}: failed to compile glob set"))?;
Ok(Some(set))
}
#[cfg(test)]
mod tests {
use super::*;
fn allow(p: &[&str]) -> Vec<String> {
p.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn noop_filter_admits_everything() {
let f = ToolFilter::from_cli(&[], &[]).expect("build");
assert!(f.is_noop());
assert!(f.permits("anything"));
assert!(f.permits(""));
assert!(f.permits("github.repos.create"));
}
#[test]
fn allow_all_constructor_matches_noop() {
let f = ToolFilter::allow_all();
assert!(f.is_noop());
assert!(f.permits("x"));
}
#[test]
fn allow_list_is_exclusive() {
let f = ToolFilter::from_cli(&allow(&["read_*", "search"]), &[]).expect("build");
assert!(!f.is_noop());
assert!(f.permits("read_file"));
assert!(f.permits("read_anything"));
assert!(f.permits("search"));
assert!(!f.permits("write_file"));
assert!(!f.permits("searchx"));
}
#[test]
fn deny_list_subtracts_from_allow_all() {
let f = ToolFilter::from_cli(&[], &allow(&["dangerous_*"])).expect("build");
assert!(!f.is_noop());
assert!(f.permits("read_file"));
assert!(!f.permits("dangerous_thing"));
}
#[test]
fn deny_beats_allow() {
let f =
ToolFilter::from_cli(&allow(&["read_*"]), &allow(&["read_secrets"])).expect("build");
assert!(f.permits("read_file"));
assert!(!f.permits("read_secrets"));
assert!(!f.permits("write_file"));
}
#[test]
fn comma_split_is_equivalent_to_repeated_flags() {
let comma = ToolFilter::from_cli(&allow(&["a,b,c"]), &[]).expect("build");
let repeated = ToolFilter::from_cli(&allow(&["a", "b", "c"]), &[]).expect("build");
for name in ["a", "b", "c", "d"] {
assert_eq!(
comma.permits(name),
repeated.permits(name),
"comma vs repeated must agree on {name}"
);
}
}
#[test]
fn whitespace_and_empty_entries_are_ignored() {
let f =
ToolFilter::from_cli(&allow(&[" read_* , ", "", " ,write_*"]), &[]).expect("build");
assert!(f.permits("read_file"));
assert!(f.permits("write_file"));
assert!(!f.permits("delete_file"));
}
#[test]
fn all_empty_input_is_noop_not_lockout() {
let f = ToolFilter::from_cli(&allow(&["", " , "]), &allow(&[""])).expect("build");
assert!(f.is_noop(), "all-empty input must degrade to no-op");
assert!(f.permits("anything"));
}
#[test]
fn invalid_glob_is_rejected_with_flag_name() {
let err =
ToolFilter::from_cli(&allow(&["read_["]), &[]).expect_err("malformed glob must error");
let msg = format!("{err:#}");
assert!(
msg.contains("--allow-tool"),
"error must mention the offending flag; got: {msg}"
);
}
#[test]
fn deny_only_with_no_match_is_no_op_in_practice() {
let f = ToolFilter::from_cli(&[], &allow(&["nope_*"])).expect("build");
assert!(!f.is_noop()); assert!(f.permits("read_file"));
assert!(!f.permits("nope_this"));
}
#[test]
fn glob_question_mark_and_classes_work() {
let f = ToolFilter::from_cli(&allow(&["tool_?", "x[0-9]"]), &[]).expect("build");
assert!(f.permits("tool_a"));
assert!(!f.permits("tool_ab"));
assert!(f.permits("x3"));
assert!(!f.permits("xa"));
}
}