Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:30:44.616Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

pattern_extraction.rs

488 lines · 16.3 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.