Skip to content

The PR-ready door: what is broken, and what shape a fix should take โ€‹

Status: proposal, 2026-08-21. Nothing has been changed. This document exists to be decided on.

Start here โ€‹

What this is about. .claude/hooks/guard-pr-ready.sh runs before every shell command an agent tries. Its one job is to refuse gh pr ready, the command that takes a pull request out of draft. Marking a PR ready is your decision, not an agent's: it is your review point and it starts billed CI. The hook is the mechanical layer under the written rule (AGENTS.md rules 12 and 16), added 2026-08-09 after an agent read a forward-looking plan sentence as though it were live permission.

What a "door" means here. The hook has an intentional way through, for when you HAVE approved: putting CLAUDE_ALLOW_PR_READY=1 in front of the command. That is the door. This document is about the door's shape, not about whether the rule is right.

What you are being asked to decide. Whether to change the hook, and if so in one step or two. Nothing here is on fire, but one of the two defects means the guard can be walked past without anyone intending to.

Why an agent did not just fix it. An agent editing a permission gate that just blocked it is the exact shape the never-bypass rule exists to stop. So this stops at a proposal.

Evidence. tooling/scripts/__tests__/guard-pr-ready.test.js, 14 tests, on the same branch. Six pin what the hook gets right. The rest pin what it does today, defects included, so that changing it produces a visible diff rather than a silent one.

What the hook gets right โ€‹

Worth saying first, because the defects are narrow and the hook is mostly doing its job. A bare ready-marking is refused. Converting a PR back TO draft, the safe direction, is allowed. And a safe form in one part of a command cannot smuggle a real one through in another part.

Defect 1: it blocks writing ABOUT the command โ€‹

Writing a document that merely mentions the command is refused. This is blocked:

cat > notes.md <<EOF
To publish, run:
gh pr ready 934
EOF

Nothing there marks anything ready. It writes a text file.

The circular part: the hook's own error message tells you to type CLAUDE_ALLOW_PR_READY=1 gh pr ready <number>, and the hook then refuses to let you write that sentence into a document. Documenting the door trips the guard.

Not hypothetical. It blocked the shell command used to investigate this very defect, which is how it was found, and it blocked the first attempt to write THIS FILE from a shell.

Who feels this: every agent writing a runbook, a PR description, or a doc that references the command. Constantly, and it is pure friction.

Defect 2: prose containing the override OPENS the door โ€‹

This is the serious one. This whole command runs cleanly, with the guard never firing:

cat > runbook.md <<EOF
If she approves:
CLAUDE_ALLOW_PR_READY=1 gh pr ready <number>
EOF
gh pr ready 934

The runbook gets written and the PR gets marked ready. An agent writing documentation about the approval process would silently gain the ability to bypass the approval process.

Mechanism, established by a control rather than assumed. The hook checks whether the override sits at the start of a command. It uses grep, and grep reads a multiline command one line at a time, so "the start of a command" matches the start of every line, including lines inside a text block that only ever get written to a file.

The control that established this: the same override text placed mid-line, inside quotes, does not open the door. A first attempt asserted that it would, and that attempt FAILED. That failure is what located the real cause. Without it the finding would have been true and wrongly explained, and a fix aimed at the wrong cause would have looked like it worked. The failed attempt is kept as a test.

Who feels this: nobody, until it matters once.

What the two defects share โ€‹

The hook decides using text that will never be executed. A text block written into a file is not a command. Both the over-blocking and the under-blocking come from reading it as one.

The one deliberate exception is correct and should survive any change: when the command is gh api, a text block piped into it genuinely IS the request being sent, so it must still be read.

The two defects pull in OPPOSITE directions โ€‹

This matters for triage, because the annoying one is the one that gets noticed:

DefectDirectionWho notices
1. Blocks writing about the commandrefuses too MUCHevery agent, every day
2. Prose containing the override opens the doorrefuses too LITTLEnobody, until it matters once

A change that only fixes defect 1 leaves defect 2 standing. Defect 1 generates the daily friction, so that is the likely accident. They share one root, so one change fixes both, but only if it is made at the root rather than at the symptom.

