feat(hooks): refuse a command that discards stderr (v1.36.0) - #53
Conversation
A failing command with its stderr discarded is indistinguishable from a succeeding one that printed nothing, so the empty result gets read as "none found" rather than "it errored". Measured on 2026-07-31: `gh api --jq --arg ... 2>/dev/null` -- gh rejects that flag combination -- produced an empty file that was reported to the team as "0 red PRs". Sixteen of 41 were red, twelve on TypeScript. The discarded stderr said exactly what was wrong. The rule matches on ORDER, which is the whole difficulty. `>/dev/null 2>&1` is blocked: stdout is redirected first, then stderr is pointed at wherever stdout now goes, so both die. `2>&1 >/dev/null` is allowed: stderr is duplicated to the ORIGINAL stdout before the redirect, so it survives. Identical token sets, opposite outcomes -- a matcher keyed on tokens alone gets one of the two wrong, and the permissive error is the costly one, because a rule that blocks working commands gets removed within a day. Three shapes stay allowed, each pinned by a test: `cmd >/dev/null` (stderr still reaches you), `command -v x >/dev/null` (the existence probe, which appears throughout these very hooks), and `cmd 2>&1 >/dev/null`. A quoted mention -- `git grep '2>/dev/null'` -- performs no redirect and is not blocked, because mention is not execution. Ships with `bypass_marker: null`, the first rule here to do so. Every case a bypass would cover is already allowed above, so a marker would only buy a way past a rule nobody needs to get past. Precedent: dojo-os `pre-bash-block-main-target.sh` accepted `DOJO_HOTFIX_TO_MAIN=1` AND printed that literal in its own refusal -- the thing meant to stop you handed you the way through, and two agents filed false P0-hotfix claims that way (DOJ-6247). A gate whose refusal prints the way around it is not a gate. 9 tests (4 blocking, 5 allowing). Rule count 39 -> 40. Suite: 337/337 hooks, 60/60 vitest, rules.json regenerated and in sync. Created by Claude Code on behalf of @lapc506
There was a problem hiding this comment.
💬 Review Comments
Comments — 0 blockers, 2 P2, 2 P3. Confidence: 2.20/5.00.
Walkthrough
main directly. According to the repository's GitFlow governance model, feature branches should target develop instead of main (feature → develop → main). Since this is a governance concern rather than a code quality blocker, this review's verdict is based solely on the technical correctness of the implementation.
Walkthrough
This PR introduces a new Bash hook rule, discard-stderr (v1.36.0), designed to detect and block commands that redirect stderr to /dev/null (e.g., 2>/dev/null, &>/dev/null, and >/dev/null 2>&1), while preserving legitimate stdout-only redirection or reversed stderr-survives forms.
Reviewed Areas
We reviewed the hook rule definitions under hooks/rules/rules.yaml and hooks/rules/rules.json, the configuration files (.claude-plugin/plugin.json, .claude-plugin/marketplace.json, package.json), and the documentation (README.md, CHANGELOG.md).
Safety Rationale
Safety Rationale: The new rule is safe to merge as it is isolated to pre-command hooks, handles standard execution patterns correctly, and has been verified with comprehensive unit tests.
Verdict
Changes suggested — 0 blockers, 1 P2, 1 P3.
🟡 P2 — Major
hooks/rules/rules.json:3801— 🟡 P2 (major) — This is the compiled JSON counterpart to thepatternregex. Updating this ensures consistency with the corrected ERE pattern that matches start-of-command redirections.
[pass 1]
hooks/rules/rules.yaml:3139— 🟡 P2 (major) — A command starting with a redirection (such as>/dev/null 2>&1 commandor1>/dev/null 2>&1 command) will bypass this rule entirely.
Because [^0-9&] requires at least one preceding character, there is no match when the redirection is at the very beginning of the command string. Using (^|[^0-9&]) instead resolves this loophole by correctly matching either the start of the command line or any non-digit/non-ampersand character.
[pass 1]
🔵 P3 — Minor
hooks/rules/rules.json:3805— 🔵 P3 (minor) — This is the compiled JSON counterpart to thenot_patternquote detection improvement. Updating this ensures consistency with therules.yamldefinition and robustly prevents false positives in JSON runtime evaluations.
[pass 1]
hooks/rules/rules.yaml:3143— 🔵 P3 (minor) — The currentnot_patternonly matches quotes that immediately precede the2(e.g.,'2>/dev/null'). Any mention of2>/dev/nullnested inside a longer quoted string (e.g.,git grep "some search 2>/dev/null"orecho "Note: 2>/dev/null is used") will fail to match the exclusion and will trigger a false-positive block.
Additionally, the seven backslashes in "['\\\\\\\"]" parse into ['\\\" in memory, which accidentally includes a literal backslash \ inside the character class.
Using a refined regex pattern like ('[^']*2>>?[[:space:]]*/dev/null[^']*'|\"[^\"]*2>>?[[:space:]]*/dev/null[^\"]*\") robustly handles quotes surrounding the entire substring and eliminates the accidental backslash character matching.
[pass 1]
Total findings: 2 compliance, 2 business context (4 total)
| "match": [ | ||
| { | ||
| "field": "command", | ||
| "pattern": "(2>>?[[:space:]]*/dev/null|&>>?[[:space:]]*/dev/null|>&[[:space:]]*/dev/null|[^0-9&]1?>[[:space:]]*/dev/null[[:space:]]+2>&1)" |
There was a problem hiding this comment.
🟡 P2 (major) — This is the compiled JSON counterpart to the pattern regex. Updating this ensures consistency with the corrected ERE pattern that matches start-of-command redirections.
[pass 1]
| }, | ||
| { | ||
| "field": "command", | ||
| "not_pattern": "['\\\"]2>>?[[:space:]]*/dev/null" |
There was a problem hiding this comment.
🔵 P3 (minor) — This is the compiled JSON counterpart to the not_pattern quote detection improvement. Updating this ensures consistency with the rules.yaml definition and robustly prevents false positives in JSON runtime evaluations.
[pass 1]
| # by ORDER: stdout dies first, then stderr follows it. The reverse, | ||
| # `2>&1 >/dev/null`, duplicates stderr to the ORIGINAL stdout before stdout | ||
| # is redirected, so stderr survives -- it is deliberately not matched here. | ||
| # Two identical token sets, opposite outcomes. |
There was a problem hiding this comment.
🟡 P2 (major) — A command starting with a redirection (such as >/dev/null 2>&1 command or 1>/dev/null 2>&1 command) will bypass this rule entirely.
Because [^0-9&] requires at least one preceding character, there is no match when the redirection is at the very beginning of the command string. Using (^|[^0-9&]) instead resolves this loophole by correctly matching either the start of the command line or any non-digit/non-ampersand character.
[pass 1]
| - field: command | ||
| pattern: '(2>>?[[:space:]]*/dev/null|&>>?[[:space:]]*/dev/null|>&[[:space:]]*/dev/null|[^0-9&]1?>[[:space:]]*/dev/null[[:space:]]+2>&1)' | ||
| # `git grep '2>/dev/null'` and similar MENTION the pattern inside single | ||
| # quotes; they perform no redirect. Mention is not execution. |
There was a problem hiding this comment.
🔵 P3 (minor) — The current not_pattern only matches quotes that immediately precede the 2 (e.g., '2>/dev/null'). Any mention of 2>/dev/null nested inside a longer quoted string (e.g., git grep "some search 2>/dev/null" or echo "Note: 2>/dev/null is used") will fail to match the exclusion and will trigger a false-positive block.
Additionally, the seven backslashes in "['\\\\\\\"]" parse into ['\\\" in memory, which accidentally includes a literal backslash \ inside the character class.
Using a refined regex pattern like ('[^']*2>>?[[:space:]]*/dev/null[^']*'|\"[^\"]*2>>?[[:space:]]*/dev/null[^\"]*\") robustly handles quotes surrounding the entire substring and eliminates the accidental backslash character matching.
[pass 1]
What
A new
Bashhook rule,discard-stderr, that blocks a command routing stderr to/dev/null. Rule count 39 → 40; version 1.35.0 → 1.36.0.Why
A failing command with its stderr discarded is indistinguishable from a succeeding one that printed nothing. The empty result then gets read as "none found" rather than "it errored".
Measured on 2026-07-31, not hypothetical:
gh api --jq --arg ... 2>/dev/null—ghrejects that flag combination — produced an empty file that was reported to the team as "0 red PRs". Sixteen of 41 were red, twelve of them on TypeScript. The discarded stderr said exactly what was wrong.The hard part: the rule matches on ORDER
cmd >/dev/null 2>&1cmd 2>&1 >/dev/nullIdentical token sets, opposite outcomes. A matcher keyed on the tokens alone gets one of the two wrong, and it is the permissive error that costs — a rule that blocks working commands gets removed within a day.
What stays allowed, each pinned by a test
cmd >/dev/null— stdout is noisy, stderr still reaches youcommand -v x >/dev/null— the existence probe, which appears throughout these very hookscmd 2>&1 >/dev/null— see abovegit grep '2>/dev/null'— a quoted mention performs no redirect; mention is not executionNo bypass marker, on purpose
This is the first rule here shipping
bypass_marker: null. Every case a bypass would cover is already allowed by the rule itself, so a marker would only buy a way past a rule nobody needs to get past.The precedent is dojo-os
pre-bash-block-main-target.sh: it acceptedDOJO_HOTFIX_TO_MAIN=1and printed that literal in its own refusal. The thing meant to stop you handed you the way through, and two agents filed false P0-hotfix claims that way (DOJ-6247). A gate whose refusal message prints the way around it is not a gate.The README's "Bypassing a rule" section now says this explicitly, since every other rule advertises its marker and a silent exception would read as an oversight.
Verification
npm run test-hooksnpm test(vitest)npm run build-rulesrules.jsonin sync (CI fails if stale)Branch is based on
origin/maindirectly — an earlier draft sat on top offeat/enf-seven-shapes(PR #52) and would have dragged that diff along. Verified#52touches zero files underhooks/rules/, so nothing was lost in the rebase.Created by Claude Code on behalf of @lapc506