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)]
147
148mod tests {
149 use super::super::gate::ToolCall;
150 use super::*;
151 use serde_json::json;
152
153 #[test]
154 fn prefix_matcher_matches_start() {
155 assert!(ArgMatcher::new_prefix("rm -rf /").matches("rm -rf /etc"));
156 assert!(!ArgMatcher::new_prefix("rm -rf /").matches("ls -la"));
157 }
158
159 #[test]
160 fn glob_matcher_matches_wildcard() {
161 assert!(
162 ArgMatcher::new_glob("sudo *")
163 .unwrap()
164 .matches("sudo apt install")
165 );
166 assert!(
167 !ArgMatcher::new_glob("sudo *")
168 .unwrap()
169 .matches("apt install")
170 );
171 }
172
173 #[test]
174 fn default_rules_block_rm_rf_root() {
175 let bl = SecurityBlacklist::new(default_blacklist_rules());
176 assert!(bl.matches_command("rm -rf /etc"));
177 }
178
179 #[test]
180 fn default_rules_block_fork_bomb() {
181 let bl = SecurityBlacklist::new(default_blacklist_rules());
182 assert!(bl.matches_command(":(){ :|:& };:"));
183 }
184
185 #[test]
186 fn default_rules_allow_curl() {
187 let bl = SecurityBlacklist::new(default_blacklist_rules());
188 assert!(!bl.matches_command("curl https://wttr.in/Seoul"));
189 }
190
191 #[test]
192 fn global_resolver_impl_returns_always_on_match() {
193 let bl = SecurityBlacklist::new(default_blacklist_rules());
194 let args = json!({"command": "rm -rf /etc"});
195 let call = ToolCall {
196 tool: "exec",
197 binary: None,
198 args: &args,
199 };
200 assert_eq!(
201 super::super::resolver::GlobalResolver::resolve(&bl, &call),
202 Some(ToolPolicy::Always)
203 );
204 }
205
206 #[test]
207 fn global_resolver_impl_returns_none_on_safe_command() {
208 let bl = SecurityBlacklist::new(default_blacklist_rules());
209 let args = json!({"command": "curl https://example.com"});
210 let call = ToolCall {
211 tool: "exec",
212 binary: None,
213 args: &args,
214 };
215 assert_eq!(
216 super::super::resolver::GlobalResolver::resolve(&bl, &call),
217 None
218 );
219 }
220}