Defect 3: the door may be hard to walk through โ€‹

The hook offers exactly one way for a session that has your approval to proceed: putting the override in front of the command. There is a report of 103 permission-prompt refusals across 15 sessions on that shape.

That number should not be built on yet. An override-prefixed command is not universally refused: CLAUDE_ALLOW_BRANCH_OPS=1 git checkout -b <branch> origin/dev ran on this machine several times today with no refusal at all. So the blocker is probably not the prefix itself but something about the specific action. Not established either way, and a redesign built on the wrong reading would be worse than no redesign.

What shape a fix should take โ€‹

A door is not a lock. This hook lets things through when it cannot parse them, deliberately, and its own header says so. It is a tripwire against an accident or a rationalisation, not a jail against a determined agent. "Make it unbypassable" is the wrong goal and would trade a real property for a false sense of one.

Three properties, in priority order:

  1. Decide only on text that will actually be executed. Ignore text blocks being written to a file, on both checks; keep reading them for the gh api case. One change, and it fixes defects 1 and 2 together.

  2. The door should need something an agent cannot produce by itself. The override today is an honour-system flag: the same actor decides and acts, and nothing about it requires you. The tappable question prompt is the one thing here whose answer an agent cannot fabricate. The flag can stay as the mechanism the hook honours, while the hook's MESSAGE points at asking you rather than at typing a magic string.

  3. Use should be visible afterwards. A door is legitimate when it is written down, used in scope, and visible after the fact. Today nothing records that the override was used. Naming it in the PR description is a convention, not a mechanism.

Is the fix "strictly safer"? โ€‹

Nearly, and the precise version matters more than the reassuring one.

Ignoring written-out text blocks does two things at once:

  • The override check fires less often, so the hook refuses more real ready-markings. Safer, and it closes defect 2.
  • The command matcher fires less often, so the hook refuses fewer document writes. Looser, and it fixes defect 1.

So it is not strictly safer in every direction. The accurate statement: the loosening applies only to text that gets written to a file and never runs, while every change in the direction of permitting an actual ready-marking is a tightening. The hook can only get harder to walk through for the action it exists to gate.

If that is not enough certainty to approve in one go, it splits cleanly into two decisions. The override-check half can land on its own and is unambiguously safer, with no downside beyond leaving the daily friction in place a while longer. The friction half can follow whenever you want.

What is deliberately NOT proposed โ€‹

  • Not making the hook unbypassable. It lets things through on purpose.
  • Not removing it, not loosening the refusal of a bare ready-marking, and not adding a list of allowed flags. The hook's own header explains why a flag list that scans the whole command is itself a way through.
  • Not any change made by the session that wrote this. The tests are safe to land because they only observe.

A neighbouring failure this does NOT fix โ€‹

A second live case the same day: guard-em-dash.sh blocked a command whose only em dash sat inside a search pattern looking for em dashes, added to check that a file contained none. The guard blocked a compliance check for itself.

Property 1 does not help there. That character IS in text that runs; it runs as a search argument, and its role is the opposite of the thing being guarded. The question there is not "will this execute" but "what is it doing in the command".

Filed separately as A text-scanning guard cannot tell content from a pattern that matches content (#945). The two approach the same shared mistake, reading raw command text, from opposite ends: this one says look only at what executes, that one says look at what gets written.

Sibling to check before changing either โ€‹

.claude/hooks/guard-git-branch.sh handles text blocks differently on purpose, and the header of each hook says to mirror fixes in the other. Whether the same open-the-door hole exists there has not been tested, and should be first.

Where the numbers in this document come from โ€‹

  • #934: fix(ci): install Playwright system deps on every run, plus four DX fixes. Merged 2026-08-20. Cited as the case where taking a PR out of draft DID re-fire CI.
  • #935: fix(admin): stop admin sign-in falling through to the legacy tier, and give the Agent Probe a portal password. Merged 2026-08-20. Cited as the case where it did not.

Built with VitePress