Skip to main content

codewhale_execpolicy/
shell_expand.rs

1//! Expand a shell command line into the set of commands a shell would run.
2//!
3//! Deny rules are the one gate that holds under `AskForApproval::Never`, so
4//! they cannot be matched against the raw command string: the string a user
5//! types and the set of commands the shell executes are different things. A
6//! command substitution runs its body (`` `rm -rf /` ``, `$(rm -rf /)`), a
7//! quoted argument executes with the quotes removed (`rm -rf "/"`), and a
8//! wrapper hands its payload straight back to a shell (`bash -c '…'`,
9//! `eval '…'`, `sudo …`).
10//!
11//! Matching one string pattern per metacharacter loses that race by
12//! construction — every new quoting or wrapping form is another bypass. This
13//! module instead tokenizes the command the way a POSIX shell word-splits it
14//! and returns *every* command line that would actually be executed, so deny
15//! rules can be matched against each one.
16//!
17//! Deliberately conservative in the deny direction: when a construct is
18//! ambiguous the expander emits extra candidate command lines rather than
19//! fewer. Over-emitting only makes deny matching stricter — `denied_prefix_matches`
20//! stays anchored at the first positional token, so an extra candidate that no
21//! rule names is inert. Under-emitting is a bypass.
22//!
23//! What it does *not* do is evaluate anything: `$VAR` is left as literal text,
24//! and single-quoted text is never treated as code (`echo '` + "`" + `rm -rf /`" +
25//! "`" + `'` really does just print). Fidelity to shell semantics is the point in
26//! both directions.
27
28use std::collections::HashSet;
29
30/// Maximum nesting depth followed through substitutions and `-c` payloads.
31const MAX_DEPTH: usize = 8;
32
33/// Upper bound on emitted command lines, so a pathological input cannot turn
34/// one policy check into unbounded work.
35const MAX_COMMANDS: usize = 256;
36
37/// How far into a command the search for a wrapper head (`bash -c`, `eval`)
38/// will walk past flags and wrapper words.
39const MAX_HEAD_SCAN: usize = 8;
40
41/// Words that prefix another command rather than being the command: the real
42/// invocation is what follows. Stripping them keeps `sudo rm -rf /` matchable
43/// by an `rm -rf /` rule.
44const PASSTHROUGH_WRAPPERS: &[&str] = &[
45    "sudo", "doas", "env", "nohup", "nice", "ionice", "time", "timeout", "stdbuf", "setsid",
46    "command", "builtin", "exec", "xargs", "unbuffer", "busybox", "chroot", "proot",
47];
48
49/// Shells whose `-c` argument is a command line to be parsed, not an operand.
50const SHELL_NAMES: &[&str] = &[
51    "sh", "bash", "zsh", "dash", "ksh", "ksh93", "mksh", "ash", "fish", "csh", "tcsh", "rbash",
52    "yash",
53];
54
55/// Returns every command line the shell would execute for `command`.
56///
57/// The raw input is always included first, so callers keep whatever matching
58/// they already did against it. Subsequent entries are the word-split, quote-
59/// stripped command lines drawn from top-level chaining, command substitutions,
60/// process substitutions, grouping, and wrapper payloads. Results are
61/// de-duplicated and order-stable.
62pub fn expanded_commands(command: &str) -> Vec<String> {
63    let mut expander = Expander {
64        out: Vec::new(),
65        seen: HashSet::new(),
66    };
67    let trimmed = command.trim();
68    if !trimmed.is_empty() {
69        expander.seen.insert(trimmed.to_string());
70        expander.out.push(trimmed.to_string());
71    }
72    expander.expand(command, 0);
73    expander.out
74}
75
76struct Expander {
77    out: Vec<String>,
78    seen: HashSet<String>,
79}
80
81impl Expander {
82    fn emit(&mut self, tokens: &[String]) {
83        if self.out.len() >= MAX_COMMANDS {
84            return;
85        }
86        let joined = tokens
87            .iter()
88            .filter(|token| !token.is_empty())
89            .cloned()
90            .collect::<Vec<_>>()
91            .join(" ");
92        if joined.is_empty() {
93            return;
94        }
95        if self.seen.insert(joined.clone()) {
96            self.out.push(joined);
97        }
98    }
99
100    /// Word-split `input` into command lines and record each one, recursing
101    /// into every nested command text found along the way.
102    fn expand(&mut self, input: &str, depth: usize) {
103        if depth > MAX_DEPTH || self.out.len() >= MAX_COMMANDS {
104            return;
105        }
106        let chars: Vec<char> = input.chars().collect();
107        let n = chars.len();
108        let mut i = 0usize;
109        let mut commands: Vec<Vec<String>> = Vec::new();
110        let mut words: Vec<String> = Vec::new();
111        let mut word = String::new();
112        let mut started = false;
113        let mut nested: Vec<String> = Vec::new();
114
115        while i < n {
116            let c = chars[i];
117            match c {
118                // A backslash outside quotes escapes exactly one character,
119                // including an operator: `echo a\;b` is one word, not two
120                // commands. A backslash-newline is a line continuation.
121                '\\' => {
122                    if i + 1 < n {
123                        if chars[i + 1] != '\n' {
124                            word.push(chars[i + 1]);
125                            started = true;
126                        }
127                        i += 2;
128                    } else {
129                        i += 1;
130                    }
131                }
132                // Single quotes are fully literal: no substitution, no escapes.
133                '\'' => {
134                    started = true;
135                    i += 1;
136                    while i < n && chars[i] != '\'' {
137                        word.push(chars[i]);
138                        i += 1;
139                    }
140                    i = (i + 1).min(n);
141                }
142                // Double quotes suppress word splitting but NOT substitution.
143                '"' => {
144                    started = true;
145                    i += 1;
146                    while i < n && chars[i] != '"' {
147                        match chars[i] {
148                            '\\' if i + 1 < n => {
149                                word.push(chars[i + 1]);
150                                i += 2;
151                            }
152                            '`' => {
153                                let (inner, next) = read_backtick(&chars, i);
154                                nested.push(inner);
155                                i = next;
156                            }
157                            '$' if i + 1 < n && chars[i + 1] == '(' => {
158                                let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
159                                nested.push(inner);
160                                i = next;
161                            }
162                            '$' if i + 1 < n && chars[i + 1] == '{' => {
163                                let (inner, next) = read_delimited(&chars, i + 1, '{', '}');
164                                nested.push(inner);
165                                i = next;
166                            }
167                            ch => {
168                                word.push(ch);
169                                i += 1;
170                            }
171                        }
172                    }
173                    i = (i + 1).min(n);
174                }
175                // `$'…'` (ANSI-C quoting) is literal text with C escapes.
176                '$' if i + 1 < n && chars[i + 1] == '\'' => {
177                    started = true;
178                    i += 2;
179                    while i < n && chars[i] != '\'' {
180                        if chars[i] == '\\' && i + 1 < n {
181                            word.push(chars[i + 1]);
182                            i += 2;
183                        } else {
184                            word.push(chars[i]);
185                            i += 1;
186                        }
187                    }
188                    i = (i + 1).min(n);
189                }
190                // Command substitution, both spellings. The body is a command
191                // line in its own right; the substitution contributes no text
192                // to the enclosing word (we do not evaluate output).
193                '`' => {
194                    let (inner, next) = read_backtick(&chars, i);
195                    nested.push(inner);
196                    i = next;
197                }
198                '$' if i + 1 < n && chars[i + 1] == '(' => {
199                    let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
200                    nested.push(inner);
201                    i = next;
202                }
203                // `${…}` is an expansion, not a command — but it can *contain*
204                // one (`${x:-$(rm -rf /)}`), so the body is rescanned.
205                '$' if i + 1 < n && chars[i + 1] == '{' => {
206                    let (inner, next) = read_delimited(&chars, i + 1, '{', '}');
207                    nested.push(inner);
208                    i = next;
209                }
210                // Process substitution `<(…)` / `>(…)` also runs its body.
211                '<' | '>' if i + 1 < n && chars[i + 1] == '(' => {
212                    let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
213                    nested.push(inner);
214                    i = next;
215                }
216                ' ' | '\t' => {
217                    flush_word(&mut words, &mut word, &mut started);
218                    i += 1;
219                }
220                // A subshell boundary. `$(`, `<(` and `>(` were consumed by the
221                // arms above, so a bare paren here is grouping: the body is a
222                // command list of its own, not part of the surrounding word.
223                '(' | ')' => {
224                    flush_word(&mut words, &mut word, &mut started);
225                    end_command(&mut commands, &mut words);
226                    i += 1;
227                }
228                // Control operators end the current command line. `&&`, `||`,
229                // `;;`, `|&` and runs of newlines collapse into one break.
230                '\n' | '\r' | ';' | '&' | '|' => {
231                    flush_word(&mut words, &mut word, &mut started);
232                    end_command(&mut commands, &mut words);
233                    i += 1;
234                    while i < n && matches!(chars[i], '\n' | '\r' | ';' | '&' | '|') {
235                        i += 1;
236                    }
237                }
238                _ => {
239                    word.push(c);
240                    started = true;
241                    i += 1;
242                }
243            }
244        }
245        flush_word(&mut words, &mut word, &mut started);
246        end_command(&mut commands, &mut words);
247
248        for tokens in &commands {
249            self.record(tokens, depth);
250        }
251        for inner in nested {
252            self.expand(&inner, depth + 1);
253        }
254    }
255
256    /// Record one word-split command line, plus the invocation hiding inside it
257    /// when the head is a wrapper.
258    fn record(&mut self, tokens: &[String], depth: usize) {
259        if tokens.is_empty() {
260            return;
261        }
262        self.emit(tokens);
263
264        // `sudo rm -rf /` is an `rm -rf /`. Strip wrapper words (and the scalar
265        // arguments that belong to them, e.g. `timeout 5`) and emit what's left.
266        let stripped = strip_leading_wrappers(tokens);
267        if stripped.len() != tokens.len() {
268            self.emit(stripped);
269        }
270
271        // `eval …` and `sh -c …` take a *command line* as data. Parse it.
272        if let Some(head) = find_wrapper_head(tokens) {
273            let name = basename(&tokens[head]).to_ascii_lowercase();
274            if name == "eval" {
275                let payload = tokens[head + 1..].join(" ");
276                self.expand(&payload, depth + 1);
277            } else if let Some(script) = shell_c_argument(&tokens[head..]) {
278                self.expand(script, depth + 1);
279            }
280        }
281    }
282}
283
284fn flush_word(words: &mut Vec<String>, word: &mut String, started: &mut bool) {
285    if *started || !word.is_empty() {
286        words.push(std::mem::take(word));
287        *started = false;
288    }
289}
290
291fn end_command(commands: &mut Vec<Vec<String>>, words: &mut Vec<String>) {
292    // `{` and `}` stand alone as reserved words in `{ cmd; }` — they group a
293    // command rather than being part of one. Dropping them here keeps every
294    // downstream consumer (wrapper detection, emission) looking at real
295    // command words only.
296    words.retain(|word| !matches!(word.as_str(), "{" | "}"));
297    if !words.is_empty() {
298        commands.push(std::mem::take(words));
299    }
300}
301
302/// Read a backtick substitution. `start` indexes the opening backtick; returns
303/// the body and the index just past the closing backtick.
304fn read_backtick(chars: &[char], start: usize) -> (String, usize) {
305    let mut i = start + 1;
306    let mut inner = String::new();
307    while i < chars.len() {
308        match chars[i] {
309            '\\' if i + 1 < chars.len() => {
310                inner.push(chars[i]);
311                inner.push(chars[i + 1]);
312                i += 2;
313            }
314            '`' => return (inner, i + 1),
315            c => {
316                inner.push(c);
317                i += 1;
318            }
319        }
320    }
321    (inner, i)
322}
323
324/// Read a balanced `open`/`close` region. `open_at` indexes the opening
325/// delimiter; returns the body and the index just past the matching close.
326fn read_delimited(chars: &[char], open_at: usize, open: char, close: char) -> (String, usize) {
327    let mut depth = 1usize;
328    let mut i = open_at + 1;
329    let mut inner = String::new();
330    while i < chars.len() {
331        let c = chars[i];
332        if c == '\\' && i + 1 < chars.len() {
333            inner.push(c);
334            inner.push(chars[i + 1]);
335            i += 2;
336            continue;
337        }
338        if c == open {
339            depth += 1;
340        } else if c == close {
341            depth -= 1;
342            if depth == 0 {
343                return (inner, i + 1);
344            }
345        }
346        inner.push(c);
347        i += 1;
348    }
349    (inner, i)
350}
351
352/// The final path component, so `/usr/bin/sudo` reads as `sudo`.
353fn basename(token: &str) -> &str {
354    token
355        .rsplit(['/', '\\'])
356        .next()
357        .filter(|part| !part.is_empty())
358        .unwrap_or(token)
359}
360
361fn is_env_assignment(token: &str) -> bool {
362    match token.split_once('=') {
363        Some((name, _)) => {
364            !name.is_empty()
365                && !name.starts_with('-')
366                && name
367                    .chars()
368                    .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
369        }
370        None => false,
371    }
372}
373
374/// True for a bare scalar operand that belongs to a wrapper word rather than
375/// starting a command — `timeout 5`, `nice -n 10`, `timeout 1.5s`.
376fn is_scalar_operand(token: &str) -> bool {
377    let body = token.trim_end_matches(['s', 'm', 'h', 'd']);
378    !body.is_empty() && body.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
379}
380
381fn is_passthrough_wrapper(token: &str) -> bool {
382    let name = basename(token).to_ascii_lowercase();
383    PASSTHROUGH_WRAPPERS.contains(&name.as_str())
384}
385
386fn is_shell_name(token: &str) -> bool {
387    let name = basename(token).to_ascii_lowercase();
388    SHELL_NAMES.contains(&name.as_str())
389}
390
391/// Drop leading environment assignments, wrapper words, and the scalar operands
392/// those wrappers take, returning the remaining slice.
393///
394/// Flags are deliberately *not* dropped: `denied_prefix_matches` already skips
395/// unrelated flags (and, ambiguously, their values) when anchoring a rule, so
396/// leaving `-u root` in place is both correct and matchable.
397fn strip_leading_wrappers(tokens: &[String]) -> &[String] {
398    let mut start = 0usize;
399    let mut dropped_wrapper = false;
400    while start < tokens.len() {
401        let token = &tokens[start];
402        if is_env_assignment(token) {
403            start += 1;
404        } else if is_passthrough_wrapper(token) {
405            dropped_wrapper = true;
406            start += 1;
407        } else if dropped_wrapper && is_scalar_operand(token) {
408            start += 1;
409        } else {
410            break;
411        }
412    }
413    &tokens[start..]
414}
415
416/// Index of the `eval` / shell word that introduces a nested command line, if
417/// this invocation has one.
418///
419/// The scan walks past environment assignments, wrapper words, flags, and the
420/// operand immediately following a single-dash flag (which may be that flag's
421/// value, as in `sudo -u root bash -c …`). It stops at the first token that
422/// cannot plausibly precede the real command, which is what keeps
423/// `echo bash -c 'rm -rf /'` — a command that only prints — from being read as
424/// a shell invocation.
425fn find_wrapper_head(tokens: &[String]) -> Option<usize> {
426    let mut previous_was_short_flag = false;
427    for (index, token) in tokens.iter().enumerate().take(MAX_HEAD_SCAN) {
428        if is_shell_name(token) || basename(token).eq_ignore_ascii_case("eval") {
429            return Some(index);
430        }
431        let skippable = is_env_assignment(token)
432            || is_passthrough_wrapper(token)
433            || token.starts_with('-')
434            || is_scalar_operand(token)
435            || previous_was_short_flag;
436        if !skippable {
437            return None;
438        }
439        previous_was_short_flag = token.starts_with('-') && !token.starts_with("--");
440    }
441    None
442}
443
444/// The command-line argument of a shell's `-c` flag, if present.
445///
446/// `tokens[0]` is the shell. Combined short flags count (`bash -lc '…'`).
447/// The scan deliberately does NOT stop at the first non-flag operand: an
448/// earlier version did, and `bash -o vi -c 'payload'` walked straight past
449/// the deny expander because `vi` (the argument of `-o`) ended the scan
450/// before `-c` was seen (2026-08-04 review). Continuing the scan can
451/// over-read a `-c` that is really an argument to a script
452/// (`bash script.sh -c x`), but this expander's contract is explicit that
453/// over-emitting targets is safe and under-emitting is a bypass.
454fn shell_c_argument(tokens: &[String]) -> Option<&str> {
455    let mut index = 1usize;
456    while index < tokens.len() {
457        let token = tokens[index].as_str();
458        let takes_command_line = match token.strip_prefix("--") {
459            Some(long) => long.eq_ignore_ascii_case("command"),
460            None => token
461                .strip_prefix('-')
462                .is_some_and(|flags| flags.contains('c')),
463        };
464        if takes_command_line {
465            return tokens.get(index + 1).map(String::as_str);
466        }
467        index += 1;
468    }
469    None
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    fn expand(command: &str) -> Vec<String> {
477        expanded_commands(command)
478    }
479
480    fn contains(command: &str, expected: &str) -> bool {
481        expand(command).iter().any(|target| target == expected)
482    }
483
484    #[test]
485    fn backtick_body_is_a_command() {
486        assert!(contains("`rm -rf /`", "rm -rf /"));
487        assert!(contains("echo `rm -rf /`", "rm -rf /"));
488        assert!(contains("echo `rm -rf /`", "echo"));
489    }
490
491    #[test]
492    fn dollar_paren_body_is_a_command() {
493        assert!(contains("echo $(rm -rf /)", "rm -rf /"));
494        assert!(contains("x=$(rm -rf /)", "rm -rf /"));
495        assert!(contains("echo \"$(rm -rf /)\"", "rm -rf /"));
496    }
497
498    #[test]
499    fn nested_substitution_is_followed() {
500        assert!(contains("echo $(echo `rm -rf /`)", "rm -rf /"));
501    }
502
503    #[test]
504    fn quotes_are_removed_from_operands() {
505        assert!(contains("rm -rf \"/\"", "rm -rf /"));
506        assert!(contains("rm -rf '/'", "rm -rf /"));
507        assert!(contains("\"rm\" -rf /", "rm -rf /"));
508        assert!(contains("rm -r\"f\" /", "rm -rf /"));
509    }
510
511    #[test]
512    fn single_quoted_text_is_not_a_command() {
513        // A literal backtick inside single quotes is printed, not executed.
514        let targets = expand("echo '`rm -rf /`'");
515        assert!(
516            !targets.iter().any(|t| t == "rm -rf /"),
517            "single-quoted text must not become a command: {targets:?}"
518        );
519    }
520
521    #[test]
522    fn escaped_operators_do_not_split() {
523        let targets = expand("echo a\\;b");
524        assert_eq!(targets.len(), 2, "{targets:?}");
525        assert!(targets.contains(&"echo a;b".to_string()), "{targets:?}");
526    }
527
528    #[test]
529    fn control_operators_split_commands() {
530        for command in [
531            "ls && rm -rf /",
532            "ls || rm -rf /",
533            "ls ; rm -rf /",
534            "ls | rm -rf /",
535            "ls & rm -rf /",
536            "ls\nrm -rf /",
537        ] {
538            assert!(contains(command, "rm -rf /"), "{command}");
539        }
540    }
541
542    #[test]
543    fn wrappers_and_payloads_are_unwrapped() {
544        for command in [
545            "sudo rm -rf /",
546            "env rm -rf /",
547            "timeout 5 rm -rf /",
548            "nohup rm -rf /",
549            "xargs rm -rf /",
550            "/usr/bin/sudo rm -rf /",
551            "eval 'rm -rf /'",
552            "bash -c 'rm -rf /'",
553            "sh -lc \"rm -rf /\"",
554            "sudo -u root bash -c 'rm -rf /'",
555            // 2026-08-04: `-o vi` used to end the flag scan before `-c` was
556            // seen, so the payload skipped deny expansion entirely.
557            "bash -o vi -c 'rm -rf /'",
558            "zsh --norcs -c 'rm -rf /'",
559        ] {
560            assert!(
561                contains(command, "rm -rf /"),
562                "{command}: {:?}",
563                expand(command)
564            );
565        }
566    }
567
568    #[test]
569    fn wrapper_head_scan_stops_at_a_real_command() {
570        // `echo` prints its arguments; nothing here is executed as a shell.
571        let targets = expand("echo bash -c 'rm -rf /'");
572        assert!(
573            !targets.iter().any(|t| t == "rm -rf /"),
574            "arguments of a printing command must not be parsed as code: {targets:?}"
575        );
576    }
577
578    #[test]
579    fn process_and_parameter_substitution_bodies_are_commands() {
580        assert!(contains("diff <(rm -rf /) b", "rm -rf /"));
581        assert!(contains("echo ${x:-$(rm -rf /)}", "rm -rf /"));
582    }
583
584    #[test]
585    fn expansion_is_bounded() {
586        let deep = "$(".repeat(64) + "rm -rf /" + &")".repeat(64);
587        let targets = expand(&deep);
588        assert!(targets.len() <= MAX_COMMANDS);
589    }
590
591    #[test]
592    fn grouping_is_a_command_boundary() {
593        assert!(contains("(rm -rf /)", "rm -rf /"));
594        assert!(contains("{ rm -rf /; }", "rm -rf /"));
595        assert!(contains("(cd /tmp && rm -rf /)", "rm -rf /"));
596        // Escaped and quoted parens are operands, not grouping.
597        assert!(contains(
598            "find . \\( -name a \\) -print",
599            "find . ( -name a ) -print"
600        ));
601    }
602
603    #[test]
604    fn plain_command_expands_to_itself() {
605        assert_eq!(expand("git status -s"), vec!["git status -s".to_string()]);
606    }
607}