use crate::rules::{Evidence, Finding, Rule, Stance, Trend};
use crate::shell::{Parsed, Simple};
pub const RULE: Rule = Rule {
id: "stdin-hang",
default_stance: Stance::Observe,
evidence: Evidence {
per_1000: 0.03,
measured: "2026-09-08",
trend: Trend::Rare,
},
examine,
confirm: None,
};
const FILTERS: &[&str] = &[
"cat",
"sort",
"uniq",
"wc",
"head",
"tail",
"tr",
"cut",
"paste",
"column",
"nl",
"tac",
"rev",
"fold",
"base64",
"shasum",
"sha256sum",
"sha1sum",
"md5sum",
"md5",
"hexdump",
"xxd",
"od",
"strings",
];
const ALWAYS: &[&str] = &["tee"];
const EXITS_EARLY: &[&str] = &["--version", "-V", "-v", "--help", "-h"];
const INTERPRETERS: &[&str] = &[
"python", "python3", "node", "ruby", "perl", "php", "bash", "sh", "zsh", "fish", "bc", "psql",
"sqlite3", "mysql",
];
const PROGRAM_FIRST: &[(&str, &[&str])] = &[
("grep", &["-e", "--regexp", "-f", "--file"]),
("egrep", &["-e", "-f"]),
("fgrep", &["-e", "-f"]),
("sed", &["-e", "--expression", "-f", "--file"]),
("awk", &["-f"]),
("gawk", &["-f"]),
("jq", &["-f", "--from-file"]),
];
const SELF_FED: &[(&str, &[&str])] = &[
(
"grep",
&["-r", "-R", "--recursive", "--dereference-recursive"],
),
("egrep", &["-r", "-R"]),
("fgrep", &["-r", "-R"]),
("sed", &["-i", "--in-place"]),
("jq", &["-n", "--null-input"]),
];
fn has_stdin_source(cmd: &Simple) -> bool {
if cmd.prev.is_some_and(|c| c.is_pipe()) {
return true;
}
if cmd.redirects.iter().any(|(op, _)| op.contains('<')) {
return true;
}
cmd.heredoc
}
fn reads_stdin(cmd: &Simple) -> bool {
let Some(program) = cmd.program() else {
return false;
};
let program = program.rsplit('/').next().unwrap_or(program);
let operands = cmd.operands();
if cmd
.args()
.iter()
.any(|w| !w.quoted && EXITS_EARLY.contains(&w.text.as_str()))
{
return false;
}
if ALWAYS.contains(&program) {
return true;
}
if FILTERS.contains(&program) || INTERPRETERS.contains(&program) {
return operands.is_empty();
}
if let Some((_, program_flags)) = PROGRAM_FIRST.iter().find(|(p, _)| *p == program) {
if let Some((_, fed)) = SELF_FED.iter().find(|(p, _)| *p == program) {
if fed.iter().any(|f| has_flag_prefix(cmd, f)) {
return false;
}
}
return program_first_files(cmd, program_flags) == Some(0);
}
false
}
fn program_first_files(cmd: &Simple, program_flags: &[&str]) -> Option<usize> {
let letters: Vec<char> = program_flags
.iter()
.filter_map(|f| {
f.strip_prefix('-')
.filter(|r| r.len() == 1)
.and_then(|r| r.chars().next())
})
.collect();
let longs: Vec<&str> = program_flags
.iter()
.filter(|f| f.starts_with("--"))
.copied()
.collect();
let mut operands = 0usize;
let mut program_in_flag = false;
let mut after_ddash = false;
let mut skip_next = false;
for w in cmd.args() {
if skip_next {
skip_next = false;
continue;
}
let t = w.text.as_str();
if after_ddash || w.quoted || !t.starts_with('-') || t == "-" {
operands += 1;
continue;
}
if t == "--" {
after_ddash = true;
continue;
}
if let Some(long) = t.strip_prefix("--") {
let name = long.split_once('=').map_or(long, |(n, _)| n);
if longs.iter().any(|l| &l[2..] == name) {
program_in_flag = true;
skip_next = !long.contains('=');
}
continue;
}
let cluster = &t[1..];
if let Some(pos) = cluster.chars().position(|c| letters.contains(&c)) {
program_in_flag = true;
skip_next = pos + 1 == cluster.chars().count();
}
}
if program_in_flag {
Some(operands)
} else if operands == 0 {
None
} else {
Some(operands - 1)
}
}
fn has_flag_prefix(cmd: &Simple, flag: &str) -> bool {
if cmd.has_flag(flag) {
return true;
}
if let Some(c) = flag
.strip_prefix('-')
.filter(|s| s.len() == 1)
.and_then(|s| s.chars().next())
{
return cmd.has_short(c);
}
cmd.words
.iter()
.any(|w| !w.quoted && w.text.starts_with(&format!("{flag}=")))
}
fn examine(parsed: &Parsed) -> Option<Finding> {
for cmd in parsed.judgeable() {
if has_stdin_source(cmd) || !reads_stdin(cmd) {
continue;
}
let program = cmd.program().unwrap_or("the command");
return Some(Finding {
reason: format!(
"`{program}` will read standard input, and nothing feeds it: no pipe, no \
`<` redirect, no heredoc. Under the Bash tool stdin stays open forever, so \
the command blocks silently until the tool's timeout moves it to the \
background — where it keeps blocking."
),
remedy: format!(
"Give `{program}` its input — a file operand, `< file`, a `<<'EOF'` heredoc, \
or put it at the end of a pipe — or, when it should read nothing, add \
`< /dev/null`. To write a file, use the Write tool or a heredoc instead of \
`cat > file`."
),
span: cmd.at..cmd.end,
});
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn fires(command: &str) -> bool {
examine(&lex(command)).is_some()
}
#[test]
fn a_reader_with_nothing_on_stdin_fires() {
assert!(fires("cat > notes.md"));
assert!(fires("cd /x && cat >> log.txt"));
assert!(fires("cat"));
assert!(fires("cat -"));
assert!(fires("sort | uniq -c"));
assert!(fires("tee /tmp/out.log"));
assert!(fires("sudo tee /etc/hosts"));
assert!(fires("python3"));
assert!(fires("python3 -"));
assert!(fires("node"));
assert!(fires("bash"));
assert!(fires("grep -i error"));
assert!(fires("grep -e error"));
assert!(fires("sed -n '1,5p'"));
assert!(fires("awk '{print $1}'"));
assert!(fires("jq -r '.id'"));
assert!(fires("wc -l"));
assert!(fires("/usr/bin/base64"));
}
#[test]
fn a_fed_reader_is_silent() {
assert!(!fires("echo hi | cat"));
assert!(!fires("cat file.txt"));
assert!(!fires("cat -n file.txt > out.txt"));
assert!(!fires("cat < in.txt > out.txt"));
assert!(!fires("cat > notes.md <<'EOF'\nhello\nEOF\n"));
assert!(!fires("python3 - <<'EOF'\nprint(1)\nEOF\n"));
assert!(!fires("python3 <<< 'print(1)'"));
assert!(!fires("python3 -c 'print(1)'"));
assert!(!fires("python3 -m json.tool f.json"));
assert!(!fires("python3 script.py"));
assert!(!fires("node -e 'console.log(1)'"));
assert!(!fires("bash -c 'echo hi'"));
assert!(!fires("bash run.sh"));
assert!(!fires("ls | sort | uniq -c | sort -rn | head"));
assert!(!fires("find . -name '*.go' | xargs grep -l TODO"));
assert!(!fires("echo x | sudo tee /etc/hosts > /dev/null"));
assert!(!fires("diff <(sort a) <(sort b)"));
assert!(!fires("cat < /dev/null"));
}
#[test]
fn program_first_readers_know_where_their_files_start() {
assert!(!fires("grep -rn error src/"));
assert!(!fires("grep -r error"));
assert!(!fires("grep error file.log"));
assert!(!fires("grep -e error file.log"));
assert!(!fires("grep"));
assert!(!fires("sed -n '1,5p' file.txt"));
assert!(!fires("sed -i 's/a/b/'"));
assert!(!fires("sed -i 's/a/b/' file.txt"));
assert!(!fires("sed -e 's/a/b/' file.txt"));
assert!(!fires("awk '{print $1}' file.txt"));
assert!(!fires("awk -f prog.awk data.txt"));
assert!(!fires("jq -r '.id' resp.json"));
assert!(!fires("jq -n '{a:1}'"));
assert!(!fires("curl -s https://x | jq -r '.id'"));
}
#[test]
fn a_reader_inside_a_string_is_text() {
assert!(!fires("echo 'cat > file'"));
assert!(!fires("grep -rn \"tee /tmp\" docs/"));
}
}