-
Notifications
You must be signed in to change notification settings - Fork 0
feat(hooks): refuse a command that discards stderr (v1.36.0) #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 P3 (minor) — This is the compiled JSON counterpart to the [pass 1] |
||
| } | ||
| ], | ||
| "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 | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 P2 (major) — A command starting with a redirection (such as Because [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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 P3 (minor) — The current Additionally, the seven backslashes in Using a refined regex pattern like [pass 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. | ||
|
|
||
| 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 | ||
There was a problem hiding this comment.
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
patternregex. Updating this ensures consistency with the corrected ERE pattern that matches start-of-command redirections.[pass 1]