History

Reject shell substitutions in terminal tool commands (#51689)

Harden the terminal tool's permission system to reject commands containing shell substitutions and interpolations (`$VAR`, `${VAR}`, `$(…)`, backticks, `$((…))`, `<(…)`, `>(…)`) before they reach terminal creation. ## Changes ### Shell command parser (`shell_command_parser`) - Added structured terminal command-prefix extraction with env-var prefix support - Added parser-backed validation that classifies commands as Safe/Unsafe/Unknown - Extended normalized command extraction to include scalar env-var assignments in order - Preserved quoted assignment values when they contain whitespace or special characters ### Pattern extraction (`agent/pattern_extraction`) - Updated terminal pattern extraction to use structured parser output - Included env-var prefixes in generated allow patterns - Normalized regex token boundaries to `\s+` while preserving display whitespace ### Tool permissions (`agent/tool_permissions`) - Added invalid-terminal-command rejection for forbidden substitutions/interpolations - Added unconditional allow-all bypass (global default Allow, or terminal-specific Allow with empty patterns) - Preserved hardcoded denial precedence over allow-all ### Terminal tool (`agent/tools/terminal_tool`) - Updated tool description and input schema to explicitly prohibit shell substitutions - Added comprehensive SEC-264 regression test suite (20 new tests) covering: - All forbidden constructs (`${HOME}`, `$1`, `$?`, `$$`, `$@`, `$(whoami)`, backticks, `$((1+1))`, `<(ls)`, `>(cat)`, env-prefix variants, multiline, nested) - Allow-all exception paths (global and terminal-specific) - Hardcoded-denial precedence - Env-prefix permission flow (matching, value mismatch rejection, multiple assignments, quoted whitespace) Closes SEC-264 Release Notes: - Terminal tool permissions regexes can now match environment variables (e.g. `FOO=bar cmd arg1 arg2`) - If terminal tool permissions have active permissions regexes running on them, then bare interpolations like `$FOO` are disallowed for security, since regexes wouldn't be able to match on them.

Richard Feldman committed 7 months ago

Commit f3fb4e01 parent 977cd6a5 files · +1762 −69Browse files

Showing the changes to crates/shell_command_parser/src/shell_command_parser.rs (1 of 5 files). Show all

Unified · Split

crates/shell_command_parser/src/shell_command_parser.rs+699−6
@@ -1,8 +1,25 @@
11 use brush_parser::ast;
2+use brush_parser::ast::SourceLocation;
23 use brush_parser::word::WordPiece;
34 use brush_parser::{Parser, ParserOptions, SourceInfo};
45 use std::io::BufReader;
56
7+#[derive(Debug, Clone, PartialEq, Eq)]
8+pub 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)]
17+pub enum TerminalCommandValidation {
18+ Safe,
19+ Unsafe,
20+ Unsupported,
21+}
22+
623 pub fn extract_commands(command: &str) -> Option<Vec<String>> {
724 let reader = BufReader::new(command.as_bytes());
825 let options = ParserOptions::default();
@@ -17,6 +34,444 @@ pub fn extract_commands(command: &str) -> Option<Vec<String>> { · 8 unmodified lines
1734 Some(commands)
1835 }
1936
37+pub 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+
101+pub 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)]
120+enum TerminalProgramValidation {
121+ Safe,
122+ Unsafe,
123+ Unsupported,
124+}
125+
126+fn 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+
137+fn 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+
146+enum NormalizedAssignment {
147+ Included(String),
148+ Skipped,
149+}
150+
151+fn 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+
176+fn 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+
198+fn program_validation(program: &ast::Program) -> TerminalProgramValidation {
199+ combine_validations(
200+ program
201+ .complete_commands
202+ .iter()
203+ .map(compound_list_validation),
204+ )
205+}
206+
207+fn 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+
216+fn 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+
228+fn pipeline_validation(pipeline: &ast::Pipeline) -> TerminalProgramValidation {
229+ combine_validations(pipeline.seq.iter().map(command_validation))
230+}
231+
232+fn 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+
246+fn 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+
257+fn command_prefix_validation(prefix: &ast::CommandPrefix) -> TerminalProgramValidation {
258+ combine_validations(prefix.0.iter().map(prefix_or_suffix_item_validation))
259+}
260+
261+fn command_suffix_validation(suffix: &ast::CommandSuffix) -> TerminalProgramValidation {
262+ combine_validations(suffix.0.iter().map(prefix_or_suffix_item_validation))
263+}
264+
265+fn 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+
280+fn 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+
302+fn 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+
315+fn 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+
329+fn 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+
360+fn 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+
413+fn do_group_validation(do_group: &ast::DoGroupCommand) -> TerminalProgramValidation {
414+ compound_list_validation(&do_group.list)
415+}
416+
417+fn 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+
424+fn redirect_list_validation(redirect_list: &ast::RedirectList) -> TerminalProgramValidation {
425+ combine_validations(redirect_list.0.iter().map(io_redirect_validation))
426+}
427+
428+fn extended_test_expr_validation(
429+ test_expr: &ast::ExtendedTestExprCommand,
430+) -> TerminalProgramValidation {
431+ extended_test_expr_inner_validation(&test_expr.expr)
432+}
433+
434+fn 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+
452+fn 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+
20475 fn extract_commands_from_program(program: &ast::Program, commands: &mut Vec<String>) -> Option<()> {
21476 for complete_command in &program.complete_commands {
22477 extract_commands_from_compound_list(complete_command, commands)?;
@@ -117,12 +572,26 @@ fn extract_commands_from_simple_command( · 94 unmodified lines
117572
118573 if let Some(prefix) = &simple_command.prefix {
119574 for item in &prefix.0 {
120− if let ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) = item {
121− match normalize_io_redirect(redirect) {
122− Some(RedirectNormalization::Normalized(s)) => redirects.push(s),
123− Some(RedirectNormalization::Skip) => {}
124− None => return None,
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)?);
125593 }
594+ ast::CommandPrefixOrSuffixItem::ProcessSubstitution(_, _) => return None,
126595 }
127596 }
128597 }
@@ -142,7 +611,15 @@ fn extract_commands_from_simple_command( · 13 unmodified lines
142611 None => return None,
143612 }
144613 }
145− _ => {}
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(_, _) => {}
146623 }
147624 }
148625 }
@@ -1061,4 +1538,220 @@ mod tests { · 912 unmodified lines
10611538 let commands = extract_commands("cmd > /tmp/out 2>/dev/null").expect("parse failed");
10621539 assert_eq!(commands, vec!["cmd", "> /tmp/out"]);
10631540 }
1541+
1542+ #[test]
1543+ fn test_scalar_env_var_prefix_included_in_extracted_command() {
1544+ let commands = extract_commands("PAGER=blah git status").expect("parse failed");
1545+ assert_eq!(commands, vec!["PAGER=blah git status"]);
1546+ }
1547+
1548+ #[test]
1549+ fn test_multiple_scalar_assignments_preserved_in_order() {
1550+ let commands = extract_commands("A=1 B=2 git log").expect("parse failed");
1551+ assert_eq!(commands, vec!["A=1 B=2 git log"]);
1552+ }
1553+
1554+ #[test]
1555+ fn test_assignment_quoting_dropped_when_safe() {
1556+ let commands = extract_commands("PAGER='curl' git log").expect("parse failed");
1557+ assert_eq!(commands, vec!["PAGER=curl git log"]);
1558+ }
1559+
1560+ #[test]
1561+ fn test_assignment_quoting_preserved_for_whitespace() {
1562+ let commands = extract_commands("PAGER='less -R' git log").expect("parse failed");
1563+ assert_eq!(commands, vec!["PAGER='less -R' git log"]);
1564+ }
1565+
1566+ #[test]
1567+ fn test_assignment_quoting_preserved_for_semicolon() {
1568+ let commands = extract_commands("PAGER='a;b' git log").expect("parse failed");
1569+ assert_eq!(commands, vec!["PAGER='a;b' git log"]);
1570+ }
1571+
1572+ #[test]
1573+ fn test_array_assignments_ignored_for_prefix_matching_output() {
1574+ let commands = extract_commands("FOO=(a b) git status").expect("parse failed");
1575+ assert_eq!(commands, vec!["git status"]);
1576+ }
1577+
1578+ #[test]
1579+ fn test_extract_terminal_command_prefix_includes_env_var_prefix_and_subcommand() {
1580+ let prefix = extract_terminal_command_prefix("PAGER=blah git log --oneline")
1581+ .expect("expected terminal command prefix");
1582+
1583+ assert_eq!(
1584+ prefix,
1585+ TerminalCommandPrefix {
1586+ normalized: "PAGER=blah git log".to_string(),
1587+ display: "PAGER=blah git log".to_string(),
1588+ tokens: vec![
1589+ "PAGER=blah".to_string(),
1590+ "git".to_string(),
1591+ "log".to_string(),
1592+ ],
1593+ command: "git".to_string(),
1594+ subcommand: Some("log".to_string()),
1595+ }
1596+ );
1597+ }
1598+
1599+ #[test]
1600+ fn test_extract_terminal_command_prefix_preserves_required_assignment_quotes_in_display_and_normalized()
1601+ {
1602+ let prefix = extract_terminal_command_prefix("PAGER='less -R' git log")
1603+ .expect("expected terminal command prefix");
1604+
1605+ assert_eq!(
1606+ prefix,
1607+ TerminalCommandPrefix {
1608+ normalized: "PAGER='less -R' git log".to_string(),
1609+ display: "PAGER='less -R' git log".to_string(),
1610+ tokens: vec![
1611+ "PAGER='less -R'".to_string(),
1612+ "git".to_string(),
1613+ "log".to_string(),
1614+ ],
1615+ command: "git".to_string(),
1616+ subcommand: Some("log".to_string()),
1617+ }
1618+ );
1619+ }
1620+
1621+ #[test]
1622+ fn test_extract_terminal_command_prefix_skips_redirects_before_subcommand() {
1623+ let prefix = extract_terminal_command_prefix("git 2>/dev/null log --oneline")
1624+ .expect("expected terminal command prefix");
1625+
1626+ assert_eq!(
1627+ prefix,
1628+ TerminalCommandPrefix {
1629+ normalized: "git log".to_string(),
1630+ display: "git 2>/dev/null log".to_string(),
1631+ tokens: vec!["git".to_string(), "log".to_string()],
1632+ command: "git".to_string(),
1633+ subcommand: Some("log".to_string()),
1634+ }
1635+ );
1636+ }
1637+
1638+ #[test]
1639+ fn test_validate_terminal_command_rejects_parameter_expansion() {
1640+ assert_eq!(
1641+ validate_terminal_command("echo $HOME"),
1642+ TerminalCommandValidation::Unsafe
1643+ );
1644+ }
1645+
1646+ #[test]
1647+ fn test_validate_terminal_command_rejects_braced_parameter_expansion() {
1648+ assert_eq!(
1649+ validate_terminal_command("echo ${HOME}"),
1650+ TerminalCommandValidation::Unsafe
1651+ );
1652+ }
1653+
1654+ #[test]
1655+ fn test_validate_terminal_command_rejects_special_parameters() {
1656+ assert_eq!(
1657+ validate_terminal_command("echo $?"),
1658+ TerminalCommandValidation::Unsafe
1659+ );
1660+ assert_eq!(
1661+ validate_terminal_command("echo $$"),
1662+ TerminalCommandValidation::Unsafe
1663+ );
1664+ assert_eq!(
1665+ validate_terminal_command("echo $@"),
1666+ TerminalCommandValidation::Unsafe
1667+ );
1668+ }
1669+
1670+ #[test]
1671+ fn test_validate_terminal_command_rejects_command_substitution() {
1672+ assert_eq!(
1673+ validate_terminal_command("echo $(whoami)"),
1674+ TerminalCommandValidation::Unsafe
1675+ );
1676+ }
1677+
1678+ #[test]
1679+ fn test_validate_terminal_command_rejects_backticks() {
1680+ assert_eq!(
1681+ validate_terminal_command("echo `whoami`"),
1682+ TerminalCommandValidation::Unsafe
1683+ );
1684+ }
1685+
1686+ #[test]
1687+ fn test_validate_terminal_command_rejects_arithmetic_expansion() {
1688+ assert_eq!(
1689+ validate_terminal_command("echo $((1 + 1))"),
1690+ TerminalCommandValidation::Unsafe
1691+ );
1692+ }
1693+
1694+ #[test]
1695+ fn test_validate_terminal_command_rejects_process_substitution() {
1696+ assert_eq!(
1697+ validate_terminal_command("cat <(ls)"),
1698+ TerminalCommandValidation::Unsafe
1699+ );
1700+ assert_eq!(
1701+ validate_terminal_command("ls >(cat)"),
1702+ TerminalCommandValidation::Unsafe
1703+ );
1704+ }
1705+
1706+ #[test]
1707+ fn test_validate_terminal_command_rejects_forbidden_constructs_in_env_var_assignments() {
1708+ assert_eq!(
1709+ validate_terminal_command("PAGER=$HOME git log"),
1710+ TerminalCommandValidation::Unsafe
1711+ );
1712+ assert_eq!(
1713+ validate_terminal_command("PAGER=$(whoami) git log"),
1714+ TerminalCommandValidation::Unsafe
1715+ );
1716+ }
1717+
1718+ #[test]
1719+ fn test_validate_terminal_command_returns_unsupported_for_parse_failure() {
1720+ assert_eq!(
1721+ validate_terminal_command("echo $(ls &&)"),
1722+ TerminalCommandValidation::Unsupported
1723+ );
1724+ }
1725+
1726+ #[test]
1727+ fn test_validate_terminal_command_rejects_substitution_in_case_pattern() {
1728+ assert_ne!(
1729+ validate_terminal_command("case x in $(echo y)) echo z;; esac"),
1730+ TerminalCommandValidation::Safe
1731+ );
1732+ }
1733+
1734+ #[test]
1735+ fn test_validate_terminal_command_safe_case_clause_without_substitutions() {
1736+ assert_eq!(
1737+ validate_terminal_command("case x in foo) echo hello;; esac"),
1738+ TerminalCommandValidation::Safe
1739+ );
1740+ }
1741+
1742+ #[test]
1743+ fn test_validate_terminal_command_rejects_substitution_in_arithmetic_for_clause() {
1744+ assert_ne!(
1745+ validate_terminal_command("for ((i=$(echo 0); i<3; i++)); do echo hello; done"),
1746+ TerminalCommandValidation::Safe
1747+ );
1748+ }
1749+
1750+ #[test]
1751+ fn test_validate_terminal_command_rejects_arithmetic_for_clause_unconditionally() {
1752+ assert_eq!(
1753+ validate_terminal_command("for ((i=0; i<3; i++)); do echo hello; done"),
1754+ TerminalCommandValidation::Unsafe
1755+ );
1756+ }
10641757 }