oxios_kernel/approval/
blacklist.rs1use glob::Pattern;
4use regex::Regex;
5use serde_json::Value;
6
7use super::policy::ToolPolicy;
8use super::resolver::GlobalResolver;
9
10#[derive(Debug, Clone)]
12pub enum ArgMatcher {
13 Prefix(String),
15 Glob(Pattern),
17 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#[derive(Debug, Clone)]
43pub struct BlacklistRule {
44 pub description: String,
46 pub matchers: Vec<(String, ArgMatcher)>,
48}
49
50pub struct SecurityBlacklist {
53 pub rules: Vec<BlacklistRule>,
55}
56
57impl SecurityBlacklist {
58 pub fn new(rules: Vec<BlacklistRule>) -> Self {
60 Self { rules }
61 }
62
63 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 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 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
98pub 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}