mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-29 19:14:13 +00:00
Skip /dev/null redirects from terminal auto-allow command extraction (#49503)
Redirects to `/dev/null` (e.g. `2>/dev/null`, `&>/dev/null`) are known-safe I/O routing, not commands. Previously, `extract_commands` emitted normalized redirect strings like `"2> /dev/null"` as separate entries in the command list checked against auto-allow regexes. Since `check_commands` requires **all** extracted entries to match an allow pattern, the unmatched redirect caused false-negatives — e.g. `git log --oneline -20 2>/dev/null || echo ...` would not be auto-allowed despite matching `^git` and `^echo` patterns. Rather than removing all redirects from extraction (which would hide dangerous redirects like `> /etc/passwd` from deny/confirm pattern checking), this fix surgically skips only `/dev/null` targets during redirect normalization. Redirects to real files are still emitted and still require a matching pattern for auto-allow, preserving the defense-in-depth property. Closes AI-41 Release Notes: - Fixed terminal auto-allow patterns incorrectly prompting for confirmation on commands containing `/dev/null` redirects (e.g. `2>/dev/null`).
This commit is contained in:
parent
27dffc12df
commit
bf1bb52b60
2 changed files with 98 additions and 1 deletions
|
|
@ -1150,6 +1150,32 @@ mod tests {
|
|||
t("ls && echo hello").allow(&["^ls", "^echo"]).is_allow();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_null_redirect_does_not_cause_false_negative() {
|
||||
// Redirects to /dev/null are known-safe and should be skipped during
|
||||
// command extraction, so they don't prevent auto-allow from matching.
|
||||
t(r#"git log --oneline -20 2>/dev/null || echo "not a git repo or no commits""#)
|
||||
.allow(&[r"^git\s+(status|diff|log|show)\b", "^echo"])
|
||||
.is_allow();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_to_real_file_still_causes_confirm() {
|
||||
// Redirects to real files (not /dev/null) should still be included in
|
||||
// the extracted commands, so they prevent auto-allow when unmatched.
|
||||
t("echo hello > /etc/passwd").allow(&["^echo"]).is_confirm();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_does_not_cause_false_negative_when_all_commands_match() {
|
||||
// A piped command like `echo "y\ny" | git add -p file` produces two commands:
|
||||
// "echo y\ny" and "git add -p file". Both should match their respective allow
|
||||
// patterns, so the overall command should be auto-allowed.
|
||||
t(r#"echo "y\ny" | git add -p crates/acp_thread/src/acp_thread.rs"#)
|
||||
.allow(&[r"^git\s+(--no-pager\s+)?(fetch|status|diff|log|show|add|commit|push|checkout\s+-b)\b", "^echo"])
|
||||
.is_allow();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_triggers_on_any_matching_command() {
|
||||
t("ls && rm file").allow(&["^ls"]).deny(&["^rm"]).is_deny();
|
||||
|
|
|
|||
|
|
@ -232,6 +232,10 @@ fn normalize_word_piece_into(
|
|||
Some(())
|
||||
}
|
||||
|
||||
fn is_known_safe_redirect_target(normalized_target: &str) -> bool {
|
||||
normalized_target == "/dev/null"
|
||||
}
|
||||
|
||||
fn normalize_io_redirect(redirect: &ast::IoRedirect) -> Option<RedirectNormalization> {
|
||||
match redirect {
|
||||
ast::IoRedirect::File(fd, kind, target) => {
|
||||
|
|
@ -257,6 +261,9 @@ fn normalize_io_redirect(redirect: &ast::IoRedirect) -> Option<RedirectNormaliza
|
|||
None => String::new(),
|
||||
};
|
||||
let normalized = normalize_word(target_word)?;
|
||||
if is_known_safe_redirect_target(&normalized) {
|
||||
return Some(RedirectNormalization::Skip);
|
||||
}
|
||||
Some(RedirectNormalization::Normalized(format!(
|
||||
"{}{} {}",
|
||||
fd_prefix, operator, normalized
|
||||
|
|
@ -265,6 +272,9 @@ fn normalize_io_redirect(redirect: &ast::IoRedirect) -> Option<RedirectNormaliza
|
|||
ast::IoRedirect::OutputAndError(word, append) => {
|
||||
let operator = if *append { "&>>" } else { "&>" };
|
||||
let normalized = normalize_word(word)?;
|
||||
if is_known_safe_redirect_target(&normalized) {
|
||||
return Some(RedirectNormalization::Skip);
|
||||
}
|
||||
Some(RedirectNormalization::Normalized(format!(
|
||||
"{} {}",
|
||||
operator, normalized
|
||||
|
|
@ -896,7 +906,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_pipe_with_stderr_redirect_on_first_command() {
|
||||
let commands = extract_commands("ls 2>/dev/null | grep foo").expect("parse failed");
|
||||
assert_eq!(commands, vec!["ls", "2> /dev/null", "grep foo"]);
|
||||
assert_eq!(commands, vec!["ls", "grep foo"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -990,4 +1000,65 @@ mod tests {
|
|||
assert!(commands.contains(&"cat".to_string()));
|
||||
assert!(commands.contains(&"tee /tmp/log".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redirect_to_dev_null_skipped() {
|
||||
let commands = extract_commands("cmd > /dev/null").expect("parse failed");
|
||||
assert_eq!(commands, vec!["cmd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stderr_redirect_to_dev_null_skipped() {
|
||||
let commands = extract_commands("cmd 2>/dev/null").expect("parse failed");
|
||||
assert_eq!(commands, vec!["cmd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stderr_redirect_to_dev_null_with_space_skipped() {
|
||||
let commands = extract_commands("cmd 2> /dev/null").expect("parse failed");
|
||||
assert_eq!(commands, vec!["cmd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_redirect_to_dev_null_skipped() {
|
||||
let commands = extract_commands("cmd >> /dev/null").expect("parse failed");
|
||||
assert_eq!(commands, vec!["cmd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_and_error_redirect_to_dev_null_skipped() {
|
||||
let commands = extract_commands("cmd &>/dev/null").expect("parse failed");
|
||||
assert_eq!(commands, vec!["cmd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_output_and_error_redirect_to_dev_null_skipped() {
|
||||
let commands = extract_commands("cmd &>>/dev/null").expect("parse failed");
|
||||
assert_eq!(commands, vec!["cmd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quoted_dev_null_redirect_skipped() {
|
||||
let commands = extract_commands("cmd 2>'/dev/null'").expect("parse failed");
|
||||
assert_eq!(commands, vec!["cmd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redirect_to_real_file_still_included() {
|
||||
let commands = extract_commands("echo hello > /etc/passwd").expect("parse failed");
|
||||
assert_eq!(commands, vec!["echo hello", "> /etc/passwd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dev_null_redirect_in_chained_command() {
|
||||
let commands =
|
||||
extract_commands("git log 2>/dev/null || echo fallback").expect("parse failed");
|
||||
assert_eq!(commands, vec!["git log", "echo fallback"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_safe_and_unsafe_redirects() {
|
||||
let commands = extract_commands("cmd > /tmp/out 2>/dev/null").expect("parse failed");
|
||||
assert_eq!(commands, vec!["cmd", "> /tmp/out"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue