Skip to content

Commit 10c9e9c

Browse files
fohteclaude
andauthored
fix(parser): handle redirects in command rule matching (#102)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0ab984f commit 10c9e9c

2 files changed

Lines changed: 147 additions & 18 deletions

File tree

src/rules/command_parser.rs

Lines changed: 100 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,23 @@ fn collect_commands(node: tree_sitter::Node, source: &[u8], commands: &mut Vec<S
279279
}
280280
}
281281
}
282+
// redirected_statement: recurse into the body (the actual command),
283+
// stripping redirect operators (>, >>, <, 2>&1, etc.).
284+
// Redirect target paths are left to the OS-level sandbox to enforce.
285+
// Also recurse into redirect children to extract nested commands
286+
// (e.g. process substitutions: `cmd > >(nested_cmd)`).
287+
"redirected_statement" => {
288+
if let Some(body) = node.child_by_field_name("body") {
289+
collect_commands(body, source, commands);
290+
}
291+
for i in 0..node.child_count() {
292+
if node.field_name_for_child(i as u32) == Some("redirect")
293+
&& let Some(child) = node.child(i)
294+
{
295+
collect_substitutions_recursive(child, source, commands);
296+
}
297+
}
298+
}
282299
// comment: skip shell comments (e.g. `# description`)
283300
"comment" => {}
284301
// variable_assignment: transparent container — skip the assignment itself
@@ -295,27 +312,49 @@ fn collect_commands(node: tree_sitter::Node, source: &[u8], commands: &mut Vec<S
295312
}
296313
}
297314
// command node: strip leading variable_assignment children
298-
// (environment variable prefixes like `FOO=bar echo hello`), extract
299-
// nested command_substitution nodes, and emit the remaining text.
315+
// (environment variable prefixes like `FOO=bar echo hello`), strip
316+
// redirect children (herestring_redirect, etc. that tree-sitter
317+
// attaches directly to a command node), extract nested
318+
// command_substitution nodes, and emit the remaining text.
300319
"command" => {
301-
let mut cursor = node.walk();
302-
for child in node.named_children(&mut cursor) {
303-
match child.kind() {
304-
"command_substitution" => {
305-
collect_commands(child, source, commands);
306-
}
307-
"variable_assignment" => {
308-
collect_substitutions_recursive(child, source, commands);
320+
for i in 0..node.child_count() {
321+
let Some(child) = node.child(i) else {
322+
continue;
323+
};
324+
if !child.is_named() {
325+
continue;
326+
}
327+
if node.field_name_for_child(i as u32) == Some("redirect") {
328+
// Recurse into redirect children for nested substitutions
329+
// (e.g. `cat <<< $(secret_cmd)`)
330+
collect_substitutions_recursive(child, source, commands);
331+
} else {
332+
match child.kind() {
333+
"command_substitution" => {
334+
collect_commands(child, source, commands);
335+
}
336+
"variable_assignment" => {
337+
collect_substitutions_recursive(child, source, commands);
338+
}
339+
_ => {}
309340
}
310-
_ => {}
311341
}
312342
}
313-
// Build command text excluding variable_assignment children
314-
let mut cursor = node.walk();
315-
let parts: Vec<&str> = node
316-
.named_children(&mut cursor)
317-
.filter(|child| child.kind() != "variable_assignment")
318-
.filter_map(|child| {
343+
// Build command text excluding variable_assignment and redirect children.
344+
// Redirects (e.g. herestring_redirect) attached directly to a command
345+
// node use the field name "redirect".
346+
let parts: Vec<&str> = (0..node.child_count())
347+
.filter_map(|i| {
348+
let child = node.child(i)?;
349+
if !child.is_named() {
350+
return None;
351+
}
352+
if child.kind() == "variable_assignment" {
353+
return None;
354+
}
355+
if node.field_name_for_child(i as u32) == Some("redirect") {
356+
return None;
357+
}
319358
let text = &source[child.start_byte()..child.end_byte()];
320359
std::str::from_utf8(text).ok()
321360
})
@@ -601,7 +640,50 @@ mod tests {
601640
"}
602641
.trim_end();
603642
let result = extract_commands(input).unwrap();
604-
assert_eq!(result, vec![input]);
643+
// heredoc is a redirected_statement; only the body command is extracted
644+
assert_eq!(result, vec!["cat"]);
645+
}
646+
647+
// ========================================
648+
// extract_commands: redirected statements
649+
// ========================================
650+
651+
#[rstest]
652+
#[case::stdout_to_file("echo hello > file.txt", vec!["echo hello"])]
653+
#[case::append_to_file("echo hello >> file.txt", vec!["echo hello"])]
654+
#[case::stdin_from_file("cat < input.txt", vec!["cat"])]
655+
#[case::stderr_to_devnull("cmd 2> /dev/null", vec!["cmd"])]
656+
#[case::stdout_and_stderr("cmd > out.txt 2>&1", vec!["cmd"])]
657+
#[case::fd_redirect_only("echo hello 2>&1", vec!["echo hello"])]
658+
#[case::devnull_redirect("curl url > /dev/null", vec!["curl url"])]
659+
#[case::herestring("cat <<< hello", vec!["cat"])]
660+
#[case::redirect_with_pipeline(
661+
"echo hello 2>&1 | grep world",
662+
vec!["echo hello", "grep world"],
663+
)]
664+
#[case::redirect_with_list(
665+
"echo hello > file.txt && cat file.txt",
666+
vec!["echo hello", "cat file.txt"],
667+
)]
668+
#[case::redirect_in_compound(
669+
r#"X="test" && echo "$X" 2>&1"#,
670+
vec![r#"echo "$X""#],
671+
)]
672+
#[case::process_substitution_in_redirect(
673+
"cmd > >(nested_cmd)",
674+
vec!["cmd", "nested_cmd"],
675+
)]
676+
#[case::command_substitution_in_redirect(
677+
"cmd > $(echo /tmp/file)",
678+
vec!["cmd", "echo /tmp/file"],
679+
)]
680+
#[case::command_substitution_in_herestring(
681+
"cat <<< $(secret_cmd)",
682+
vec!["secret_cmd", "cat"],
683+
)]
684+
fn extract_redirected_statements(#[case] input: &str, #[case] expected: Vec<&str>) {
685+
let result = extract_commands(input).unwrap();
686+
assert_eq!(result, expected);
605687
}
606688

