Skip to main content

mati_core/hooks/decide/
classification.rs

1//! Command classification — is this a known action tool, a schema
2//! introspection call, or something else. Pure, no I/O.
3
4use super::*;
5
6// ── Command Classification ──────────────────────────────────────────────────
7
8const 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
26/// The action categories emitted by the normalizer and accepted by policy
27/// matching. Keep this as the single source for author-time recognition too.
28pub const KNOWN_ACTION_TOOLS: &[&str] = &[
29    ACTION_TOOL_DB_CLIENT,
30    ACTION_TOOL_FILE_READ,
31    ACTION_TOOL_PATH,
32];
33
34/// Return whether a policy trigger names an action category emitted by the
35/// normalizer. Unknown values remain valid authoring data, but cannot match.
36pub fn is_known_action_tool(tool: &str) -> bool {
37    KNOWN_ACTION_TOOLS.contains(&tool)
38}
39
40/// Returns true if `trimmed` starts with `word` followed by whitespace
41/// (or is exactly `word`). Prevents `"catch"` matching `"cat"`.
42fn 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
55/// Command prefixes that wrap the real command without changing what it reads:
56/// `sudo cat …`, `env LOG=1 cat …`, `nice cat …`. Stripped before classifying
57/// so the read gate sees `cat`, not the wrapper. Wrapper *flags* (e.g.
58/// `sudo -u root`) are intentionally NOT parsed here — guessing which take a
59/// value risks mis-stripping a real argument, so that narrow case is left as a
60/// tracked gap rather than handled unsafely.
61const 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
67/// Is `tok` a `NAME=VALUE` shell environment assignment?
68fn 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
156/// Normalize a command for detection: strip leading env assignments and wrapper
157/// prefixes (`sudo`/`env`/`nice`/…), unwrap up to four shell `-c` layers, then
158/// reduce the command word to its basename (`/bin/cat` → `cat`). One matching
159/// outer quote layer is stripped from each `-c` string; shell unescaping is not
160/// attempted. The tokenizer honors backslash-escaped characters inside double
161/// quotes, so escaped quotes remain part of the extracted command rather than
162/// closing its token; they are still not unescaped. Returns the effective
163/// command, left-trimmed. Pure; closes the prefix and absolute-path bypass
164/// classes for the read gate.
165pub(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/// Fuzz-only reexport of the private [`effective_command`]. `cargo fuzz`
201/// sets `--cfg fuzzing` automatically, so this compiles only under
202/// `cargo fuzz build`/`run` and never in a normal build.
203#[cfg(fuzzing)]
204pub fn effective_command_for_fuzzing(cmd: &str) -> String {
205    effective_command(cmd)
206}
207
208/// Classify a bash command string. Returns `None` for non-file-read commands.
209pub 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
235/// Return whether a command performs an allowlisted database schema
236/// introspection. This is deliberately lexical: it recognizes only bounded
237/// SQL/meta-command forms and never infers intent from natural language.
238pub 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}