Skip to main content

lean_ctx/core/patterns/
tool_output.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `tool_output` classification of shell commands (pz21 §1.8, Q6 a).
3//!
4//! A command whose output comes from outside the repository (an issue
5//! tracker CLI, a network fetch, a package registry query, a foreign ref)
6//! delivers untrusted text. The table below names those commands by head;
7//! wrapped and compound forms are unwrapped first (`sh -c`, `env`,
8//! `timeout`, `nice`, `sudo`, `xargs`, and the parts of `;`, `&&`, `||` and
9//! `|`). Any matching part makes the whole output `tool_output`. A command
10//! that cannot be parsed (unbalanced quotes, `eval`) fails closed: it is
11//! `tool_output` too. The project's own build and test commands never match.
12//!
13//! The reference is `cmd:<blake3(command)[..8]>`: deterministic and
14//! content-free. A download (`curl -o x`, `wget -O x`, a matching command
15//! redirected into a file) also names the written path, so a later read of
16//! exactly that file in the same session is fenced (`file:<path>`).
17
18use crate::core::context_admission::origin::{InputKind, InputOrigin, SourceClass};
19
20/// What the table found for one command.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ToolOutputMatch {
23    /// `cmd:<blake3[..8]>`.
24    pub origin: InputOrigin,
25    /// Paths the command writes downloaded text to, as written in the
26    /// command (relative to its working directory).
27    pub downloads: Vec<String>,
28}
29
30impl ToolOutputMatch {
31    /// The class the command's output is admitted with.
32    pub fn class(&self) -> SourceClass {
33        SourceClass::External(self.origin.clone())
34    }
35}
36
37/// The content-free reference of a command.
38pub fn command_ref(command: &str) -> String {
39    let hash = blake3::hash(command.as_bytes()).to_hex();
40    format!("cmd:{}", &hash[..8])
41}
42
43/// Classify `command`. `None`: the output is the project's own.
44pub 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    // Command substitutions run first; a matching inner command matches.
69    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
86/// The parts of a compound command as word lists, with redirections kept as
87/// words (`>`, `>>`). `None`: not parsable (unbalanced quotes).
88fn 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
165/// Inner commands of `$(…)`, `<(…)`, `>(…)` and backticks.
166fn 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        // `$(…)`, and process substitution `<(…)` / `>(…)`.
172        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            // `-c` alone or bundled with other short options (`-lc`, `-ec`).
216            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                // A script file: not unwrapped (named residual, §3).
223                None => false,
224            }
225        }
226        // `eval` runs text the table cannot read: fail closed. Forge and
227        // HTTP clients, remote shells, raw sockets and multi-protocol
228        // downloaders: their output is the remote side's.
229        "eval" | "gh" | "glab" | "hub" | "jira" | "linear" | "http" | "https" | "xh" | "ssh"
230        | "nc" | "ncat" | "netcat" | "socat" | "telnet" | "aria2c" | "sftp" | "ftp" => true,
231        // A copy from a remote host (`host:path`, `rsync://`): the local
232        // destination holds downloaded text.
233        "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
274/// Words without a trailing output redirection, and its target.
275fn 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
282/// Strip leading assignments and wrapper commands.
283fn 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                // `timeout [options] DURATION command`; `-s`/`-k` take a value.
301                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                // Options, and the separate value of those that take one
329                // (`sudo -u bob`, `xargs -n 1`): never the command head.
330                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
352/// The options of a wrapper command that take their value as the next word.
353fn 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
402/// `-c` alone or in a bundle of short options (`-lc`, `-ec`, `-xc`).
403fn is_command_flag(word: &str) -> bool {
404    word.strip_prefix('-')
405        .is_some_and(|flags| !flags.starts_with('-') && flags.contains('c'))
406}
407
408/// An scp/rsync operand on another host: `host:path`, `user@host:path`,
409/// `host::module`, `rsync://…`. A local path with a slash before the first
410/// colon (`./a:b`) is local, as scp reads it.
411fn 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    // Every side of a range (`a..b`, `a...b`) is a ref.
431    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    /// Review 22480 finding 4: each shape read as the project's own output.
553    #[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}