607689
// ========================================

tests/integration/compound_command_evaluation.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,53 @@ fn unmatched_sub_command_uses_defaults_action(
333333
expected(&result.action);
334334
}
335335

336+
// ========================================
337+
// Redirected statements: redirects are stripped before rule evaluation
338+
// ========================================
339+
340+
#[rstest]
341+
#[case::stdout_redirect(
342+
"echo hello > file.txt",
343+
assert_allow as ActionAssertion,
344+
)]
345+
#[case::stderr_redirect(
346+
"git branch --help 2>&1",
347+
assert_allow as ActionAssertion,
348+
)]
349+
#[case::devnull_redirect(
350+
"echo hello > /dev/null 2>&1",
351+
assert_allow as ActionAssertion,
352+
)]
353+
#[case::compound_with_redirect(
354+
r#"X="test" && echo "$X" 2>&1"#,
355+
assert_allow as ActionAssertion,
356+
)]
357+
#[case::deny_still_works_with_redirect(
358+
"rm -rf /tmp/data > /dev/null 2>&1",
359+
assert_deny as ActionAssertion,
360+
)]
361+
#[case::redirect_in_pipeline(
362+
"echo hello 2>&1 | grep world",
363+
assert_allow as ActionAssertion,
364+
)]
365+
fn redirected_statements_match_rules(
366+
#[case] command: &str,
367+
#[case] expected: ActionAssertion,
368+
empty_context: EvalContext,
369+
) {
370+
let config = parse_config(indoc! {"
371+
rules:
372+
- allow: 'echo *'
373+
- allow: 'git *'
374+
- allow: 'grep *'
375+
- deny: 'rm -rf *'
376+
"})
377+
.unwrap();
378+
379+
let result = evaluate_compound(&config, command, &empty_context).unwrap();
380+
expected(&result.action);
381+
}
382+
336383
// ========================================
337384
// Execution mode: compound commands are represented as Shell input (sh -c)
338385
// ========================================

0 commit comments

Comments
 (0)