Skip to main content

lean_ctx/core/shell_allowlist/
tokenizer.rs

1/// Tokenize a shell command segment respecting single/double quotes and backslash escapes.
2/// Returns tokens with outer quotes stripped, matching how the shell would parse them.
3/// E.g. `git -C "Program Files" status` → `["git", "-C", "Program Files", "status"]`
4pub fn shell_tokenize(input: &str) -> Vec<String> {
5    let mut tokens = Vec::new();
6    let mut current = String::new();
7    let mut chars = input.chars().peekable();
8    let mut in_single = false;
9    let mut in_double = false;
10    let mut parameter_depth: u32 = 0;
11
12    while let Some(c) = chars.next() {
13        match c {
14            '\'' if !in_double => in_single = !in_single,
15            '"' if !in_single => in_double = !in_double,
16            '\\' if !in_single => {
17                if let Some(next) = chars.next() {
18                    current.push(next);
19                }
20            }
21            '$' if !in_single && chars.peek() == Some(&'{') => {
22                parameter_depth += 1;
23                current.push(c);
24            }
25            '}' if !in_single && parameter_depth > 0 => {
26                parameter_depth -= 1;
27                current.push(c);
28            }
29            c if c.is_whitespace() && !in_single && !in_double && parameter_depth == 0 => {
30                if !current.is_empty() {
31                    tokens.push(std::mem::take(&mut current));
32                }
33            }
34            _ => current.push(c),
35        }
36    }
37    if !current.is_empty() {
38        tokens.push(current);
39    }
40    tokens
41}
42
43/// Returns the byte length of the first shell token in `input`, respecting quotes
44/// and `(...)` nesting. Used by `skip_env_assignments` to advance past env
45/// assignments with quoted values like `FOO="bar baz"` — and, critically, past
46/// assignments whose value is a command substitution like `FOO=$(cmd a b)`
47/// (#855): without paren-depth tracking, whitespace *inside* the unclosed
48/// `$(...)` looked like the end of the token, splitting `s=$(gh pr view …)`
49/// into a bogus token `s=$(gh` plus a leftover `pr` that got misread as the
50/// base command.
51pub(super) fn quote_aware_token_end(input: &str) -> usize {
52    let bytes = input.as_bytes();
53    let len = bytes.len();
54    let mut i = 0;
55    let mut in_single = false;
56    let mut in_double = false;
57    let mut paren_depth: u32 = 0;
58    let mut parameter_depth: u32 = 0;
59
60    while i < len {
61        let ch = bytes[i];
62        match ch {
63            b'\'' if !in_double => {
64                in_single = !in_single;
65                i += 1;
66            }
67            b'"' if !in_single => {
68                in_double = !in_double;
69                i += 1;
70            }
71            b'\\' if !in_single => {
72                i = (i + 2).min(len);
73            }
74            b'(' if !in_single && !in_double => {
75                paren_depth += 1;
76                i += 1;
77            }
78            b')' if !in_single && !in_double && paren_depth > 0 => {
79                paren_depth -= 1;
80                i += 1;
81            }
82            b'$' if !in_single && !in_double && bytes.get(i + 1) == Some(&b'{') => {
83                parameter_depth += 1;
84                i += 1;
85            }
86            b'}' if !in_single && parameter_depth > 0 => {
87                parameter_depth -= 1;
88                i += 1;
89            }
90            b if b.is_ascii_whitespace()
91                && !in_single
92                && !in_double
93                && paren_depth == 0
94                && parameter_depth == 0 =>
95            {
96                return i;
97            }
98            _ => i += 1,
99        }
100    }
101    len
102}
103/// Extract ALL command segments from a compound shell command.
104/// Splits on: &&, ||, ;, | (pipe), and handles subshell grouping.
105pub(super) fn extract_all_commands(command: &str) -> Vec<String> {
106    split_on_operators(command)
107        .into_iter()
108        .map(|s| s.trim().to_string())
109        .filter(|s| !s.is_empty())
110        .collect()
111}
112
113/// Split command string on shell operators: ;, &&, ||, |
114/// Respects single/double quotes, parentheses nesting, and backslash escapes
115/// outside single quotes (GL #1160): `rg split\.label\|quantityLabel` is ONE
116/// command — the escaped pipe is regex data, not an operator. The old scanner
117/// split there and blocked the pattern fragment as an unknown command; same
118/// for `find … -exec rm {} \;`.
119pub(super) fn split_on_operators(command: &str) -> Vec<&str> {
120    let mut segments = Vec::new();
121    let mut start = 0;
122    let bytes = command.as_bytes();
123    let len = bytes.len();
124    let mut i = 0;
125    let mut in_single_quote = false;
126    let mut in_double_quote = false;
127    let mut paren_depth: u32 = 0;
128    // #939: brace groups (`{ cmd; }`) need the same operator-shielding as
129    // `( cmd )` subshells — otherwise a `}` that closes a `{` opened on an
130    // earlier physical line (e.g. after heredoc-body stripping collapses the
131    // body between them) is misread as its own bare command segment.
132    let mut brace_depth: u32 = 0;
133
134    while i < len {
135        let ch = bytes[i];
136
137        if in_single_quote {
138            if ch == b'\'' {
139                in_single_quote = false;
140            }
141            i += 1;
142            continue;
143        }
144
145        if in_double_quote {
146            match ch {
147                // \" stays inside the string; \\ consumes both so `"x\\"` closes.
148                b'\\' => i = (i + 2).min(len),
149                b'"' => {
150                    in_double_quote = false;
151                    i += 1;
152                }
153                _ => i += 1,
154            }
155            continue;
156        }
157
158        match ch {
159            b'\\' => {
160                // Escaped char is data (bash semantics outside quotes) — never
161                // an operator or quote opener.
162                i = (i + 2).min(len);
163            }
164            b'\'' => {
165                in_single_quote = true;
166                i += 1;
167            }
168            b'"' => {
169                in_double_quote = true;
170                i += 1;
171            }
172            b'(' => {
173                paren_depth += 1;
174                i += 1;
175            }
176            b')' => {
177                paren_depth = paren_depth.saturating_sub(1);
178                i += 1;
179            }
180            b'{' => {
181                brace_depth += 1;
182                i += 1;
183            }
184            b'}' => {
185                brace_depth = brace_depth.saturating_sub(1);
186                i += 1;
187            }
188            b'\n' | b'\r' | b';' if paren_depth == 0 && brace_depth == 0 => {
189                segments.push(&command[start..i]);
190                i += 1;
191                start = i;
192            }
193            b'&' if paren_depth == 0 && brace_depth == 0 => {
194                if i + 1 < len && bytes[i + 1] == b'&' {
195                    // &&
196                    segments.push(&command[start..i]);
197                    i += 2;
198                    start = i;
199                } else if (i > 0 && bytes[i - 1] == b'>') || (i + 1 < len && bytes[i + 1] == b'>') {
200                    // Redirect operator, NOT a separator: `2>&1`, `1>&2`, `>&file` (prev is '>')
201                    // or `&>file`, `&>>file` (next is '>'). The '&' belongs to the current
202                    // command — splitting here would mistake the fd/target (e.g. `1`) for a
203                    // standalone command and falsely block it (#334).
204                    i += 1;
205                } else {
206                    // single & (background operator) — still a command separator
207                    segments.push(&command[start..i]);
208                    i += 1;
209                    start = i;
210                }
211            }
212            b'|' if paren_depth == 0 && brace_depth == 0 => {
213                if i + 1 < len && bytes[i + 1] == b'|' {
214                    // ||
215                    segments.push(&command[start..i]);
216                    i += 2;
217                    start = i;
218                } else if i > 0 && bytes[i - 1] == b'>' {
219                    // `>|` (noclobber redirect), NOT a pipe: the '|' belongs to
220                    // the redirect operator and the following token is a file
221                    // path, not a command. Splitting here treated the target
222                    // (e.g. `out` in `date >| out`) as a command and falsely
223                    // blocked it against the allowlist (#387).
224                    i += 1;
225                } else {
226                    // pipe
227                    segments.push(&command[start..i]);
228                    i += 1;
229                    start = i;
230                }
231            }
232            _ => {
233                i += 1;
234            }
235        }
236    }
237
238    if start < len {
239        segments.push(&command[start..]);
240    }
241
242    segments
243}
244
245/// Extract the base command name from a single segment (no operators).
246pub(super) fn extract_base_from_segment(segment: &str) -> String {
247    let trimmed = segment.trim();
248    if trimmed.is_empty() {
249        return String::new();
250    }
251
252    let cmd_part = skip_env_assignments(trimmed);
253    if cmd_part.is_empty() {
254        return String::new();
255    }
256
257    let tokens = shell_tokenize(cmd_part);
258    // #939: a leading `{` brace-group token (e.g. from
259    // `agent_wrapper::rebuild`'s `{ <real command>\n} && pwd ...` wrapping)
260    // is not itself a command — skip it so the base extracted is the real
261    // command inside the group, not the brace.
262    let mut token_iter = tokens.iter();
263    let first_token = match token_iter.next().map(String::as_str) {
264        Some("{") => token_iter.next().map_or("", String::as_str),
265        other => other.unwrap_or(""),
266    };
267
268    first_token
269        .rsplit('/')
270        .next()
271        .unwrap_or(first_token)
272        .to_string()
273}
274
275/// Shell builtins that legitimately export or mutate environment variables.
276/// A segment beginning with one of these (`export PATH=…`, `readonly FOO=bar`)
277/// is not a bare inline `PATH=… cmd` hijack — skip the builtin and any
278/// following `VAR=value` tokens so export-only segments contribute no leaf
279/// command and `export PATH=… ; python3 …` resolves to `python3`.
280const ENV_SETTING_BUILTINS: &[&str] =
281    &["export", "unset", "readonly", "local", "declare", "typeset"];
282
283/// Skip leading KEY=VALUE environment variable assignments.
284/// Uses quote-aware scanning so `FOO="bar baz" git status` correctly
285/// skips the entire `FOO="bar baz"` token.
286pub(super) fn skip_env_assignments(segment: &str) -> &str {
287    let mut rest = segment;
288    loop {
289        let rest_trimmed = rest.trim_start();
290        if rest_trimmed.is_empty() {
291            return rest_trimmed;
292        }
293        let end = quote_aware_token_end(rest_trimmed);
294        if end == 0 {
295            return rest_trimmed;
296        }
297        let raw_token = &rest_trimmed[..end];
298        let unquoted: String = raw_token
299            .chars()
300            .filter(|c| *c != '"' && *c != '\'')
301            .collect();
302        let base_token = unquoted.rsplit('/').next().unwrap_or(unquoted.as_str());
303        if ENV_SETTING_BUILTINS.contains(&base_token) {
304            rest = &rest_trimmed[end..];
305            continue;
306        }
307        if unquoted.contains('=')
308            && !unquoted.starts_with('-')
309            && !unquoted.starts_with('/')
310            && !unquoted.starts_with('.')
311        {
312            rest = &rest_trimmed[end..];
313        } else {
314            return rest_trimmed;
315        }
316    }
317}
318/// Public accessor for extracting all command segments.
319pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
320    extract_all_commands(command)
321}
322// Legacy compat: single-segment extraction (used by other callers)
323pub fn extract_base_command(command: &str) -> String {
324    let first_seg = split_on_operators(command)
325        .into_iter()
326        .next()
327        .unwrap_or(command);
328    extract_base_from_segment(first_seg)
329}