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",