From 40fc1f349f3de565e63d8c2cb7559b33ca0e59a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Fri, 31 Jul 2026 00:53:52 -0600 Subject: [PATCH] feat(hooks): refuse a command that discards stderr (v1.36.0) 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 --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 37 +++++++++++ README.md | 19 +++++- hooks/rules/rules.json | 108 ++++++++++++++++++++++++++++++++ hooks/rules/rules.yaml | 76 ++++++++++++++++++++++ package.json | 2 +- 7 files changed, 243 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 34299c4..2d18392 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "make-no-mistakes", - "version": "1.35.0", + "version": "1.36.0", "description": "The disciplined dev lifecycle \u2014 implement issues, review PRs, sync releases, test E2E, manage sessions, and stash secrets via OS-native prompts. One plugin to make no mistakes.", "owner": { "name": "Luis Andres Pena Castillo", @@ -11,7 +11,7 @@ { "name": "make-no-mistakes", "description": "Dev lifecycle orchestrator. Start with /make-no-mistakes:domain-driven-advisor \u2014 the canonical entry point that inspects your repo, asks a few plain-language questions, and routes you across the six-family audit engine (schema-drift, contract-drift, ddd, explicit-architecture, strangler, enforcement-hooks), then runs a premortem on the aggregated remediation plan. Also ships disciplined Linear issue execution with worktree isolation, PR review with Greptile gating, team release sync, E2E test generation/execution, test suite previewer, security pentesting, MoSCoW + RICE prioritization, cross-platform secret stash via OS-native GUI prompts (zenity / kdialog / osascript / Get-Credential), and session management. 37 commands, 11 auto-activating skills, 2 specialized agents.", - "version": "1.35.0", + "version": "1.36.0", "author": { "name": "Luis Andres Pena Castillo", "email": "lapc506@users.noreply.github.com" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 20ca43b..5b89844 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "make-no-mistakes", - "version": "1.35.0", + "version": "1.36.0", "description": "The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, manage sessions, stash secrets, and enforce manifest-driven tool-call hooks. One plugin to make no mistakes.", "author": { "name": "Luis Andres Pena Castillo", diff --git a/CHANGELOG.md b/CHANGELOG.md index 0411334..9ff9ef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.36.0] - 2026-07-31 + +### Added +- **`discard-stderr` hook rule** — blocks a `Bash` command that routes stderr to + `/dev/null`. 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". That is not a hypothetical: on + 2026-07-31 a `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 of them 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 that keyed on the tokens + alone would get one of the two wrong, and it is the permissive error that + costs, because a rule that blocks working commands gets removed. + + Three shapes stay allowed and each has a test pinning it: `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 have covered is already allowed above, 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`, which 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. + + 9 tests (4 blocking, 5 allowing). Rule count: 39 → 40. + ## [1.35.0] - 2026-07-29 ### Added diff --git a/README.md b/README.md index 844f57b..12f9b54 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # make-no-mistakes -**Version: 1.35.0** · [CHANGELOG](./CHANGELOG.md) · [Marketplace](https://github.com/DojoCodingLabs/make-no-mistakes-toolkit) +**Version: 1.36.0** · [CHANGELOG](./CHANGELOG.md) · [Marketplace](https://github.com/DojoCodingLabs/make-no-mistakes-toolkit) The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, and manage sessions. One plugin to make no mistakes. @@ -391,6 +391,7 @@ ships 10 rules: - `destructive-db-ops` — blocks `supabase db reset|push|repair` and inline `DROP/TRUNCATE/DELETE FROM` - `manual-edge-fn-deploy` — blocks `supabase functions deploy` (forces CI-only deploys) - `gcloud-missing-project` — warns when a `gcloud` subcommand is missing `--project=` +- `discard-stderr` (v1.36.0) — blocks a command that routes stderr to `/dev/null` (`2>/dev/null`, `&>/dev/null`, `>/dev/null 2>&1`). A failing command with its stderr discarded is indistinguishable from a succeeding one that printed nothing, so the empty result reads as *"none found"* rather than *"it errored"*. `cmd >/dev/null` alone is untouched — stderr still reaches you — and so is `cmd 2>&1 >/dev/null`, where stderr is duplicated to the original stdout *before* stdout is redirected and therefore survives. Same tokens, opposite outcomes; the rule matches on order. **No bypass marker** — see below. **PreToolUse on `Edit | Write | MultiEdit` (block):** - `minified-build-output` — blocks writing minified content to `amd/build/*.min.js` or `dist/*.min.{js,css}` @@ -430,6 +431,22 @@ becomes a no-op in that repo. The current sentinel filenames are: Bypasses are explicit acknowledgements — they sit inside the command/content itself, not as silent flags. +**Not every rule has one, on purpose.** `discard-stderr` (v1.36.0) ships with +`bypass_marker: null`, because the legitimate cases it might otherwise need a +bypass for are already *allowed* by the rule itself: `cmd >/dev/null` for noisy +stdout, `cmd 2>&1 >/dev/null` when stderr must survive, `command -v x >/dev/null` +for existence probes, and `cmd 2>>"$log"` to keep stderr somewhere readable. +With no case left over, a marker would only buy a way past a rule nobody needs +to get past. + +The general shape matters beyond this one rule: a gate whose refusal message +*prints the way around it* is not a gate. `pre-bash-block-main-target.sh` in +dojo-os accepted `DOJO_HOTFIX_TO_MAIN=1` **and** quoted that literal in its own +refusal, so typing the string it handed you WAS the authorization; two agents +filed false P0-hotfix claims that way on 2026-07-28 (DOJ-6247). A bypass is +worth its cost when it names a real case the rule cannot express. When it does +not, it is a password printed on the lock. + ### Adding your own rules Edit `hooks/rules/rules.yaml`, run `npm run build-rules`, run diff --git a/hooks/rules/rules.json b/hooks/rules/rules.json index aa434ee..d679365 100644 --- a/hooks/rules/rules.json +++ b/hooks/rules/rules.json @@ -3788,5 +3788,113 @@ "expected_exit": 0 } ] + }, + { + "id": "discard-stderr", + "description": "Block a command that routes stderr to /dev/null", + "applies_to": [ + "Bash" + ], + "match": [ + { + "field": "command", + "pattern": "(2>>?[[:space:]]*/dev/null|&>>?[[:space:]]*/dev/null|>&[[:space:]]*/dev/null|[^0-9&]1?>[[:space:]]*/dev/null[[:space:]]+2>&1)" + }, + { + "field": "command", + "not_pattern": "['\\\"]2>>?[[:space:]]*/dev/null" + } + ], + "action": "block", + "bypass_marker": null, + "memory_ref": "feedback_never_redirect_errors_to_devnull.md", + "references": [ + "dojo-os CLAUDE.md non-negotiable 24", + "DOJ-6391" + ], + "message": "BLOCKED: this command discards stderr.\n\nA failing command with its stderr discarded is INDISTINGUISHABLE from a\nsucceeding one that printed nothing. The empty result then reads as\n\"none found\" rather than \"it errored\".\n\nMeasured, 2026-07-31: `gh api --jq --arg ... 2>/dev/null` -- gh rejects that\nflag combination -- produced an empty file that was reported to the team as\n\"0 red PRs\". Sixteen of 41 were red, twelve on TypeScript.\n\nKeep stderr and put it somewhere:\n cmd 2>>\"$logfile\" # separate file, exit code preserved\n cmd 2>&1 | tee \"$logfile\" # interleaved, readable now and later\n out=$(cmd 2>&1); rc=$? # captured, inspected, still branchable\n\nOnly stdout is noisy? Already allowed, unchanged:\n cmd >/dev/null # stderr still reaches you\n cmd 2>&1 >/dev/null # stderr to the ORIGINAL stdout\n command -v x >/dev/null # the existence probe still passes\n\nNo bypass marker: the alternatives above cover every legitimate case, and\na gate whose refusal hands you the way around it is not a gate (DOJ-6247).\n", + "tests": [ + { + "name": "blocks-bare-2-dev-null", + "input": { + "tool_input": { + "command": "gh api foo 2>/dev/null" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-append-form", + "input": { + "tool_input": { + "command": "node x.mjs 2>> /dev/null" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-ordered-both-streams", + "input": { + "tool_input": { + "command": "ls -la >/dev/null 2>&1" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-ampersand-form", + "input": { + "tool_input": { + "command": "find . -name x &>/dev/null" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-stdout-only", + "input": { + "tool_input": { + "command": "ls -la >/dev/null" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-existence-probe", + "input": { + "tool_input": { + "command": "command -v jq >/dev/null" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-reversed-order-stderr-survives", + "input": { + "tool_input": { + "command": "cmd 2>&1 >/dev/null" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-stderr-to-a-file", + "input": { + "tool_input": { + "command": "node s.mjs 2>>\"$log\"" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-single-quoted-mention", + "input": { + "tool_input": { + "command": "git grep '2>/dev/null' -- scripts/" + } + }, + "expected_exit": 0 + } + ] } ] diff --git a/hooks/rules/rules.yaml b/hooks/rules/rules.yaml index 12c69cb..951440d 100644 --- a/hooks/rules/rules.yaml +++ b/hooks/rules/rules.yaml @@ -3127,3 +3127,79 @@ tool_input: command: 'gcloud sql import sql my-instance gs://backups/dump.sql --database=mydb # hook-bypass: db-mutation-rule' expected_exit: 0 + +- id: discard-stderr + description: Block a command that routes stderr to /dev/null + applies_to: [Bash] + match: + # Every form that sends STDERR to /dev/null. `>/dev/null 2>&1` is included + # 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. + - 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. + - field: command + not_pattern: "['\\\"]2>>?[[:space:]]*/dev/null" + action: block + bypass_marker: null + memory_ref: feedback_never_redirect_errors_to_devnull.md + references: + - "dojo-os CLAUDE.md non-negotiable 24" + - "DOJ-6391" + message: | + BLOCKED: this command discards stderr. + + A failing command with its stderr discarded is INDISTINGUISHABLE from a + succeeding one that printed nothing. The empty result then reads as + "none found" rather than "it errored". + + Measured, 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. + + Keep stderr and put it somewhere: + cmd 2>>"$logfile" # separate file, exit code preserved + cmd 2>&1 | tee "$logfile" # interleaved, readable now and later + out=$(cmd 2>&1); rc=$? # captured, inspected, still branchable + + Only stdout is noisy? Already allowed, unchanged: + cmd >/dev/null # stderr still reaches you + cmd 2>&1 >/dev/null # stderr to the ORIGINAL stdout + command -v x >/dev/null # the existence probe still passes + + No bypass marker: the alternatives above cover every legitimate case, and + a gate whose refusal hands you the way around it is not a gate (DOJ-6247). + tests: + - name: blocks-bare-2-dev-null + input: { tool_input: { command: "gh api foo 2>/dev/null" } } + expected_exit: 2 + - name: blocks-append-form + input: { tool_input: { command: "node x.mjs 2>> /dev/null" } } + expected_exit: 2 + - name: blocks-ordered-both-streams + input: { tool_input: { command: "ls -la >/dev/null 2>&1" } } + expected_exit: 2 + - name: blocks-ampersand-form + input: { tool_input: { command: "find . -name x &>/dev/null" } } + expected_exit: 2 + # The three below are the reason this rule survives contact with the repo. + # `command -v x >/dev/null` appears throughout these very hooks; a rule that + # failed here would be removed within a day. + - name: allows-stdout-only + input: { tool_input: { command: "ls -la >/dev/null" } } + expected_exit: 0 + - name: allows-existence-probe + input: { tool_input: { command: "command -v jq >/dev/null" } } + expected_exit: 0 + - name: allows-reversed-order-stderr-survives + input: { tool_input: { command: "cmd 2>&1 >/dev/null" } } + expected_exit: 0 + - name: allows-stderr-to-a-file + input: { tool_input: { command: "node s.mjs 2>>\"$log\"" } } + expected_exit: 0 + - name: allows-single-quoted-mention + input: { tool_input: { command: "git grep '2>/dev/null' -- scripts/" } } + expected_exit: 0 diff --git a/package.json b/package.json index 99683c6..db8b325 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lapc506/make-no-mistakes", - "version": "1.35.0", + "version": "1.36.0", "description": "The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, manage sessions, stash secrets, and enforce manifest-driven tool-call hooks (no SSH+DB, no manual prod, no minified build, no secret leaks, Slack format). OpenCode + Claude Code plugin.", "type": "module", "main": "./dist/index.js",