use glob::Pattern;
use regex::Regex;
use serde_json::Value;
use super::policy::ToolPolicy;
use super::resolver::GlobalResolver;
#[derive(Debug, Clone)]
pub enum ArgMatcher {
Prefix(String),
Glob(Pattern),
Regex(Regex),
}
impl ArgMatcher {
pub fn new_prefix(p: &str) -> Self {
Self::Prefix(p.into())
}
pub fn new_glob(p: &str) -> Result<Self, glob::PatternError> {
Ok(Self::Glob(Pattern::new(p)?))
}
pub fn new_regex(r: &str) -> Result<Self, regex::Error> {
Ok(Self::Regex(Regex::new(r)?))
}
pub fn matches(&self, value: &str) -> bool {
match self {
Self::Prefix(p) => value.starts_with(p),
Self::Glob(p) => p.matches(value),
Self::Regex(r) => r.is_match(value),
}
}
}
#[derive(Debug, Clone)]
pub struct BlacklistRule {
pub description: String,
pub matchers: Vec<(String, ArgMatcher)>,
}
pub struct SecurityBlacklist {
pub rules: Vec<BlacklistRule>,
}
impl SecurityBlacklist {
pub fn new(rules: Vec<BlacklistRule>) -> Self {
Self { rules }
}
pub fn matches_command(&self, command: &str) -> bool {
self.rules
.iter()
.any(|r| r.matchers.iter().any(|(_, m)| m.matches(command)))
}
pub fn matches_args(&self, args: &Value) -> bool {
let cmd = args
.get("command")
.and_then(|v| v.as_str())
.or_else(|| args.get("binary").and_then(|v| v.as_str()));
match cmd {
Some(c) => self.matches_command(c),
None => false,
}
}
pub fn policy_for(&self, args: &Value) -> Option<ToolPolicy> {
if self.matches_args(args) {
Some(ToolPolicy::Always)
} else {
None
}
}
}
impl GlobalResolver for SecurityBlacklist {
fn resolve(&self, call: &super::gate::ToolCall<'_>) -> Option<ToolPolicy> {
self.policy_for(call.args)
}
}
pub fn default_blacklist_rules() -> Vec<BlacklistRule> {
fn rule(desc: &str, key: &str, matcher: ArgMatcher) -> BlacklistRule {
BlacklistRule {
description: desc.into(),
matchers: vec![(key.into(), matcher)],
}
}
vec![
rule(
"rm -rf system",
"command",
ArgMatcher::new_prefix("rm -rf /"),
),
rule("rm -rf home", "command", ArgMatcher::new_prefix("rm -rf ~")),
rule(
"sudo escalation",
"command",
ArgMatcher::new_glob("sudo *").unwrap(),
),
rule(
"fork bomb",
"command",
ArgMatcher::new_prefix(":(){ :|:& };:"),
),
rule(
"disk format",
"command",
ArgMatcher::new_glob("mkfs*").unwrap(),
),
rule(
"raw disk write",
"command",
ArgMatcher::new_glob("dd *of=/dev/*").unwrap(),
),
rule(
"force push",
"command",
ArgMatcher::new_prefix("git push --force"),
),
rule(
"chmod 777 system",
"command",
ArgMatcher::new_prefix("chmod -R 777 /"),
),
]
}
#[cfg(test)]
mod tests {
use super::super::gate::ToolCall;
use super::*;
use serde_json::json;
#[test]
fn prefix_matcher_matches_start() {
assert!(ArgMatcher::new_prefix("rm -rf /").matches("rm -rf /etc"));
assert!(!ArgMatcher::new_prefix("rm -rf /").matches("ls -la"));
}
#[test]
fn glob_matcher_matches_wildcard() {
assert!(
ArgMatcher::new_glob("sudo *")
.unwrap()
.matches("sudo apt install")
);
assert!(
!ArgMatcher::new_glob("sudo *")
.unwrap()
.matches("apt install")
);
}
#[test]
fn default_rules_block_rm_rf_root() {
let bl = SecurityBlacklist::new(default_blacklist_rules());
assert!(bl.matches_command("rm -rf /etc"));
}
#[test]
fn default_rules_block_fork_bomb() {
let bl = SecurityBlacklist::new(default_blacklist_rules());
assert!(bl.matches_command(":(){ :|:& };:"));
}
#[test]
fn default_rules_allow_curl() {
let bl = SecurityBlacklist::new(default_blacklist_rules());
assert!(!bl.matches_command("curl https://wttr.in/Seoul"));
}
#[test]
fn global_resolver_impl_returns_always_on_match() {
let bl = SecurityBlacklist::new(default_blacklist_rules());
let args = json!({"command": "rm -rf /etc"});
let call = ToolCall {
tool: "exec",
binary: None,
args: &args,
};
assert_eq!(
super::super::resolver::GlobalResolver::resolve(&bl, &call),
Some(ToolPolicy::Always)
);
}
#[test]
fn global_resolver_impl_returns_none_on_safe_command() {
let bl = SecurityBlacklist::new(default_blacklist_rules());
let args = json!({"command": "curl https://example.com"});
let call = ToolCall {
tool: "exec",
binary: None,
args: &args,
};
assert_eq!(
super::super::resolver::GlobalResolver::resolve(&bl, &call),
None
);
}
}