Skip to content

tooling(hooks): the guards' escape hatch names where it actually works - #15987

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-15971-hatch-message-reachability
Sep 5, 2026
Merged

tooling(hooks): the guards' escape hatch names where it actually works#15987
os-zhuang merged 2 commits into
mainfrom
claude/issue-15971-hatch-message-reachability

Conversation

@os-steve

@os-steve os-steve commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15971

Every guard's refusal ended by telling the reader to re-run the blocked thing with the override spelled as a command prefix. A VAR=1 cmd prefix sets the variable in the environment of that command; the hook is not that command, and it reads the variable from its own environment — so the one place an operator naturally applies the printed remedy is the one place it cannot work.

The guard is right and is not weakened here: no predicate, no OS_ALLOW_* check and no block/allow verdict is touched. Message text only. The hatch stays, because it is the repo's sanctioned deliberate exception and CLAUDE.md names it; what changes is that the sentence now names the environment the hook actually reads.

Reproduced first, against a throwaway fixture (never the shared checkout)

A fixture primary checkout under $TMPDIR, fed the same PreToolUse payload shape Claude Code delivers, with the prefix spelled exactly as the old message told the reader to spell it:

### A. the printed remedy, applied exactly as printed (prefix on the command)
exit=2
⛔ Blocked: this Bash command WRITES into the shared PRIMARY checkout, not a worktree.
   command: OS_ALLOW_MAIN_EDITS=1 rm -f $FIX/x
   target:  $FIX/x

### B. the SAME payload with the variable in the hook's OWN environment
exit=0

VERDICT: prefix_exit=2 env_exit=0  (2 = blocked, 0 = allowed)

The hook sees the prefix in the command string and blocks anyway — the prefix never became part of the hook's environment.

The exact sentences, for the objectui twin to mirror

Six message sites, one wording. The three-line tail is byte-identical at all six; only the head line carries each hook's own qualifier and variable name. Diffing the twin against this block is therefore mechanical.

Shared tail, all six sites:

environment this hook itself runs in — a local settings "env" entry, or whatever this
agent process was started with. A VAR=1 prefix on a command sets it for that command
only, and this hook is not that command, so a prefix never reaches it.

Head line, per site:

guard-main-checkout.sh:86   (schema-drift refusal)
guard-main-checkout.sh:140  (the ordinary refusal)
guard-main-checkout-bash.sh:574
  Deliberate non-task exception: set OS_ALLOW_MAIN_EDITS=1 in the

guard-shared-stash.sh:224
  Deliberate exception (the stack really is yours alone): set OS_ALLOW_STASH=1 in the

guard-tree-enum.sh:314 (its two-line qualifier is unchanged; only the second line moves)
  what YOUR branch changed): set OS_ALLOW_TREE_ENUM=1 in the

guard-governed-enqueue.sh:558
  Deliberate exception (you know this one is right): set OS_ALLOW_GOVERNED_ENQUEUE=1 in the

Removed at all six: the re-run with OS_ALLOW_...=1 prefix remedy (the governed-enqueue line implied the same reading without the words).

