| 1 | //! Command matching helpers for execpolicy rules. |
| 2 | |
| 3 | use std::collections::HashMap; |
| 4 | use std::sync::{Arc, Mutex, OnceLock}; |
| 5 | |
| 6 | use regex::Regex; |
| 7 | |
| 8 | /// `pattern` compiled once as an anchored `*`-glob, then reused. |
| 9 | /// |
| 10 | /// Every other regex metacharacter is escaped, so `*` is the only wildcard |
| 11 | /// (matching any run of characters, newline included). `None` means the pattern |
| 12 | /// does not compile; callers treat that as "no match". |
| 13 | /// |
| 14 | /// The patterns come from configuration and are stable between edits, but the |
| 15 | /// callers run per shell execution and per hook event. Compiling here on first |
| 16 | /// use keeps the fast path free of `Regex::new` without changing what matches. |
| 17 | pub fn compiled_glob(pattern: &str) -> Option<Arc<Regex>> { |
| 18 | static CACHE: OnceLock<Mutex<HashMap<String, Option<Arc<Regex>>>>> = OnceLock::new(); |
| 19 | let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); |
| 20 | let mut cache = cache |
| 21 | .lock() |
| 22 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 23 | cache |
| 24 | .entry(pattern.to_string()) |
| 25 | .or_insert_with(|| { |
| 26 | let escaped = regex::escape(pattern).replace(r"\*", ".*"); |
| 27 | Regex::new(&format!("^{escaped}$")).ok().map(Arc::new) |
| 28 | }) |
| 29 | .clone() |
| 30 | } |
| 31 | |
| 32 | /// Normalize a command string by shlex parsing and re-joining tokens. |
| 33 | /// |
| 34 | /// Strips heredoc bodies first (#419) so a command like |
| 35 | /// `cat <<EOF > file.txt\nbody\nEOF` collapses to `cat > file.txt` |
| 36 | /// before pattern matching. Without this, an `auto_allow` pattern |
| 37 | /// of `cat > file.txt` would fail to match because shlex would |
| 38 | /// tokenize the body lines into the command. |
| 39 | pub fn normalize_command(command: &str) -> String { |
| 40 | let stripped = strip_heredoc_bodies(command); |
| 41 | if let Some(tokens) = shlex::split(&stripped) { |
| 42 | tokens.join(" ") |
| 43 | } else { |
| 44 | stripped |
| 45 | .split_whitespace() |
| 46 | .filter(|token| !token.is_empty()) |
| 47 | .collect::<Vec<_>>() |
| 48 | .join(" ") |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | /// Strip heredoc bodies from a multi-line command string. |
| 53 | /// |
| 54 | /// Recognises the common forms: |
| 55 | /// |
| 56 | /// * `<<DELIM` — body until line equal to `DELIM`. |
| 57 | /// * `<<-DELIM` — body until line equal to `DELIM` (tabs stripped |
| 58 | /// in real shell; we keep the delimiter match the same). |
| 59 | /// * `<<'DELIM'` / `<<"DELIM"` — quoted delimiter; quotes peeled |
| 60 | /// for the closing match. |
| 61 | /// |
| 62 | /// The here-string operator `<<<` is intentionally not stripped — |
| 63 | /// its body is the next token on the same line, not separate lines, |
| 64 | /// and shlex tokenizes it correctly. |
| 65 | fn strip_heredoc_bodies(command: &str) -> String { |
| 66 | if !command.contains("<<") { |
| 67 | return command.to_string(); |
| 68 | } |
| 69 | // Sidestep the here-string operator (`<<<`) by replacing it |
| 70 | // with a placeholder before running the heredoc regex, then |
| 71 | // restoring it after. Rust's `regex` crate doesn't support |
| 72 | // lookbehind, so we can't write "match `<<` only when not |
| 73 | // preceded by `<`" directly; this preprocessing achieves the |
| 74 | // same outcome. |
| 75 | const HERESTRING_PLACEHOLDER: &str = "\u{0001}HERESTRING\u{0001}"; |
| 76 | let command_owned: String = command.replace("<<<", HERESTRING_PLACEHOLDER); |
| 77 | let command: &str = &command_owned; |
| 78 | |
| 79 | // Lazy-init the heredoc-start regex. Allows whitespace / `-` |
| 80 | // between `<<` and the delimiter, accepts optional `'` / `"` |
| 81 | // around the delimiter name. The delimiter is a typical |
| 82 | // shell identifier (alphanumeric + underscore). |
| 83 | static HEREDOC_RE_INIT: std::sync::OnceLock<Regex> = std::sync::OnceLock::new(); |
| 84 | let re = HEREDOC_RE_INIT.get_or_init(|| { |
| 85 | Regex::new(r#"<<-?\s*(?:['"]?)([A-Za-z_][A-Za-z0-9_]*)(?:['"]?)"#) |
| 86 | .expect("heredoc regex compiles") |
| 87 | }); |
| 88 | |
| 89 | let mut out = String::with_capacity(command.len()); |
| 90 | let mut lines = command.lines(); |
| 91 | while let Some(line) = lines.next() { |
| 92 | // Detect heredoc on this line, capture the delimiter, and |
| 93 | // strip the `<<DELIM` operator from the line so downstream |
| 94 | // tokenizers don't see it in the pattern. A single line can |
| 95 | // have multiple heredocs (rare but legal: `cmd <<A <<B`); |
| 96 | // we strip every match on the line and consume until the |
| 97 | // *last* delimiter (the matching shell behavior is to stack |
| 98 | // them, but for pattern-match purposes they all collapse). |
| 99 | let mut delim: Option<String> = None; |
| 100 | let mut redacted = line.to_string(); |
| 101 | for cap in re.captures_iter(line) { |
| 102 | // Strip the entire `<<DELIM` text from the line. |
| 103 | let whole = cap.get(0).map_or("", |m| m.as_str()); |
| 104 | redacted = redacted.replace(whole, ""); |
| 105 | // Track the last-seen delimiter for body consumption. |
| 106 | delim = cap.get(1).map(|m| m.as_str().to_string()); |
| 107 | } |
| 108 | // Trim any double-spaces left after stripping. |
| 109 | let cleaned = redacted |
| 110 | .split_whitespace() |
| 111 | .filter(|t| !t.is_empty()) |
| 112 | .collect::<Vec<_>>() |
| 113 | .join(" "); |
| 114 | out.push_str(&cleaned); |
| 115 | out.push('\n'); |
| 116 | if let Some(d) = delim { |
| 117 | // Skip body lines until we hit the matching delimiter. |
| 118 | for body_line in lines.by_ref() { |
| 119 | if body_line.trim() == d { |
| 120 | break; |
| 121 | } |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | // Restore the here-string operator we hid before regex matching. |
| 126 | out.replace(HERESTRING_PLACEHOLDER, "<<<") |
| 127 | } |
| 128 | |
| 129 | /// Return true if the pattern matches the command. |
| 130 | /// |
| 131 | /// Patterns support `*` wildcards that match any substring. |
| 132 | pub fn pattern_matches(pattern: &str, command: &str) -> bool { |
| 133 | let pattern = normalize_command(pattern); |
| 134 | let command = normalize_command(command); |
| 135 | |
| 136 | if pattern == "*" { |
| 137 | return true; |
| 138 | } |
| 139 | |
| 140 | compiled_glob(&pattern).is_some_and(|re| re.is_match(&command)) |
| 141 | } |
| 142 | |
| 143 | #[cfg(test)] |
| 144 | mod tests { |
| 145 | use super::*; |
| 146 | |
| 147 | #[test] |
| 148 | fn test_normalize_command() { |
| 149 | assert_eq!(normalize_command("git status"), "git status"); |
| 150 | assert_eq!( |
| 151 | normalize_command("git \"log --oneline\""), |
| 152 | "git log --oneline" |
| 153 | ); |
| 154 | } |
| 155 | |
| 156 | #[test] |
| 157 | fn test_pattern_matches() { |
| 158 | assert!(pattern_matches("git status", "git status")); |
| 159 | assert!(pattern_matches("git log *", "git log --oneline")); |
| 160 | assert!(pattern_matches("cargo *", "cargo test --all")); |
| 161 | assert!(!pattern_matches("git push --force", "git push origin main")); |
| 162 | } |
| 163 | |
| 164 | #[test] |
| 165 | fn strip_heredoc_strips_simple_body() { |
| 166 | let cmd = "cat <<EOF > file.txt\nhello\nworld\nEOF"; |
| 167 | let stripped = super::strip_heredoc_bodies(cmd); |
| 168 | // Body lines `hello` and `world` are gone; the delimiter |
| 169 | // `EOF` line is also consumed. |
| 170 | assert!(!stripped.contains("hello")); |
| 171 | assert!(!stripped.contains("world")); |
| 172 | // The redirect target survives. |
| 173 | assert!(stripped.contains("> file.txt")); |
| 174 | } |
| 175 | |
| 176 | #[test] |
| 177 | fn strip_heredoc_handles_dash_form() { |
| 178 | // `<<-EOF` strips leading tabs in a real shell; for our |
| 179 | // matching purposes we still want the delimiter consumed. |
| 180 | let cmd = "cat <<-EOF > file.txt\n\tbody\nEOF"; |
| 181 | let stripped = super::strip_heredoc_bodies(cmd); |
| 182 | assert!(!stripped.contains("body")); |
| 183 | assert!(stripped.contains("> file.txt")); |
| 184 | } |
| 185 | |
| 186 | #[test] |
| 187 | fn strip_heredoc_handles_quoted_delimiter() { |
| 188 | let cmd = "cat <<'END_OF_FILE' > out\nliteral $vars\nEND_OF_FILE"; |
| 189 | let stripped = super::strip_heredoc_bodies(cmd); |
| 190 | assert!(!stripped.contains("literal $vars")); |
| 191 | assert!(stripped.contains("> out")); |
| 192 | } |
| 193 | |
| 194 | #[test] |
| 195 | fn strip_heredoc_leaves_non_heredoc_commands_intact() { |
| 196 | let cmd = "echo hello && ls"; |
| 197 | // Early-return path: no `<<` in the input, so the original |
| 198 | // string flows through unchanged (no trailing newline added). |
| 199 | assert_eq!(super::strip_heredoc_bodies(cmd), "echo hello && ls"); |
| 200 | } |
| 201 | |
| 202 | #[test] |
| 203 | fn strip_heredoc_does_not_touch_here_string_operator() { |
| 204 | // `<<<` is here-string; the body is on the same line. |
| 205 | // shlex handles it fine — we shouldn't try to strip |
| 206 | // anything because there's no body following on later lines. |
| 207 | let cmd = "grep foo <<< \"some text\""; |
| 208 | let stripped = super::strip_heredoc_bodies(cmd); |
| 209 | // Output keeps the `<<<` — content not stripped. |
| 210 | assert!(stripped.contains("<<<")); |
| 211 | assert!(stripped.contains("some text")); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn normalize_command_strips_heredoc_for_pattern_matching() { |
| 216 | // The end-to-end goal: a user's `auto_allow = ["cat > file.txt"]` |
| 217 | // pattern matches the heredoc form too. |
| 218 | let normalized = normalize_command("cat <<EOF > file.txt\nbody\nEOF"); |
| 219 | assert!(pattern_matches("cat > file.txt", &normalized)); |
| 220 | } |
| 221 | |
| 222 | #[test] |
| 223 | fn compiled_glob_is_compiled_once_per_pattern() { |
| 224 | // The per-shell-execution and per-hook-event callers rely on this |
| 225 | // returning the same compiled program rather than rebuilding it. |
| 226 | let first = compiled_glob("mcp__*").expect("glob compiles"); |
| 227 | let second = compiled_glob("mcp__*").expect("glob compiles"); |
| 228 | assert!(std::sync::Arc::ptr_eq(&first, &second)); |
| 229 | assert!(first.is_match("mcp__github__search")); |
| 230 | assert!(!first.is_match("read_file")); |
| 231 | } |
| 232 | |
| 233 | #[test] |
| 234 | fn compiled_glob_escapes_every_metacharacter_except_star() { |
| 235 | // `regex::escape` is what makes `a.b` a literal while `*` stays a |
| 236 | // wildcard — the same contract `pattern_matches` has always had. |
| 237 | let literal = compiled_glob("a.b").expect("glob compiles"); |
| 238 | assert!(literal.is_match("a.b")); |
| 239 | assert!(!literal.is_match("axb")); |
| 240 | |
| 241 | let wildcard = compiled_glob("a*b").expect("glob compiles"); |
| 242 | assert!(wildcard.is_match("ab")); |
| 243 | assert!(wildcard.is_match("a middle b")); |
| 244 | } |
| 245 | } |
| 246 |