1use crate::core::context_admission::origin::{InputKind, InputOrigin, SourceClass};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ToolOutputMatch {
23 pub origin: InputOrigin,
25 pub downloads: Vec<String>,
28}
29
30impl ToolOutputMatch {
31 pub fn class(&self) -> SourceClass {
33 SourceClass::External(self.origin.clone())
34 }
35}
36
37pub fn command_ref(command: &str) -> String {
39 let hash = blake3::hash(command.as_bytes()).to_hex();
40 format!("cmd:{}", &hash[..8])
41}
42
43pub fn classify(command: &str) -> Option<ToolOutputMatch> {
45 let mut downloads = Vec::new();
46 let matched = match scan(command, 0, &mut downloads) {
47 Scan::Match => true,
48 Scan::Clean => false,
49 };
50 matched.then(|| ToolOutputMatch {
51 origin: InputOrigin::new(InputKind::ToolOutput, command_ref(command)),
52 downloads,
53 })
54}
55
56#[derive(PartialEq, Eq)]
57enum Scan {
58 Match,
59 Clean,
60}
61
62const MAX_DEPTH: usize = 8;
63
64fn scan(command: &str, depth: usize, downloads: &mut Vec<String>) -> Scan {
65 if depth > MAX_DEPTH {
66 return Scan::Match;
67 }
68 for inner in substitutions(command) {
70 if scan(&inner, depth + 1, downloads) == Scan::Match {
71 return Scan::Match;
72 }
73 }
74 let Some(parts) = split_parts(command) else {
75 return Scan::Match;
76 };
77 let mut matched = false;
78 for words in parts {
79 if part_matches(&words, depth, downloads) {
80 matched = true;
81 }
82 }
83 if matched { Scan::Match } else { Scan::Clean }
84}
85
86fn split_parts(command: &str) -> Option<Vec<Vec<String>>> {
89 let mut parts = Vec::new();
90 let mut words: Vec<String> = Vec::new();
91 let mut word = String::new();
92 let mut in_word = false;
93 let mut chars = command.chars().peekable();
94 let flush = |word: &mut String, in_word: &mut bool, words: &mut Vec<String>| {
95 if *in_word {
96 words.push(std::mem::take(word));
97 *in_word = false;
98 }
99 };
100 while let Some(c) = chars.next() {
101 match c {
102 '\'' => {
103 in_word = true;
104 loop {
105 match chars.next() {
106 Some('\'') => break,
107 Some(c) => word.push(c),
108 None => return None,
109 }
110 }
111 }
112 '"' => {
113 in_word = true;
114 loop {
115 match chars.next() {
116 Some('"') => break,
117 Some('\\') => {
118 if let Some(c) = chars.next() {
119 word.push(c);
120 }
121 }
122 Some(c) => word.push(c),
123 None => return None,
124 }
125 }
126 }
127 '\\' => {
128 in_word = true;
129 if let Some(c) = chars.next() {
130 word.push(c);
131 }
132 }
133 ';' | '&' | '|' | '\n' => {
134 flush(&mut word, &mut in_word, &mut words);
135 if matches!(c, '&' | '|') && chars.peek() == Some(&c) {
136 chars.next();
137 }
138 if !words.is_empty() {
139 parts.push(std::mem::take(&mut words));
140 }
141 }
142 '>' | '<' => {
143 flush(&mut word, &mut in_word, &mut words);
144 let mut op = c.to_string();
145 if chars.peek() == Some(&'>') {
146 chars.next();
147 op.push('>');
148 }
149 words.push(op);
150 }
151 c if c.is_whitespace() => flush(&mut word, &mut in_word, &mut words),
152 c => {
153 in_word = true;
154 word.push(c);
155 }
156 }
157 }
158 flush(&mut word, &mut in_word, &mut words);
159 if !words.is_empty() {
160 parts.push(words);
161 }
162 Some(parts)
163}
164
165fn substitutions(command: &str) -> Vec<String> {
167 let mut found = Vec::new();
168 let bytes = command.as_bytes();
169 let mut i = 0;
170 while i < bytes.len() {
171 if matches!(bytes[i], b'$' | b'<' | b'>') && bytes.get(i + 1) == Some(&b'(') {
173 let start = i + 2;
174 let mut depth = 1usize;
175 let mut j = start;
176 while j < bytes.len() && depth > 0 {
177 match bytes[j] {
178 b'(' => depth += 1,
179 b')' => depth -= 1,
180 _ => {}
181 }
182 j += 1;
183 }
184 let end = if depth == 0 { j - 1 } else { bytes.len() };
185 found.push(command[start..end].to_owned());
186 i = j;
187 } else if bytes[i] == b'`' {
188 let start = i + 1;
189 let end = command[start..]
190 .find('`')
191 .map_or(bytes.len(), |offset| start + offset);
192 found.push(command[start..end].to_owned());
193 i = end + 1;
194 } else {
195 i += 1;
196 }
197 }
198 found
199}
200
201fn base(word: &str) -> &str {
202 word.rsplit('/').next().unwrap_or(word)
203}
204
205fn part_matches(words: &[String], depth: usize, downloads: &mut Vec<String>) -> bool {
206 let (words, redirect) = split_redirect(words);
207 let words = unwrap(words);
208 let Some(head) = words.first() else {
209 return false;
210 };
211 let head = base(head);
212 let arg = |n: usize| words.get(n).map(String::as_str).unwrap_or_default();
213 let matched = match head {
214 "sh" | "bash" | "zsh" | "dash" | "ksh" => {
215 match words.iter().skip(1).position(|word| is_command_flag(word)) {
217 Some(at) => words
218 .iter()
219 .skip(at + 2)
220 .find(|word| !word.starts_with(['-', '+']))
221 .is_some_and(|script| scan(script, depth + 1, downloads) == Scan::Match),
222 None => false,
224 }
225 }
226 "eval" | "gh" | "glab" | "hub" | "jira" | "linear" | "http" | "https" | "xh" | "ssh"
230 | "nc" | "ncat" | "netcat" | "socat" | "telnet" | "aria2c" | "sftp" | "ftp" => true,
231 "scp" | "rsync" => {
234 let operands: Vec<&String> = words
235 .iter()
236 .skip(1)
237 .filter(|word| !word.starts_with('-'))
238 .collect();
239 let remote = operands.iter().any(|word| is_remote_operand(word));
240 if remote
241 && let Some(target) = operands.last()
242 && !is_remote_operand(target)
243 {
244 downloads.push((*target).clone());
245 }
246 remote
247 }
248 "curl" => {
249 downloads.extend(curl_outputs(words));
250 true
251 }
252 "wget" => {
253 downloads.extend(wget_outputs(words));
254 true
255 }
256 "npm" => matches!(arg(1), "view" | "info" | "show" | "v"),
257 "pnpm" => matches!(arg(1), "view" | "info"),
258 "yarn" => matches!(arg(1), "info" | "npm"),
259 "cargo" => matches!(arg(1), "search" | "info"),
260 "pip" | "pip3" => matches!(arg(1), "download" | "index"),
261 "git" => git_foreign(words),
262 "python" | "python3" | "node" | "ruby" | "perl" | "deno" | "bun" => words
263 .iter()
264 .skip(1)
265 .any(|word| word.contains("http://") || word.contains("https://")),
266 _ => false,
267 };
268 if matched && let Some(target) = redirect {
269 downloads.push(target);
270 }
271 matched
272}
273
274fn split_redirect(words: &[String]) -> (&[String], Option<String>) {
276 match words.iter().position(|word| word == ">" || word == ">>") {
277 Some(at) => (&words[..at], words.get(at + 1).cloned()),
278 None => (words, None),
279 }
280}
281
282fn unwrap(mut words: &[String]) -> &[String] {
284 loop {
285 let Some(head) = words.first() else {
286 return words;
287 };
288 if is_assignment(head) {
289 words = &words[1..];
290 continue;
291 }
292 let skip = match base(head) {
293 "env" => {
294 1 + words[1..]
295 .iter()
296 .take_while(|word| word.starts_with('-') || is_assignment(word))
297 .count()
298 }
299 "timeout" => {
300 let mut n = 1;
302 while let Some(word) = words.get(n) {
303 if matches!(word.as_str(), "-s" | "--signal" | "-k" | "--kill-after") {
304 n += 2;
305 } else if word.starts_with('-') {
306 n += 1;
307 } else {
308 break;
309 }
310 }
311 n + 1
312 }
313 "nice" => {
314 let mut n = 1;
315 while let Some(word) = words.get(n) {
316 if word == "-n" {
317 n += 2;
318 } else if word.starts_with('-') {
319 n += 1;
320 } else {
321 break;
322 }
323 }
324 n
325 }
326 wrapper @ ("sudo" | "xargs" | "nohup" | "command" | "exec" | "time" | "stdbuf"
327 | "doas") => {
328 let takes_value = option_values(wrapper);
331 let mut n = 1;
332 while let Some(word) = words.get(n) {
333 if word == "--" {
334 n += 1;
335 break;
336 } else if takes_value.contains(&word.as_str()) {
337 n += 2;
338 } else if word.starts_with('-') {
339 n += 1;
340 } else {
341 break;
342 }
343 }
344 n
345 }
346 _ => return words,
347 };
348 words = words.get(skip..).unwrap_or_default();
349 }
350}
351
352fn option_values(wrapper: &str) -> &'static [&'static str] {
354 match wrapper {
355 "sudo" => &[
356 "-u",
357 "-g",
358 "-h",
359 "-p",
360 "-C",
361 "-D",
362 "-r",
363 "-t",
364 "-T",
365 "-U",
366 "--user",
367 "--group",
368 "--host",
369 "--prompt",
370 "--close-from",
371 "--chdir",
372 "--role",
373 "--type",
374 "--command-timeout",
375 "--other-user",
376 ],
377 "xargs" => &[
378 "-n",
379 "-L",
380 "-P",
381 "-s",
382 "-d",
383 "-E",
384 "-I",
385 "-a",
386 "--max-args",
387 "--max-lines",
388 "--max-procs",
389 "--max-chars",
390 "--delimiter",
391 "--arg-file",
392 "--process-slot-var",
393 ],
394 "doas" => &["-u", "-C"],
395 "exec" => &["-a"],
396 "time" => &["-f", "-o", "--format", "--output"],
397 "stdbuf" => &["-i", "-o", "-e", "--input", "--output", "--error"],
398 _ => &[],
399 }
400}
401
402fn is_command_flag(word: &str) -> bool {
404 word.strip_prefix('-')
405 .is_some_and(|flags| !flags.starts_with('-') && flags.contains('c'))
406}
407
408fn is_remote_operand(word: &str) -> bool {
412 if word.contains("://") {
413 return true;
414 }
415 word.find(':')
416 .is_some_and(|at| at > 0 && !word[..at].contains('/'))
417}
418
419fn is_assignment(word: &str) -> bool {
420 word.split_once('=').is_some_and(|(name, _)| {
421 !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
422 })
423}
424
425fn git_foreign(words: &[String]) -> bool {
426 let sub = words.get(1).map(String::as_str).unwrap_or_default();
427 if !matches!(sub, "log" | "show" | "diff") {
428 return false;
429 }
430 words
432 .iter()
433 .skip(2)
434 .flat_map(|word| word.split(".."))
435 .map(|word| word.trim_start_matches('.'))
436 .any(|word| {
437 word.contains("FETCH_HEAD")
438 || (word.starts_with("remotes/") && !word.starts_with("remotes/origin/"))
439 || (word.starts_with("refs/remotes/") && !word.starts_with("refs/remotes/origin/"))
440 })
441}
442
443fn curl_outputs(words: &[String]) -> Vec<String> {
444 let mut out = Vec::new();
445 let mut remote_name = false;
446 let mut iter = words.iter().skip(1).peekable();
447 let mut urls = Vec::new();
448 while let Some(word) = iter.next() {
449 match word.as_str() {
450 "-o" | "--output" => out.extend(iter.next().cloned()),
451 "-O" | "--remote-name" => remote_name = true,
452 w if w.starts_with("--output=") => out.push(w["--output=".len()..].to_owned()),
453 w if w.starts_with("-o") && w.len() > 2 && !w.starts_with("--") => {
454 out.push(w[2..].to_owned());
455 }
456 w if w.contains("://") => urls.push(w.to_owned()),
457 _ => {}
458 }
459 }
460 if remote_name {
461 out.extend(urls.iter().filter_map(|url| url_file_name(url)));
462 }
463 out
464}
465
466fn wget_outputs(words: &[String]) -> Vec<String> {
467 let mut out = Vec::new();
468 let mut named = false;
469 let mut iter = words.iter().skip(1);
470 let mut urls = Vec::new();
471 while let Some(word) = iter.next() {
472 match word.as_str() {
473 "-O" | "--output-document" => {
474 named = true;
475 out.extend(iter.next().cloned().filter(|target| target != "-"));
476 }
477 w if w.starts_with("--output-document=") => {
478 named = true;
479 out.push(w["--output-document=".len()..].to_owned());
480 }
481 w if w.contains("://") => urls.push(w.to_owned()),
482 _ => {}
483 }
484 }
485 if !named {
486 out.extend(urls.iter().filter_map(|url| url_file_name(url)));
487 }
488 out
489}
490
491fn url_file_name(url: &str) -> Option<String> {
492 let path = url.split(['?', '#']).next()?;
493 let (_, rest) = path.split_once("://")?;
494 let (_, path) = rest.split_once('/')?;
495 let name = path.rsplit('/').next()?;
496 (!name.is_empty()).then(|| name.to_owned())
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 fn matched(command: &str) -> bool {
504 classify(command).is_some()
505 }
506
507 #[test]
508 fn the_table_and_its_wrapped_forms_match() {
509 for command in [
510 "gh issue view 42",
511 "sh -c \"gh issue view 42\"",
512 "bash -c 'glab mr view 7'",
513 "cargo build && gh pr view 3",
514 "env X=1 gh issue list",
515 "X=1 gh issue list",
516 "timeout 5 curl https://example.com",
517 "timeout -s KILL 5 curl https://example.com",
518 "nice -n 5 wget https://example.com/a",
519 "sudo -E xh example.com",
520 "cat list | xargs -n1 curl",
521 "npm view left-pad",
522 "cargo search serde",
523 "pip download requests",
524 "git log FETCH_HEAD",
525 "git diff main..remotes/upstream/main",
526 "python3 -c 'import urllib; urllib.request.urlopen(\"https://x\")'",
527 "echo $(gh issue view 1)",
528 "echo `curl -s https://x`",
529 "echo \"unbalanced",
530 "eval \"$CMD\"",
531 ] {
532 assert!(matched(command), "{command}");
533 }
534 }
535
536 #[test]
537 fn the_projects_own_commands_do_not_match() {
538 for command in [
539 "cargo test",
540 "cargo build --release",
541 "git log --oneline -5",
542 "git diff HEAD~1",
543 "npm test",
544 "python3 -c 'print(1)'",
545 "ls -la && echo gh",
546 "grep -rn curl src/",
547 ] {
548 assert!(!matched(command), "{command}");
549 }
550 }
551
552 #[test]
554 fn bundled_shell_flags_are_unwrapped() {
555 assert!(matched("bash -lc 'gh issue view 1'"));
556 assert!(matched("sh -ec 'curl -s https://x.test'"));
557 assert!(matched("zsh -o pipefail -c 'gh pr view 2'"));
558 assert!(!matched("bash -lc 'cargo test'"));
559 }
560
561 #[test]
562 fn wrapper_option_values_are_not_the_command_head() {
563 assert!(matched("sudo -u bob gh issue view 1"));
564 assert!(matched("sudo --user bob curl https://x.test"));
565 assert!(matched("cat urls | xargs -n 1 curl"));
566 assert!(matched("xargs -I {} -P 4 wget {}"));
567 assert!(matched("doas -u bob gh issue list"));
568 assert!(!matched("sudo -u bob cargo build"));
569 }
570
571 #[test]
572 fn process_substitution_is_scanned() {
573 assert!(matched("diff <(curl -s https://x.test/a) local.txt"));
574 assert!(matched("tee >(gh issue comment 1 -F -) < notes.md"));
575 assert!(!matched("diff <(sort a.txt) b.txt"));
576 }
577
578 #[test]
579 fn remote_copies_and_sockets_match() {
580 for command in [
581 "scp host:/etc/motd motd.txt",
582 "rsync -av user@host:src/ dst/",
583 "rsync rsync://mirror.test/pub/x .",
584 "ssh host cat /var/log/app.log",
585 "aria2c https://x.test/file.bin",
586 "nc example.test 80",
587 ] {
588 assert!(matched(command), "{command}");
589 }
590 assert!(!matched("rsync -a src/ dst/"));
591 assert!(!matched("scp ./a.txt ./b.txt"));
592 let found = classify("scp host:/etc/motd motd.txt").unwrap();
593 assert_eq!(found.downloads, vec!["motd.txt".to_owned()]);
594 }
595
596 #[test]
597 fn downloads_name_the_written_path() {
598 let found = classify("curl -o x.md https://example.com/issue").unwrap();
599 assert_eq!(found.downloads, vec!["x.md".to_owned()]);
600 let found = classify("wget https://example.com/a/page.html").unwrap();
601 assert_eq!(found.downloads, vec!["page.html".to_owned()]);
602 let found = classify("gh issue view 1 > issue.txt").unwrap();
603 assert_eq!(found.downloads, vec!["issue.txt".to_owned()]);
604 assert!(found.origin.r#ref.starts_with("cmd:"));
605 assert_eq!(found.origin.r#ref.len(), 12);
606 assert_eq!(classify("gh issue view 1 > issue.txt"), Some(found));
607 }
608}