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}