mati_core/hooks/decide/
classification.rs1use super::*;
5
6const CAT_LIKE: &[&str] = &["cat", "less", "head", "tail", "bat"];
9const GREP_LIKE: &[&str] = &["grep", "egrep", "fgrep", "rg", "sed", "awk"];
10const DB_CLIENT_LIKE: &[&str] = &[
11 "psql",
12 "mysql",
13 "mariadb",
14 "redis-cli",
15 "mongosh",
16 "mongo",
17 "sqlite3",
18 "sqlcmd",
19];
20const PATH_MUTATING: &[&str] = &["rm", "mv", "rmdir", "shred"];
21
22pub(super) const ACTION_TOOL_DB_CLIENT: &str = "db_client";
23pub(super) const ACTION_TOOL_FILE_READ: &str = "file_read";
24pub(super) const ACTION_TOOL_PATH: &str = "path";
25
26pub const KNOWN_ACTION_TOOLS: &[&str] = &[
29 ACTION_TOOL_DB_CLIENT,
30 ACTION_TOOL_FILE_READ,
31 ACTION_TOOL_PATH,
32];
33
34pub fn is_known_action_tool(tool: &str) -> bool {
37 KNOWN_ACTION_TOOLS.contains(&tool)
38}
39
40fn matches_command_word(trimmed: &str, word: &str) -> bool {
43 if trimmed.len() < word.len() {
44 return false;
45 }
46 if !trimmed.starts_with(word) {
47 return false;
48 }
49 if trimmed.len() == word.len() {
50 return true;
51 }
52 trimmed.as_bytes()[word.len()].is_ascii_whitespace()
53}
54
55const PREFIX_WORDS: &[&str] = &[
62 "sudo", "doas", "env", "nice", "ionice", "nohup", "setsid", "stdbuf", "command", "time",
63];
64const SHELL_BASENAMES: &[&str] = &["sh", "bash", "zsh", "dash", "ksh"];
65const MAX_SHELL_UNWRAP_DEPTH: usize = 4;
66
67fn is_env_assignment(tok: &str) -> bool {
69 match tok.find('=') {
70 Some(eq) if eq > 0 => {
71 let name = &tok[..eq];
72 name.chars()
73 .next()
74 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
75 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
76 }
77 _ => false,
78 }
79}
80
81fn next_shell_arg<'a>(args: &'a str, pos: &mut usize) -> Option<&'a str> {
82 while *pos < args.len() {
83 let c = args[*pos..].chars().next()?;
84 if !c.is_whitespace() {
85 break;
86 }
87 *pos += c.len_utf8();
88 }
89 if *pos == args.len() {
90 return None;
91 }
92
93 let start = *pos;
94 let mut quote = None;
95 while *pos < args.len() {
96 let c = args[*pos..].chars().next()?;
97 if let Some(open) = quote {
98 if open == '"' && c == '\\' {
99 *pos += c.len_utf8();
100 if *pos < args.len() {
101 let escaped = args[*pos..].chars().next()?;
102 *pos += escaped.len_utf8();
103 }
104 continue;
105 }
106 if c == open {
107 quote = None;
108 }
109 } else if c.is_whitespace() {
110 break;
111 } else if c == '\'' || c == '"' {
112 quote = Some(c);
113 }
114 *pos += c.len_utf8();
115 }
116 Some(&args[start..*pos])
117}
118
119fn is_shell_flag_cluster(token: &str) -> bool {
120 let bytes = token.as_bytes();
121 bytes.len() > 1 && bytes[0] == b'-' && bytes[1] != b'-'
122}
123
124fn shell_c_command(args: &str) -> Option<&str> {
125 let mut pos = 0;
126 loop {
127 let token = next_shell_arg(args, &mut pos)?;
128 if is_shell_flag_cluster(token) && token.as_bytes()[1..].contains(&b'c') {
129 return next_shell_arg(args, &mut pos);
130 }
131 if matches!(token, "-o" | "+o" | "-O") {
132 next_shell_arg(args, &mut pos)?;
133 continue;
134 }
135 if token.starts_with("--") && token.len() > 2 {
136 continue;
137 }
138 if !is_shell_flag_cluster(token) {
139 return None;
140 }
141 }
142}
143
144fn strip_one_outer_quote(command: &str) -> &str {
145 let bytes = command.as_bytes();
146 if bytes.len() >= 2
147 && (bytes[0] == b'\'' || bytes[0] == b'"')
148 && bytes[0] == bytes[bytes.len() - 1]
149 {
150 &command[1..command.len() - 1]
151 } else {
152 command
153 }
154}
155
156pub(super) fn effective_command(cmd: &str) -> String {
166 let mut rest = cmd.trim_start();
167 let mut unwrap_depth = 0;
168 loop {
169 loop {
170 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
171 let tok = &rest[..end];
172 if tok.is_empty() {
173 break;
174 }
175 if is_env_assignment(tok) || PREFIX_WORDS.contains(&tok) {
176 rest = rest[end..].trim_start();
177 continue;
178 }
179 break;
180 }
181
182 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
183 let (word, args) = rest.split_at(end);
184 let base = word.rsplit('/').next().unwrap_or(word);
185 if unwrap_depth < MAX_SHELL_UNWRAP_DEPTH && SHELL_BASENAMES.contains(&base) {
186 if let Some(inner) = shell_c_command(args) {
187 rest = strip_one_outer_quote(inner).trim_start();
188 unwrap_depth += 1;
189 continue;
190 }
191 }
192
193 let mut out = String::with_capacity(base.len() + args.len());
194 out.push_str(base);
195 out.push_str(args);
196 return out;
197 }
198}
199
200#[cfg(fuzzing)]
204pub fn effective_command_for_fuzzing(cmd: &str) -> String {
205 effective_command(cmd)
206}
207
208pub fn classify_command(cmd: &str) -> Option<CommandClass> {
210 let eff = effective_command(cmd);
211 let trimmed = eff.as_str();
212 for &word in CAT_LIKE {
213 if matches_command_word(trimmed, word) {
214 return Some(CommandClass::CatLike);
215 }
216 }
217 for &word in GREP_LIKE {
218 if matches_command_word(trimmed, word) {
219 return Some(CommandClass::GrepLike);
220 }
221 }
222 for &word in DB_CLIENT_LIKE {
223 if matches_command_word(trimmed, word) {
224 return Some(CommandClass::DbClientLike);
225 }
226 }
227 for &word in PATH_MUTATING {
228 if matches_command_word(trimmed, word) {
229 return Some(CommandClass::PathMutating);
230 }
231 }
232 None
233}
234
235pub fn is_schema_introspection(command: &str) -> bool {
239 let normalized = effective_command(split_at_shell_operator(command)).to_ascii_lowercase();
240 let tokens = shell_tokens(&normalized);
241
242 if tokens
243 .iter()
244 .any(|token| token.contains("information_schema"))
245 {
246 return true;
247 }
248
249 for token in &tokens {
250 let token = token.trim_matches(|c: char| c.is_ascii_punctuation() && c != '\\');
251 if ["describe", "desc"]
252 .iter()
253 .any(|word| token == *word || token.starts_with(&format!("{word} ")))
254 {
255 return true;
256 }
257 if ["\\d", "\\dt", "\\d+", "\\l"].iter().any(|meta| {
258 token
259 .strip_prefix(meta)
260 .is_some_and(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
261 }) {
262 return true;
263 }
264 }
265
266 tokens.iter().any(|token| {
267 let token = token.trim_matches(|c: char| c.is_ascii_punctuation());
268 ["show tables", "show columns", "show schemas"]
269 .iter()
270 .any(|phrase| token == *phrase || token.starts_with(&format!("{phrase} ")))
271 }) || tokens.windows(2).any(|pair| {
272 pair[0] == "show"
273 && matches!(
274 pair[1].trim_matches(|c: char| c.is_ascii_punctuation()),
275 "tables" | "columns" | "schemas"
276 )
277 })
278}