Skip to repository content488 lines · 16.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:30:44.616Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
pattern_extraction.rs
1use acp_thread::PermissionPattern;
2use shell_command_parser::{extract_commands, extract_terminal_command_prefix};
3use std::path::{Path, PathBuf};
4use url::Url;
5
6/// Escapes a string for use in a regex pattern, but leaves dashes unescaped.
7///
8/// `regex::escape()` escapes dashes, but they are only special inside `[]`
9/// character classes. Leaving them unescaped produces cleaner patterns
10/// (e.g. `^git-lfs\s+pull` instead of `^git\-lfs\s+pull`).
11fn escape_for_pattern(text: &str) -> String {
12 regex::escape(text).replace("\\-", "-")
13}
14
15/// Normalize path separators to forward slashes for consistent cross-platform patterns.
16fn normalize_separators(path_str: &str) -> String {
17 path_str.replace('\\', "/")
18}
19
20/// Returns true if the token looks like a command name or subcommand — i.e. it
21/// contains only alphanumeric characters, hyphens, and underscores, and does not
22/// start with a hyphen (which would make it a flag).
23fn is_plain_command_token(token: &str) -> bool {
24 !token.starts_with('-')
25 && token
26 .chars()
27 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
28}
29
30struct CommandPrefix {
31 normalized_tokens: Vec<String>,
32 display: String,
33}
34
35/// Extracts the command name and optional subcommand from a shell command using
36/// the shell parser.
37///
38/// This parses the command properly to extract the command name and optional
39/// subcommand (e.g. "cargo" and "test" from "cargo test -p search"), handling shell
40/// syntax correctly. Returns `None` if parsing fails or if the command name
41/// contains path separators (for security reasons).
42fn extract_command_prefix(command: &str) -> Option<CommandPrefix> {
43 let prefix = extract_terminal_command_prefix(command)?;
44
45 if !is_plain_command_token(&prefix.command) {
46 return None;
47 }
48
49 Some(CommandPrefix {
50 normalized_tokens: prefix.tokens,
51 display: prefix.display,
52 })
53}
54
55/// Extracts a regex pattern and display name from a terminal command.
56///
57/// Returns `None` for commands starting with `./`, `/`, or other path-like prefixes.
58/// This is a deliberate security decision: we only allow pattern-based "always allow"
59/// rules for well-known command names (like `cargo`, `npm`, `git`), not for arbitrary
60/// scripts or absolute paths which could be manipulated by an attacker.
61pub fn extract_terminal_permission_pattern(command: &str) -> Option<PermissionPattern> {
62 let pattern = extract_terminal_pattern(command)?;
63 let display_name = extract_terminal_pattern_display(command)?;
64 Some(PermissionPattern {
65 pattern,
66 display_name,
67 })
68}
69
70pub fn extract_terminal_pattern(command: &str) -> Option<String> {
71 let prefix = extract_command_prefix(command)?;
72 let tokens = prefix.normalized_tokens;
73
74 match tokens.as_slice() {
75 [] => None,
76 [single] => Some(format!("^{}\\b", escape_for_pattern(single))),
77 [rest @ .., last] => Some(format!(
78 "^{}\\s+{}(\\s|$)",
79 rest.iter()
80 .map(|token| escape_for_pattern(token))
81 .collect::<Vec<_>>()
82 .join("\\s+"),
83 escape_for_pattern(last)
84 )),
85 }
86}
87
88pub fn extract_terminal_pattern_display(command: &str) -> Option<String> {
89 let prefix = extract_command_prefix(command)?;
90 Some(prefix.display)
91}
92
93/// Extracts patterns for ALL commands in a pipeline, not just the first one.
94///
95/// For a command like `"cargo test 2>&1 | tail"`, this returns patterns for
96/// both `cargo` and `tail`. Path-based commands (e.g. `./script.sh`) are
97/// filtered out, and duplicate command names are deduplicated while preserving
98/// order.
99pub fn extract_all_terminal_patterns(command: &str) -> Vec<PermissionPattern> {
100 let commands = match extract_commands(command) {
101 Some(commands) => commands,
102 None => return Vec::new(),
103 };
104
105 let mut results = Vec::new();
106
107 for cmd in &commands {
108 let Some(permission_pattern) = extract_terminal_permission_pattern(cmd) else {
109 continue;
110 };
111
112 if results.contains(&permission_pattern) {
113 continue;
114 }
115
116 results.push(permission_pattern);
117 }
118
119 results
120}
121
122pub fn extract_path_pattern(path: &str) -> Option<String> {
123 let parent = Path::new(path).parent()?;
124 let parent_str = normalize_separators(parent.to_str()?);
125 if parent_str.is_empty() || parent_str == "/" {
126 return None;
127 }
128 Some(format!("^{}/", escape_for_pattern(&parent_str)))
129}
130
131pub fn extract_path_pattern_display(path: &str) -> Option<String> {
132 let parent = Path::new(path).parent()?;
133 let parent_str = normalize_separators(parent.to_str()?);
134 if parent_str.is_empty() || parent_str == "/" {
135 return None;
136 }
137 Some(format!("{}/", parent_str))
138}
139
140fn common_parent_dir(path_a: &str, path_b: &str) -> Option<PathBuf> {
141 let parent_a = Path::new(path_a).parent()?;
142 let parent_b = Path::new(path_b).parent()?;
143
144 let components_a: Vec<_> = parent_a.components().collect();
145 let components_b: Vec<_> = parent_b.components().collect();
146
147 let common_count = components_a
148 .iter()
149 .zip(components_b.iter())
150 .take_while(|(a, b)| a == b)
151 .count();
152
153 if common_count == 0 {
154 return None;
155 }
156
157 let common: PathBuf = components_a[..common_count].iter().collect();
158 Some(common)
159}
160
161pub fn extract_copy_move_pattern(input: &str) -> Option<String> {
162 let (source, dest) = input.split_once('\n')?;
163 let common = common_parent_dir(source, dest)?;
164 let common_str = normalize_separators(common.to_str()?);
165 if common_str.is_empty() || common_str == "/" {
166 return None;
167 }
168 Some(format!("^{}/", escape_for_pattern(&common_str)))
169}
170
171pub fn extract_copy_move_pattern_display(input: &str) -> Option<String> {
172 let (source, dest) = input.split_once('\n')?;
173 let common = common_parent_dir(source, dest)?;
174 let common_str = normalize_separators(common.to_str()?);
175 if common_str.is_empty() || common_str == "/" {
176 return None;
177 }
178 Some(format!("{}/", common_str))
179}
180
181pub fn extract_url_pattern(url: &str) -> Option<String> {
182 let parsed = Url::parse(url).ok()?;
183 let domain = parsed.host_str()?;
184 Some(format!("^https?://{}", escape_for_pattern(domain)))
185}
186
187pub fn extract_url_pattern_display(url: &str) -> Option<String> {
188 let parsed = Url::parse(url).ok()?;
189 let domain = parsed.host_str()?;
190 Some(domain.to_string())
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn test_extract_terminal_pattern() {
199 assert_eq!(
200 extract_terminal_pattern("cargo build --release"),
201 Some("^cargo\\s+build(\\s|$)".to_string())
202 );
203 assert_eq!(
204 extract_terminal_pattern("cargo test -p search"),
205 Some("^cargo\\s+test(\\s|$)".to_string())
206 );
207 assert_eq!(
208 extract_terminal_pattern("npm install"),
209 Some("^npm\\s+install(\\s|$)".to_string())
210 );
211 assert_eq!(
212 extract_terminal_pattern("git-lfs pull"),
213 Some("^git-lfs\\s+pull(\\s|$)".to_string())
214 );
215 assert_eq!(
216 extract_terminal_pattern("my_script arg"),
217 Some("^my_script\\s+arg(\\s|$)".to_string())
218 );
219
220 // Flags as second token: only the command name is used
221 assert_eq!(
222 extract_terminal_pattern("ls -la"),
223 Some("^ls\\b".to_string())
224 );
225 assert_eq!(
226 extract_terminal_pattern("rm --force foo"),
227 Some("^rm\\b".to_string())
228 );
229
230 // Single-word commands
231 assert_eq!(extract_terminal_pattern("ls"), Some("^ls\\b".to_string()));
232
233 // Subcommand pattern does not match a hyphenated extension of the subcommand
234 // (e.g. approving "cargo build" should not approve "cargo build-foo")
235 assert_eq!(
236 extract_terminal_pattern("cargo build"),
237 Some("^cargo\\s+build(\\s|$)".to_string())
238 );
239 let pattern = regex::Regex::new(&extract_terminal_pattern("cargo build").unwrap()).unwrap();
240 assert!(pattern.is_match("cargo build --release"));
241 assert!(pattern.is_match("cargo build"));
242 assert!(!pattern.is_match("cargo build-foo"));
243 assert!(!pattern.is_match("cargo builder"));
244
245 // Env-var prefixes are included in generated patterns
246 assert_eq!(
247 extract_terminal_pattern("PAGER=blah git log --oneline"),
248 Some("^PAGER=blah\\s+git\\s+log(\\s|$)".to_string())
249 );
250 assert_eq!(
251 extract_terminal_pattern("A=1 B=2 git log"),
252 Some("^A=1\\s+B=2\\s+git\\s+log(\\s|$)".to_string())
253 );
254 assert_eq!(
255 extract_terminal_pattern("PAGER='less -R' git log"),
256 Some("^PAGER='less -R'\\s+git\\s+log(\\s|$)".to_string())
257 );
258
259 // Path-like commands are rejected
260 assert_eq!(extract_terminal_pattern("./script.sh arg"), None);
261 assert_eq!(extract_terminal_pattern("/usr/bin/python arg"), None);
262 assert_eq!(extract_terminal_pattern("PAGER=blah ./script.sh arg"), None);
263 }
264
265 #[test]
266 fn test_extract_terminal_pattern_display() {
267 assert_eq!(
268 extract_terminal_pattern_display("cargo build --release"),
269 Some("cargo build".to_string())
270 );
271 assert_eq!(
272 extract_terminal_pattern_display("cargo test -p search"),
273 Some("cargo test".to_string())
274 );
275 assert_eq!(
276 extract_terminal_pattern_display("npm install"),
277 Some("npm install".to_string())
278 );
279 assert_eq!(
280 extract_terminal_pattern_display("ls -la"),
281 Some("ls".to_string())
282 );
283 assert_eq!(
284 extract_terminal_pattern_display("ls"),
285 Some("ls".to_string())
286 );
287 assert_eq!(
288 extract_terminal_pattern_display("PAGER=blah git log --oneline"),
289 Some("PAGER=blah git log".to_string())
290 );
291 assert_eq!(
292 extract_terminal_pattern_display("PAGER='less -R' git log"),
293 Some("PAGER='less -R' git log".to_string())
294 );
295 }
296
297 #[test]
298 fn test_terminal_pattern_regex_normalizes_whitespace() {
299 let pattern = extract_terminal_pattern("PAGER=blah git log --oneline")
300 .expect("expected terminal pattern");
301 let regex = regex::Regex::new(&pattern).expect("expected valid regex");
302
303 assert!(regex.is_match("PAGER=blah git log"));
304 assert!(regex.is_match("PAGER=blah git log --stat"));
305 }
306
307 #[test]
308 fn test_extract_terminal_pattern_skips_redirects_before_subcommand() {
309 assert_eq!(
310 extract_terminal_pattern("git 2>/dev/null log --oneline"),
311 Some("^git\\s+log(\\s|$)".to_string())
312 );
313 assert_eq!(
314 extract_terminal_pattern_display("git 2>/dev/null log --oneline"),
315 Some("git 2>/dev/null log".to_string())
316 );
317
318 assert_eq!(
319 extract_terminal_pattern("rm --force foo"),
320 Some("^rm\\b".to_string())
321 );
322 }
323
324 #[test]
325 fn test_extract_all_terminal_patterns_pipeline() {
326 assert_eq!(
327 extract_all_terminal_patterns("cargo test 2>&1 | tail"),
328 vec![
329 PermissionPattern {
330 pattern: "^cargo\\s+test(\\s|$)".to_string(),
331 display_name: "cargo test".to_string(),
332 },
333 PermissionPattern {
334 pattern: "^tail\\b".to_string(),
335 display_name: "tail".to_string(),
336 },
337 ]
338 );
339 }
340
341 #[test]
342 fn test_extract_all_terminal_patterns_with_path_commands() {
343 assert_eq!(
344 extract_all_terminal_patterns("./script.sh | grep foo"),
345 vec![PermissionPattern {
346 pattern: "^grep\\s+foo(\\s|$)".to_string(),
347 display_name: "grep foo".to_string(),
348 }]
349 );
350 }
351
352 #[test]
353 fn test_extract_all_terminal_patterns_all_paths() {
354 assert_eq!(extract_all_terminal_patterns("./a.sh | /usr/bin/b"), vec![]);
355 }
356
357 #[test]
358 fn test_extract_path_pattern() {
359 assert_eq!(
360 extract_path_pattern("/Users/alice/project/src/main.rs"),
361 Some("^/Users/alice/project/src/".to_string())
362 );
363 assert_eq!(
364 extract_path_pattern("src/lib.rs"),
365 Some("^src/".to_string())
366 );
367 assert_eq!(extract_path_pattern("file.txt"), None);
368 assert_eq!(extract_path_pattern("/file.txt"), None);
369 }
370
371 #[test]
372 fn test_extract_path_pattern_display() {
373 assert_eq!(
374 extract_path_pattern_display("/Users/alice/project/src/main.rs"),
375 Some("/Users/alice/project/src/".to_string())
376 );
377 assert_eq!(
378 extract_path_pattern_display("src/lib.rs"),
379 Some("src/".to_string())
380 );
381 }
382
383 #[test]
384 fn test_extract_url_pattern() {
385 assert_eq!(
386 extract_url_pattern("https://github.com/user/repo"),
387 Some("^https?://github\\.com".to_string())
388 );
389 assert_eq!(
390 extract_url_pattern("http://example.com/path?query=1"),
391 Some("^https?://example\\.com".to_string())
392 );
393 assert_eq!(extract_url_pattern("not a url"), None);
394 }
395
396 #[test]
397 fn test_extract_url_pattern_display() {
398 assert_eq!(
399 extract_url_pattern_display("https://github.com/user/repo"),
400 Some("github.com".to_string())
401 );
402 assert_eq!(
403 extract_url_pattern_display("http://api.example.com/v1/users"),
404 Some("api.example.com".to_string())
405 );
406 }
407
408 #[test]
409 fn test_dashes_are_not_escaped() {
410 assert_eq!(
411 extract_terminal_pattern("git-lfs pull"),
412 Some("^git-lfs\\s+pull(\\s|$)".to_string())
413 );
414 assert_eq!(
415 extract_url_pattern("https://typescript-eslint.io/rules/no-unused-vars"),
416 Some("^https?://typescript-eslint\\.io".to_string())
417 );
418 assert_eq!(
419 extract_path_pattern("/my-project/sub-dir/file.rs"),
420 Some("^/my-project/sub-dir/".to_string())
421 );
422 }
423
424 #[test]
425 fn test_special_chars_are_escaped() {
426 assert_eq!(
427 extract_path_pattern("/path/with (parens)/file.txt"),
428 Some("^/path/with \\(parens\\)/".to_string())
429 );
430 assert_eq!(
431 extract_url_pattern("https://test.example.com/path"),
432 Some("^https?://test\\.example\\.com".to_string())
433 );
434 }
435
436 #[test]
437 fn test_extract_copy_move_pattern_same_directory() {
438 assert_eq!(
439 extract_copy_move_pattern(
440 "/Users/alice/project/src/old.rs\n/Users/alice/project/src/new.rs"
441 ),
442 Some("^/Users/alice/project/src/".to_string())
443 );
444 }
445
446 #[test]
447 fn test_extract_copy_move_pattern_sibling_directories() {
448 assert_eq!(
449 extract_copy_move_pattern(
450 "/Users/alice/project/src/old.rs\n/Users/alice/project/dst/new.rs"
451 ),
452 Some("^/Users/alice/project/".to_string())
453 );
454 }
455
456 #[test]
457 fn test_extract_copy_move_pattern_no_common_prefix() {
458 assert_eq!(
459 extract_copy_move_pattern("/home/file.txt\n/tmp/file.txt"),
460 None
461 );
462 }
463
464 #[test]
465 fn test_extract_copy_move_pattern_relative_paths() {
466 assert_eq!(
467 extract_copy_move_pattern("src/old.rs\nsrc/new.rs"),
468 Some("^src/".to_string())
469 );
470 }
471
472 #[test]
473 fn test_extract_copy_move_pattern_display() {
474 assert_eq!(
475 extract_copy_move_pattern_display(
476 "/Users/alice/project/src/old.rs\n/Users/alice/project/dst/new.rs"
477 ),
478 Some("/Users/alice/project/".to_string())
479 );
480 }
481
482 #[test]
483 fn test_extract_copy_move_pattern_no_arrow() {
484 assert_eq!(extract_copy_move_pattern("just/a/path.rs"), None);
485 assert_eq!(extract_copy_move_pattern_display("just/a/path.rs"), None);
486 }
487}
488