Skip to main content

oxios_kernel/approval/
blacklist.rs

1//! Security blacklist — always-enforced dangerous-command patterns.
2
3use glob::Pattern;
4use regex::Regex;
5use serde_json::Value;
6
7use super::policy::ToolPolicy;
8use super::resolver::GlobalResolver;
9
10/// Argument matcher (lobehub `ArgumentMatcher` analog).
11#[derive(Debug, Clone)]
12pub enum ArgMatcher {
13    /// Prefix match: `"git push --force"`.
14    Prefix(String),
15    /// Glob: `"sudo *"`, `"mkfs*"`.
16    Glob(Pattern),
17    /// Regex for precise control.
18    Regex(Regex),
19}
20
21impl ArgMatcher {
22    pub fn new_prefix(p: &str) -> Self {
23        Self::Prefix(p.into())
24    }
25    pub fn new_glob(p: &str) -> Result<Self, glob::PatternError> {
26        Ok(Self::Glob(Pattern::new(p)?))
27    }
28    pub fn new_regex(r: &str) -> Result<Self, regex::Error> {
29        Ok(Self::Regex(Regex::new(r)?))
30    }
31
32    pub fn matches(&self, value: &str) -> bool {
33        match self {
34            Self::Prefix(p) => value.starts_with(p),
35            Self::Glob(p) => p.matches(value),
36            Self::Regex(r) => r.is_match(value),
37        }
38    }
39}
40
41/// One blacklist rule: a description plus matchers keyed by argument name.
42#[derive(Debug, Clone)]
43pub struct BlacklistRule {
44    /// Human-readable reason (surfaced to the user on the approval card).
45    pub description: String,
46    /// Argument key → matcher. Conventionally `{"command": ...}` or `{"binary": ...}`.
47    pub matchers: Vec<(String, ArgMatcher)>,
48}
49
50/// Security blacklist. Implements `GlobalResolver` so it slots into the
51/// approval pipeline's Phase 4 (always-enforced escalation to `Always`).
52pub struct SecurityBlacklist {
53    /// Rules. Defaults always apply; users extend (not replace) via config.
54    pub rules: Vec<BlacklistRule>,
55}
56
57impl SecurityBlacklist {
58    /// Create a blacklist with the given rules.
59    pub fn new(rules: Vec<BlacklistRule>) -> Self {
60        Self { rules }
61    }
62
63    /// Test a command string against all rules.
64    pub fn matches_command(&self, command: &str) -> bool {
65        self.rules
66            .iter()
67            .any(|r| r.matchers.iter().any(|(_, m)| m.matches(command)))
68    }
69
70    /// Extract the command/binary from a `Value` args bag and test.
71    pub fn matches_args(&self, args: &Value) -> bool {
72        let cmd = args
73            .get("command")
74            .and_then(|v| v.as_str())
75            .or_else(|| args.get("binary").and_then(|v| v.as_str()));
76        match cmd {
77            Some(c) => self.matches_command(c),
78            None => false,
79        }
80    }
81
82    /// Return `Some(Always)` if the args match any rule, else `None`.
83    pub fn policy_for(&self, args: &Value) -> Option<ToolPolicy> {
84        if self.matches_args(args) {
85            Some(ToolPolicy::Always)
86        } else {
87            None
88        }
89    }
90}
91
92impl GlobalResolver for SecurityBlacklist {
93    fn resolve(&self, call: &super::gate::ToolCall<'_>) -> Option<ToolPolicy> {
94        self.policy_for(call.args)
95    }
96}
97
98/// Default always-on rules. Users extend (not replace) via config.
99pub fn default_blacklist_rules() -> Vec<BlacklistRule> {
100    fn rule(desc: &str, key: &str, matcher: ArgMatcher) -> BlacklistRule {
101        BlacklistRule {
102            description: desc.into(),
103            matchers: vec![(key.into(), matcher)],
104        }
105    }
106    vec![
107        rule(
108            "rm -rf system",
109            "command",
110            ArgMatcher::new_prefix("rm -rf /"),
111        ),
112        rule("rm -rf home", "command", ArgMatcher::new_prefix("rm -rf ~")),
113        rule(
114            "sudo escalation",
115            "command",
116            ArgMatcher::new_glob("sudo *").unwrap(),
117        ),
118        rule(
119            "fork bomb",
120            "command",
121            ArgMatcher::new_prefix(":(){ :|:& };:"),
122        ),
123        rule(
124            "disk format",
125            "command",
126            ArgMatcher::new_glob("mkfs*").unwrap(),
127        ),
128        rule(
129            "raw disk write",
130            "command",
131            ArgMatcher::new_glob("dd *of=/dev/*").unwrap(),
132        ),
133        rule(
134            "force push",
135            "command",
136            ArgMatcher::new_prefix("git push --force"),
137        ),
138        rule(
139            "chmod 777 system",
140            "command",
141            ArgMatcher::new_prefix("chmod -R 777 /"),
142        ),
143    ]
144}
145
146#[cfg(test)]
147mod tests {
148    use super::super::gate::ToolCall;
149    use super::*;
150    use serde_json::json;
151
152    #[test]
153    fn prefix_matcher_matches_start() {
154        assert!(ArgMatcher::new_prefix("rm -rf /").matches("rm -rf /etc"));
155        assert!(!ArgMatcher::new_prefix("rm -rf /").matches("ls -la"));
156    }
157
158    #[test]
159    fn glob_matcher_matches_wildcard() {
160        assert!(
161            ArgMatcher::new_glob("sudo *")
162                .unwrap()
163                .matches("sudo apt install")
164        );
165        assert!(
166            !ArgMatcher::new_glob("sudo *")
167                .unwrap()
168                .matches("apt install")
169        );
170    }
171
172    #[test]
173    fn default_rules_block_rm_rf_root() {
174        let bl = SecurityBlacklist::new(default_blacklist_rules());
175        assert!(bl.matches_command("rm -rf /etc"));
176    }
177
178    #[test]
179    fn default_rules_block_fork_bomb() {
180        let bl = SecurityBlacklist::new(default_blacklist_rules());
181        assert!(bl.matches_command(":(){ :|:& };:"));
182    }
183
184    #[test]
185    fn default_rules_allow_curl() {
186        let bl = SecurityBlacklist::new(default_blacklist_rules());
187        assert!(!bl.matches_command("curl https://wttr.in/Seoul"));
188    }
189
190    #[test]
191    fn global_resolver_impl_returns_always_on_match() {
192        let bl = SecurityBlacklist::new(default_blacklist_rules());
193        let args = json!({"command": "rm -rf /etc"});
194        let call = ToolCall {
195            tool: "exec",
196            binary: None,
197            args: &args,
198        };
199        assert_eq!(
200            super::super::resolver::GlobalResolver::resolve(&bl, &call),
201            Some(ToolPolicy::Always)
202        );
203    }
204
205    #[test]
206    fn global_resolver_impl_returns_none_on_safe_command() {
207        let bl = SecurityBlacklist::new(default_blacklist_rules());
208        let args = json!({"command": "curl https://example.com"});
209        let call = ToolCall {
210            tool: "exec",
211            binary: None,
212            args: &args,
213        };
214        assert_eq!(
215            super::super::resolver::GlobalResolver::resolve(&bl, &call),
216            None
217        );
218    }
219}