Skip to content

feat(hooks): refuse a command that discards stderr (v1.36.0) - #53

Merged
lapc506 merged 1 commit into
mainfrom
andres/toolkit-discard-stderr-rule
Jul 31, 2026
Merged

feat(hooks): refuse a command that discards stderr (v1.36.0)#53
lapc506 merged 1 commit into
mainfrom
andres/toolkit-discard-stderr-rule

Conversation

@lapc506

@lapc506 lapc506 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What

A new Bash hook 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/nullgh rejects 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

Form Verdict Why
cmd >/dev/null 2>&1 blocked stdout is redirected first, then stderr is pointed at wherever stdout now goes — both die
cmd 2>&1 >/dev/null allowed stderr is duplicated to the original stdout before the redirect, so it survives

Identical 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 you
  • command -v x >/dev/null — the existence probe, which appears throughout these very hooks
  • cmd 2>&1 >/dev/null — see above
  • git grep '2>/dev/null' — a quoted mention performs no redirect; mention is not execution

No 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 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 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

Check Result
npm run test-hooks 337 / 337
npm test (vitest) 60 / 60, 12 files
New rule's own tests 9 / 9 — 4 blocking, 5 allowing
npm run build-rules regenerated; rules.json in sync (CI fails if stale)

Branch is based on origin/main directly — an earlier draft sat on top of feat/enf-seven-shapes (PR #52) and would have dragged that diff along. Verified #52 touches zero files under hooks/rules/, so nothing was lost in the rebase.

Created by Claude Code on behalf of @lapc506

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

@dojo-code-reviewer dojo-code-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Review Comments

Comments — 0 blockers, 2 P2, 2 P3. Confidence: 2.20/5.00.

Walkthrough

⚠️ Non-blocking Governance Note: This PR targets 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 the pattern regex. 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 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]

🔵 P3 — Minor

  • hooks/rules/rules.json:3805 — 🔵 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]

  • hooks/rules/rules.yaml:3143 — 🔵 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]


Total findings: 2 compliance, 2 business context (4 total)

Comment thread hooks/rules/rules.json
"match": [
{
"field": "command",
"pattern": "(2>>?[[:space:]]*/dev/null|&>>?[[:space:]]*/dev/null|>&[[:space:]]*/dev/null|[^0-9&]1?>[[:space:]]*/dev/null[[:space:]]+2>&1)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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]

Comment thread hooks/rules/rules.json
},
{
"field": "command",
"not_pattern": "['\\\"]2>>?[[:space:]]*/dev/null"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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]

Comment thread hooks/rules/rules.yaml
# 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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]

Comment thread hooks/rules/rules.yaml
- 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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]

@lapc506
lapc506 merged commit ee0ba47 into main Jul 31, 2026
2 checks passed
@lapc506
lapc506 deleted the andres/toolkit-discard-stderr-rule branch July 31, 2026 09:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant