From 71cbbc1a85ee2164d99b62a41bea0e2a20e70bd6 Mon Sep 17 00:00:00 2001 From: James Soubry Date: Fri, 25 Sep 2026 11:10:01 +0000 Subject: [PATCH 1/2] fix: anchor heredoc-to-interpreter deny patterns against file-extension false positive (v3.21.1) The heredoc-to-sh/bash/zsh/python builtin_deny patterns used \b before the interpreter name, which also matches right after a dot (a non-word char) -- so they matched the .sh/.bash/.zsh file extension immediately preceding a heredoc redirect, not just the literal interpreter word as a command. This denied an extremely common, benign idiom outright (write a script to a file via heredoc, then execute it) before it ever reached the existing heredoc-content-scanner (issue #216) built specifically to scan that shape safely. Fixed by anchoring with (?:^|[^.\w]) instead of \b, mirroring the identical fix already applied to exec_re elsewhere in this file. Closes jamessoubry/clawband#302 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0187k8QeNYjgcEGv74YFdh2J --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a663d91..56c6cc4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,7 +50,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clawband" -version = "3.21.0" +version = "3.21.1" dependencies = [ "libc", "regex", diff --git a/Cargo.toml b/Cargo.toml index 6e71faa8..b9496226 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clawband" -version = "3.21.0" +version = "3.21.1" edition = "2021" description = "Claude Code PreToolUse hook that guards against destructive shell commands and unsafe file-write content" diff --git a/src/main.rs b/src/main.rs index 8e6519b1..bd0bca84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -989,11 +989,25 @@ fn builtin_deny() -> Vec { "pipe to tclsh", r"\|\s*(?:(?:command|exec|env|nohup|nice|sudo)\s+(?:-\S+\s+)*)?(?:[\w./]*/)?tclsh(?:\d[\d.]*)?\b", ), - // Heredoc to interpreter - ("heredoc to bash", r"\bbash\s+<<"), - ("heredoc to sh", r"\bsh\s+<<"), - ("heredoc to zsh", r"\bzsh\s+<<"), - ("heredoc to python", r"\bpython3?\s+<<"), + // Heredoc to interpreter — piping heredoc content directly into an + // interpreter's stdin (`sh << EOF ... EOF`) executes immediately with + // no chance to inspect it first, unlike `cat > file << EOF ... EOF; + // bash file`, which the write-then-exec heredoc-content-scanner + // (issue #216, `try_scan_heredoc_content`) already handles safely by + // extracting and scanning the body before deciding. + // + // Uses `(?:^|[^.\w])` instead of `\b` before the interpreter name — + // `\b` fires right after `.` too (it's a non-word char), so `\bsh\s+<<` + // also matched the *file extension* in `cat > script.sh << 'EOF'`, + // an extremely common, benign way to write-then-run a temp script. + // That false-positive denied the command outright, short-circuiting + // the heredoc-content-scanner before it ever got a chance to run. + // Same fix already applied to `exec_re` elsewhere in this file (see + // its comment for the identical `.sh`-extension gotcha). + ("heredoc to bash", r"(?:^|[^.\w])bash\s+<<"), + ("heredoc to sh", r"(?:^|[^.\w])sh\s+<<"), + ("heredoc to zsh", r"(?:^|[^.\w])zsh\s+<<"), + ("heredoc to python", r"(?:^|[^.\w])python3?\s+<<"), // Pipe to database CLI ("pipe to psql", r"\|\s*psql\b"), ("pipe to mysql", r"\|\s*mysql\b"), @@ -8483,6 +8497,69 @@ mod tests { ); } + // ── heredoc-to-interpreter false positive on file extensions ────────────── + // Real-world bug (reported by a user): `\bsh\s+<<` etc. matched the `.sh` + // file extension immediately before a heredoc redirect (`\b` fires right + // after `.` too, since it's a non-word char), so `cat > script.sh << + // 'EOF'` — an extremely common way to write-then-run a temp script — was + // denied outright by the blunt "heredoc to sh" builtin_deny pattern, + // short-circuiting the smarter heredoc-content-scanner (issue #216) + // before it ever got a chance to inspect the actual content. + + #[test] + fn write_then_exec_heredoc_to_sh_extension_benign_passes() { + // The exact reported shape: redirect target has a `.sh` extension + // immediately before the heredoc opener. + let cmd = "cat > /tmp/script.sh << 'EOF'\necho hello\nEOF\nbash /tmp/script.sh"; + assert_eq!(decision(cmd), None); + } + + #[test] + fn write_then_exec_heredoc_to_sh_extension_dangerous_denies() { + // Same shape, but the content-scanner must still catch real danger — + // the fix must not turn this into a blanket allow. + let cmd = "cat > /tmp/bad.sh << 'EOF'\nrm -rf /\nEOF\nbash /tmp/bad.sh"; + assert_eq!(decision(cmd), Some("deny".into())); + } + + #[test] + fn write_then_exec_heredoc_to_bash_extension_benign_passes() { + let cmd = "cat > /tmp/script.bash << 'EOF'\necho hello\nEOF\nbash /tmp/script.bash"; + assert_eq!(decision(cmd), None); + } + + #[test] + fn write_then_exec_heredoc_to_zsh_extension_benign_passes() { + let cmd = "cat > /tmp/script.zsh << 'EOF'\necho hello\nEOF\nzsh /tmp/script.zsh"; + assert_eq!(decision(cmd), None); + } + + #[test] + fn heredoc_to_sh_direct_stdin_pipe_still_denied() { + // The genuine danger this pattern exists for must still be caught: + // piping heredoc content directly into an interpreter's stdin + // executes immediately with no file, no scan, no chance to inspect. + assert_eq!(decision("sh << EOF\necho hello\nEOF"), Some("deny".into())); + } + + #[test] + fn heredoc_to_bash_direct_stdin_pipe_still_denied() { + assert_eq!(decision("bash << EOF\nrm -rf /\nEOF"), Some("deny".into())); + } + + #[test] + fn heredoc_to_zsh_direct_stdin_pipe_still_denied() { + assert_eq!(decision("zsh << EOF\nrm -rf /\nEOF"), Some("deny".into())); + } + + #[test] + fn heredoc_to_python_direct_stdin_pipe_still_denied() { + assert_eq!( + decision("python3 << EOF\nimport os; os.system('rm -rf /')\nEOF"), + Some("deny".into()) + ); + } + // ── subshell scanning ────────────────────────────────────────────────────── #[test] From 749b9e63d60a70d151ceb8e7fd0bf1d693c7cae5 Mon Sep 17 00:00:00 2001 From: James Soubry Date: Fri, 25 Sep 2026 11:41:15 +0000 Subject: [PATCH 2/2] fix: add missing CLI e2e tests for heredoc extension fix (per Greptile review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile flagged that the heredoc-to-interpreter false-positive fix lacked CLI e2e coverage in tests/cli.rs, per this repo's testing convention. Adds e2e tests mirroring the existing unit tests: the reported `.sh` extension false positive now passes clean, the same shape with dangerous heredoc content still denies, and the direct `sh <<`/`bash <<` stdin-pipe danger still denies. *— Claude (Sonnet 5), clawband backlog automation* --- tests/cli.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/cli.rs b/tests/cli.rs index 3ce00a9d..9b767d0e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -5391,3 +5391,58 @@ fn e2e_script_just_under_limit_scans_normally() { "script under the size limit must be scanned normally and catch the dangerous line: {out}" ); } + +// ── heredoc-to-interpreter false positive on file extensions (issue #216) ── +// Mirrors the unit tests in src/main.rs (`write_then_exec_heredoc_to_*` and +// `heredoc_to_*_direct_stdin_pipe_still_denied`) at the CLI/e2e layer, per +// Greptile review on PR #303. + +#[test] +fn e2e_heredoc_to_sh_extension_benign_passes() { + // The exact reported false-positive shape: a `.sh` extension immediately + // before the heredoc opener must not be denied outright. + let out = run( + &bash("cat > /tmp/script.sh << 'EOF'\necho hello\nEOF\nbash /tmp/script.sh"), + &[], + ); + assert_eq!(decision(&out), None, "benign heredoc-to-.sh-file: {out}"); +} + +#[test] +fn e2e_heredoc_to_sh_extension_dangerous_content_still_denied() { + // Same shape, but with genuinely dangerous content in the heredoc — the + // fix must defer to the content scanner, not turn this into a blanket + // allow. + let out = run( + &bash("cat > /tmp/bad.sh << 'EOF'\nrm -rf /\nEOF\nbash /tmp/bad.sh"), + &[], + ); + assert_eq!( + decision(&out), + Some("deny"), + "dangerous heredoc-to-.sh-file content must still be caught: {out}" + ); +} + +#[test] +fn e2e_heredoc_to_sh_direct_stdin_pipe_still_denied() { + // The genuine danger this pattern exists for: piping heredoc content + // directly into an interpreter's stdin, with no file and no chance to + // scan, must still be denied outright. + let out = run(&bash("sh << EOF\necho hello\nEOF"), &[]); + assert_eq!( + decision(&out), + Some("deny"), + "direct sh << heredoc stdin pipe must still be denied: {out}" + ); +} + +#[test] +fn e2e_heredoc_to_bash_direct_stdin_pipe_still_denied() { + let out = run(&bash("bash << EOF\nrm -rf /\nEOF"), &[]); + assert_eq!( + decision(&out), + Some("deny"), + "direct bash << heredoc stdin pipe must still be denied: {out}" + ); +}