The objectui twin (objectstack-ai/objectui#7775) is dispatched separately and waits behind objectui PR #7749 on the same two files; it mirrors the block above.

Self-tests — both directions pinned

Each of the five matrices gains an absence assertion (the dead prefix remedy is gone from the emitted text) and its positive twin (the sentence names the environment the hook reads). The allow rows that already export the variable into the hook's environment are the other half of the pair and were left as they are. The Bash matrix additionally pins the card's own reproduction: the prefix spelled as the old message told the reader to spell it must still block.

Green on this head:

guard-governed-enqueue.selftest.sh    52 passed, 0 failed
guard-main-checkout-bash.selftest.sh 130 passed, 0 failed
guard-main-checkout.selftest.sh      120 passed, 0 failed
guard-shared-stash.selftest.sh        53 passed, 0 failed
guard-tree-enum.selftest.sh           38 passed, 0 failed

Red against the OLD message text — the new assertions run against scratch copies of the hooks read out of the merge base with git show, never against the tree:

guard-main-checkout      116 passed, 4 failed   (both message sites x 2 assertions)
guard-main-checkout-bash 128 passed, 2 failed
guard-shared-stash        51 passed, 2 failed
guard-tree-enum           36 passed, 2 failed
guard-governed-enqueue    50 passed, 2 failed

Two readings of that last line, stated rather than buried. Its lacks assertion passes against the old text too, because that message never printed the words — which is exactly why its positive twin earns its place; only the twin goes red there. Its second failure ("the hook agrees with the register about an exception-row candidate") is an artifact of the scratch root, not a regression: both the hook and the register resolve their repo root from the hook's own location, and the scratch root is not a real checkout. Isolated by putting the new hook in that same scratch root, where the same case still fails, while the matrix is 52/52 in the tree.

Verification, all at a4f2124a4f

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 13 commands from the 10 changed paths; all 13 ran. Reconciliation: ✓ dispatch-gates --ran: 13 derived famil(ies) accounted for — 13 run, 0 NOT-MEASURED.
  • 12 of the 13 were green first time. check:doc-formula-expressions returned exit 3 — prerequisite not met, nothing measured (unbuilt @objectstack/formula and @objectstack/lint); after building those two it is exit 0. Recorded as the gate itself insists: exit 3 is not a finding.
  • All five .claude/hooks/*.selftest.sh, green (numbers above). CI discovers these by glob, so the added assertions run there with no workflow edit.
  • node scripts/pm/check-governed-merges.mjs --test on the 10-path file list: exit 3, GOVERNED.claude/** x10. Hence draft, skip-changeset, human merge; no ready flip, no enqueue, no auto-merge.
  • Whole-repo pnpm lint (eslint . --no-inline-config, no narrowing) through scripts/pm/os-verify-lock.sh in slot issue-15971: VERDICT command-exit 0.
  • Every exit code captured by redirecting to a file before any pipe.

CLAUDE.md, AGENTS.md and settings*.json are untouched — they already name the overrides without the prefix form, which is why the defect lived only in the emitted messages.

维护者速读(草稿)

这张卡在修什么。 五个护栏(worktree 守卫的两处消息、Bash 守卫、stash 守卫、tree-enum 守卫、governed-enqueue 守卫)在拦下一次操作时,最后一句都告诉读者「带上 OS_ALLOW_xxx=1 重跑一遍」。这句话在它被打印的那个位置恰好行不通:VAR=1 命令 这种前缀只把变量塞进那条命令自己的环境,而钩子不是那条命令,它读的是它自己的环境。填卡的席位实测被拦了两次,本 PR 动手前又在一次性夹具上复现了一次。

为什么值得一张卡,而不是耸耸肩。 护栏本身是对的,这个改动一个字都没动它的判定逻辑。风险全在教学面:一个照着提示做、发现照样被拒的 agent,离「这护栏坏了」只有一步,再一步就是 --no-verify、改 settings、或者干脆把钩子关掉——那些的破坏力远大于它原本想写的那一次改动。一条不成立的指令,等于在邀请别人绕过护栏。

改法。 保留豁免口(它是仓库明文承认的 deliberate exception,CLAUDE.md 点名了它),只把话说准:变量要设在钩子自己所在的那个环境里,命令前缀永远到不了钩子。六处消息共用同一段措辞,三行尾巴逐字节相同,objectui 的孪生卡因此可以机械镜像,两个仓不会各自漂移。

风险面。 纯文案改动:谓词、OS_ALLOW_* 判断、每一条 block/allow 判定都没碰,CLAUDE.md/AGENTS.md/settings*.json 也没碰。五个自测矩阵 393 例在本 head 全绿;更要紧的是每个矩阵新增的断言拿旧文案跑都确实转红,所以这几条断言不是摆设。.claude/** 命中治理面,按规矩走草稿 + 人工合并。

席位意见。 (留空,待席位在 ACCEPT 时填写。)


🤖 Generated with Claude Code

https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox


Generated by Claude Code

Every guard's refusal ended by telling the reader to re-run the blocked thing with
OS_ALLOW_*=1 as a command prefix. A VAR=1 prefix sets the variable in the environment
of THAT COMMAND; the hook is not that command, and it reads the variable from its own
environment — so the one place an operator naturally applies the printed remedy is the
one place it cannot work, and a remedy that does not work is an invitation to route
around the guard.

The hatch stays: it is the repo's sanctioned deliberate exception, named in CLAUDE.md.
What changes is that the sentence now names the environment the hook actually reads and
says plainly that a prefix never reaches it. One wording, byte-identical across all six
message sites, so the objectui twins can mirror it mechanically.

Message text only — no predicate, no env check, no block/allow verdict is touched. Each
matrix gains the absence assertion (the dead prefix remedy is gone) and its positive twin
(the sentence names the environment the hook reads); the Bash matrix also pins the card's
own reproduction, that the prefix spelled as the old message told the reader to spell it
still blocks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
@github-actions github-actions Bot added the size/m label Sep 5, 2026
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 5, 2026

os-steve commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

ACCEPT — lands #15971 as ruled: message text only, the guards' predicates untouched. Governed (.claude/** ×10, predicate exit 3), so this PR stays draft at the governed terminal: both approvers requested; needs-user-decision goes on the PR with the final 「维护者速读」 posted beneath this verdict.

What the seat verified, at head a4f2124a4f, in its own compare worktree (merge base aa6ba0623a)

  • Ten files, +205/−6: six refusal sites across five hooks now end in the same three-line tail — set the variable in the environment the hook itself runs in (a local settings env entry, or whatever the agent process was started with); a VAR=1 prefix on a command sets it for that command only and never reaches the hook. The tail is byte-identical at all six sites, so objectui#7775 can mirror it mechanically once PR A bearer-authenticated admin metadata write is stamped actor: 'system'req.user / req.userId are unset on the /meta PUT path #7749 lands.
  • Zero diff lines touch an OS_ALLOW_* check or a predicate; re-run with survives only inside the new self-test assertions. Every added line ≤ 89 bytes; bash -n clean.
  • All five matrices green in the seat's own run: 52 · 130 · 120 · 53 · 38 (393 cases); the new assertions were shown red against the old message text read out of the merge base. check-governed-merges --test exit 3.
  • Body line 1 Fixes #15971; skip-changeset present; the 草稿 section is in the body.

CI, seat's read 17:2xZ: 29 check runs — 16 success, 12 skipped, 1 failure: Lint & Repo Gates, failing at pnpm check:merge-driver (check-regen-pending.mjs --self-test). That step is red on main itself — at this branch's merge base aa6ba0623a and at main's head f7db8f4fd2 — with a byte-identical verdict, and it touches no path of this PR. It is tracked as #15992 (p0, dispatched in the devx lane) and #15994. Standing down on that check: the fix is outside this PR's surface and porting it here would widen a hooks PR; the merge queue re-runs the gate once the fix lands. The approvers can review the diff meanwhile; the seat re-reads the PR at each patrol until it is green.

Implemented-by: os-dev executor, flight #15971, branch claude/issue-15971-hatch-message-reachability
Reviewed-by: pm-dispatch skills seat, https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox


Generated by Claude Code

@claude
claude Bot requested review from hotlong and os-zhuang September 5, 2026 17:59

os-steve commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

维护者速读

改了什么:五个守卫钩子(worktree 守卫的 Edit/Write 与 Bash 两个、stash 守卫、tree-enum 守卫、受管面入队守卫)拦截时打印的最后一句。原文是「带上 OS_ALLOW_xxx=1 重跑一遍」;现在改为说明变量要设在钩子自己运行的那个环境里(本地 settings 的 env 项,或启动 agent 进程时的环境),并明说命令前缀 VAR=1 命令 只对那条命令生效、永远到不了钩子。六处消息共用同一段三行措辞,逐字节相同;判定逻辑、OS_ALLOW_* 检查、每条拦/放判决一个字没动;CLAUDE.md / AGENTS.md / settings 没动。

为什么改:立卡席位按提示原样执行被拦了两次,dev 动手前又在一次性夹具上复现:前缀形式在它被打印的位置恰好行不通。守卫本身是对的;风险在教学面——一条照做不生效的指令,会让 agent 断定「守卫坏了」,下一步就是绕过它(--no-verify、改 settings、关钩子),那比它原本想写的那一次改动破坏力大得多。

风险与代价(含回滚):纯文案,零判定变化;五个自测矿阵 393 例全绿,每个矿阵新增的断言拿旧文案跑确实转红(不是摆设)。CI 有一处红,不是本 PR 的:Lint & Repo Gatescheck:merge-driver 步失败,main 自身此刻同一步同样红(#15992,p0,devx 车道在修);本 PR 未触及该路径,不在这里顺手改,等 main 修好队列会重跑。回滚 = revert 一个 commit。

席位意见:建议批准。四轴:业务——实测两次被拦、一次复现,不是推测;长远——豁免口保留但说准位置,守卫的自述与行为一致;防 AI 错——去掉一条会诱导绕行的错误指令,正是护栏的教学面;创业阶段——文案改动,零新机制。objectui 双子卡 #7775 等 PR #7749 落地后机械镜像。

你要做的:批准;合并等 main 的 merge-driver 门禁修好后由队列重跑,或你人工直合。一字:是/否。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 5, 2026 22:37
@os-zhuang
os-zhuang enabled auto-merge September 5, 2026 22:38
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit c252041 Sep 5, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-15971-hatch-message-reachability branch September 5, 2026 23:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-user-decision size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants