diff --git a/crates/flare-proxy/src/shortcircuit.rs b/crates/flare-proxy/src/shortcircuit.rs
index 6fbaece6..0c362277 100644
--- a/crates/flare-proxy/src/shortcircuit.rs
+++ b/crates/flare-proxy/src/shortcircuit.rs
@@ -251,6 +251,25 @@ fn extract_command_prefix(command: &str) -> String {
first_word.to_string()
}
+/// True for shell redirection/control-operator tokens (`>`, `>>`, `<`,
+/// `2>`, `2>&1`, `&>`, `|`, `||`, `&&`, `;`) that a naive whitespace split
+/// leaves as their own token (e.g. `2>/dev/null`) or attached to a target
+/// (e.g. `>out.txt`) -- neither is a filepath the command reads/writes by
+/// name, so both must be excluded rather than mistaken for one.
+fn is_shell_operator_token(part: &str) -> bool {
+ let trimmed = part.trim_start_matches(|c: char| c.is_ascii_digit());
+ matches!(trimmed.chars().next(), Some('>' | '<' | '&' | '|' | ';'))
+}
+
+/// True when `part` is a redirection operator with no target fused onto it
+/// (e.g. `>`, `2>`, `>>`) -- the shell takes the *next* whitespace-separated
+/// token as the redirect target, so that token must also be dropped rather
+/// than kept as a filepath the command reads.
+fn is_bare_redirect_operator(part: &str) -> bool {
+ let trimmed = part.trim_start_matches(|c: char| c.is_ascii_digit());
+ matches!(trimmed, ">" | ">>" | "<" | "<<" | "&>" | "&>>")
+}
+
fn extract_filepaths(command: &str, _output: &str) -> String {
let listing_commands = [
"ls", "dir", "find", "tree", "pwd", "cd", "mkdir", "rmdir", "rm",
@@ -273,11 +292,24 @@ fn extract_filepaths(command: &str, _output: &str) -> String {
}
if reading_commands.contains(&base_cmd.as_str()) {
- let filepaths: Vec<&str> = parts[1..]
- .iter()
- .filter(|p| !p.starts_with('-'))
- .copied()
- .collect();
+ let mut filepaths: Vec<&str> = Vec::new();
+ let mut skip_next = false;
+ for part in &parts[1..] {
+ if skip_next {
+ skip_next = false;
+ continue;
+ }
+ if part.starts_with('-') {
+ continue;
+ }
+ if is_shell_operator_token(part) {
+ if is_bare_redirect_operator(part) {
+ skip_next = true;
+ }
+ continue;
+ }
+ filepaths.push(part);
+ }
if filepaths.is_empty() {
return "\n".into();
}
@@ -304,6 +336,12 @@ fn extract_filepaths(command: &str, _output: &str) -> String {
}
continue;
}
+ if is_shell_operator_token(part) {
+ if is_bare_redirect_operator(part) {
+ skip_next = true;
+ }
+ continue;
+ }
positional.push(part);
}
@@ -677,6 +715,24 @@ mod tests {
assert!(result.contains("tests/test.rs"));
}
+ #[test]
+ fn test_extract_filepaths_ignores_stderr_redirect() {
+ let result = extract_filepaths("tail -5 src/main.rs 2>/dev/null", "some content");
+ assert_eq!(result, "\nsrc/main.rs\n");
+ }
+
+ #[test]
+ fn test_extract_filepaths_ignores_stdout_redirect() {
+ let result = extract_filepaths("cat src/main.rs > out.txt", "some content");
+ assert_eq!(result, "\nsrc/main.rs\n");
+ }
+
+ #[test]
+ fn test_extract_filepaths_grep_ignores_redirect() {
+ let result = extract_filepaths("grep pattern src/main.rs 2>/dev/null", "matches");
+ assert_eq!(result, "\nsrc/main.rs\n");
+ }
+
#[test]
fn test_extract_filepaths_empty() {
let result = extract_filepaths("", "");