wrapper-guard: every block message goes to stdout, so agents are refused with no reason (Claude Code shows 'No stderr output') #1225

Open
opened 2026-08-15 07:48:54 +00:00 by Ghost · 0 comments

Symptom

Every wrapper-guard.sh refusal is invisible to a Claude Code agent. The agent sees only:

PreToolUse:Bash hook error: [sh -c 'G=$HOME/.config/mosaic/tools/git/wrapper-guard.sh; ...']: No stderr output

The refusal lands — the command is correctly blocked — but the reason, the remedy, and the
break-glass instruction never reach the agent. The guard writes a careful multi-paragraph
explanation naming the wrapper to use instead; none of it is ever read.

Cause

Claude Code's PreToolUse contract feeds the agent stderr on the exit-2 (block) path and
discards stdout. All seven block messages in wrapper-guard.sh are emitted with a bare
cat <<EOF, i.e. to stdout, immediately before exit 2:

line rule
563 checkout with $HOME unset/unusable
595 checkout under $HOME
733 unreadable request supplied from a config file
825 provider API write containing a percent-escape
1066 raw provider API write with unreadable endpoint
1120 raw provider API write to a wrapped endpoint
1144 the APPROVE/APPROVED trap

Consequence

This is worse than a cosmetic defect, because a silent block is indistinguishable from a crashed
hook. The documented remedy for each rule — use this wrapper, spell the request on the command
line
, the override token as the first thing in the command — has never been delivered to any
agent the guard has stopped. An agent that cannot see why it was refused retries variations
blindly, which is precisely the behaviour the guard's prose is written to prevent. The
APPROVE/APPROVED rule in particular exists to teach a non-obvious provider quirk, and has been
teaching it to /dev/null.

Fix

Route the seven block messages to stderr — cat >&2 <<EOF. No rule, threshold, condition or exit
code changes; only the destination. Verified locally: tripping the config-file rule now surfaces
the full message and remedy to the agent.

The same defect and the same one-character-per-site fix applied to three downstream hooks in a
consuming repo, so this is a pattern worth stating once in the hook-authoring guidance: a
PreToolUse/PostToolUse block message must go to stderr, because stdout on the exit-2 path is
discarded.

Second, smaller finding, found while filing this

The guard matches its rules against the entire command text, including heredoc bodies. Writing
this issue from a shell heredoc was itself blocked, because the body quotes the flag the rule
matches on. Prose describing a rule is not an invocation of it. This is not urgent — an agent can
route around it by writing the file with a file tool — but a guard that cannot be documented from
the shell it guards is worth a note. Matching the command's argv rather than its input text would
resolve it.

Note

wrapper-guard.sh is framework-owned and overwritten on upgrade, so the local fix does not survive
mosaic upgrade. That is why this is filed rather than only patched.

## Symptom Every `wrapper-guard.sh` refusal is invisible to a Claude Code agent. The agent sees only: ``` PreToolUse:Bash hook error: [sh -c 'G=$HOME/.config/mosaic/tools/git/wrapper-guard.sh; ...']: No stderr output ``` The refusal lands — the command is correctly blocked — but the *reason*, the remedy, and the break-glass instruction never reach the agent. The guard writes a careful multi-paragraph explanation naming the wrapper to use instead; none of it is ever read. ## Cause Claude Code's PreToolUse contract feeds the agent **stderr** on the exit-2 (block) path and discards stdout. All seven block messages in `wrapper-guard.sh` are emitted with a bare `cat <<EOF`, i.e. to stdout, immediately before `exit 2`: | line | rule | |---|---| | 563 | checkout with `$HOME` unset/unusable | | 595 | checkout under `$HOME` | | 733 | unreadable request supplied from a config file | | 825 | provider API write containing a percent-escape | | 1066 | raw provider API write with unreadable endpoint | | 1120 | raw provider API write to a wrapped endpoint | | 1144 | the `APPROVE`/`APPROVED` trap | ## Consequence This is worse than a cosmetic defect, because a silent block is indistinguishable from a crashed hook. The documented remedy for each rule — *use this wrapper*, *spell the request on the command line*, *the override token as the first thing in the command* — has never been delivered to any agent the guard has stopped. An agent that cannot see why it was refused retries variations blindly, which is precisely the behaviour the guard's prose is written to prevent. The `APPROVE`/`APPROVED` rule in particular exists to teach a non-obvious provider quirk, and has been teaching it to `/dev/null`. ## Fix Route the seven block messages to stderr — `cat >&2 <<EOF`. No rule, threshold, condition or exit code changes; only the destination. Verified locally: tripping the config-file rule now surfaces the full message and remedy to the agent. The same defect and the same one-character-per-site fix applied to three downstream hooks in a consuming repo, so this is a pattern worth stating once in the hook-authoring guidance: **a PreToolUse/PostToolUse block message must go to stderr, because stdout on the exit-2 path is discarded.** ## Second, smaller finding, found while filing this The guard matches its rules against the **entire command text**, including heredoc bodies. Writing this issue from a shell heredoc was itself blocked, because the body quotes the flag the rule matches on. Prose describing a rule is not an invocation of it. This is not urgent — an agent can route around it by writing the file with a file tool — but a guard that cannot be documented from the shell it guards is worth a note. Matching the command's argv rather than its input text would resolve it. ## Note `wrapper-guard.sh` is framework-owned and overwritten on upgrade, so the local fix does not survive `mosaic upgrade`. That is why this is filed rather than only patched.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mosaicstack/stack#1225