Skip to content

hooks: the three remaining guards name the environment their hatch variable must be set in, never a command prefix - #9300

Draft
claude[bot] wants to merge 2 commits into
mainfrom
claude/issue-7775-hook-hatch-wording
Draft

hooks: the three remaining guards name the environment their hatch variable must be set in, never a command prefix#9300
claude[bot] wants to merge 2 commits into
mainfrom
claude/issue-7775-hook-hatch-wording

Conversation

@claude

@claude claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #7775

guard-main-checkout-bash.sh, guard-shared-stash.sh and guard-tree-enum.sh each ended their
refusal with re-run with OS_ALLOW_*=1. A VAR=1 prefix on a command sets the variable for THAT
COMMAND; the hook reads ${OS_ALLOW_*:-} from its OWN environment, where nothing set it, so it
blocks again while echoing the prefix it just saw. The fourth guard, guard-main-checkout.sh, was
already repaired (objectui#7259 / PR #7749), which left two sibling guards for the same escape
hatch giving an operator opposite instructions — and the one still advertising the dead remedy was
the Bash guard, the one reached when an agent writes through a shell rather than through Edit/Write.

The wording is objectstack's, at origin/main 7c2c5aed (objectstack#15971), so the executable
lines of the guards stay identical across the two repositories, which is what the card requires.
No third wording was invented.

Route: these six files are PINNED ports, so the deliberate act is a re-sync

First pass hand-edited the three sentences. check-upstream-port-parity red-flagged it, correctly:
all six files are pinned ports of objectstack's copies with declared divergences, and the gate's own
remedy for wording that moved upstream is a re-sync, not an edit. This PR now carries two commits —
the port, then the pin bump — and the gate's procedure was run once per ported path:

node scripts/check-upstream-port-parity.mjs --resync UPSTREAM_FILE   --path .claude/hooks/FILE --ref 7c2c5aedd94d7b0d94c91432bc607862e9c83c6c   --rewrite-governed-file

with UPSTREAM_FILE from git show origin/main:.claude/hooks/FILE in the objectstack checkout.
--rewrite-governed-file is required because .claude/** is governed surface here; the gate names
the flag and printed the in-place rewrite for each of the six. No pinned digest was edited by hand.

What the re-sync changed beyond the three sentences: nothing in any hook. All six files came
back byte-identical to the hand-ported commit (diff per file printed nothing, six for six), so
the only diff the second commit carries is scripts/upstream-port-pin.json — six ref values to
7c2c5aed and six upstreamSha256 values, twelve lines, with the five other pinned entries
keeping their own refs. That is the pin registry, the gate's own output, and it is the one file in
this PR outside .claude/hooks/**.

The reason the rewrite was a content no-op was measured beforehand, not hoped for: objectstack's
movement since each pinned ref is exactly this card's change and nothing else — +4/-1 on each
hook and +47 / +39 / +41 on the three self-tests, line-for-line what the first commit adds. Every
declared divergence was re-applied and survives: guard-shared-stash.sh still carries its own
self-test case count with its re-derivation recipe, and the objectui card references
(objectui#3430 / #3435 / #6042) and worktree example names are intact.

The three hatch sentences, before and after

.claude/hooks/guard-main-checkout-bash.sh (was :562)

- Deliberate non-task exception: re-run with OS_ALLOW_MAIN_EDITS=1.
+ Deliberate non-task exception: set OS_ALLOW_MAIN_EDITS=1 in the
+ 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.

.claude/hooks/guard-shared-stash.sh (was :206)

- Deliberate exception (the stack really is yours alone): re-run with OS_ALLOW_STASH=1.
+ Deliberate exception (the stack really is yours alone): set OS_ALLOW_STASH=1 in the
+ 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.

.claude/hooks/guard-tree-enum.sh (was :321; the sentence's first line is unchanged)

  Deliberate exception (the working tree really is the population you mean — e.g. asking
- what YOUR branch changed): re-run with OS_ALLOW_TREE_ENUM=1.
+ what YOUR branch changed): set OS_ALLOW_TREE_ENUM=1 in the
+ 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.

The assertions that keep it from being re-lost

Each of the three self-tests gains the pair the already-repaired sibling uses at
guard-main-checkout.selftest.sh :244 / :248, plus the stderr_of / says / lacks helpers
those assertions need — the three matrices had no message-text helper at all, which is the gap the
card's triage comment named: they asserted verdicts only, never message text, so nothing mechanical
would have noticed the divergence or a later regression.

lacks 're-run with'         'the refusal no longer prints the prefix remedy'
says  'hook itself runs in' 'the refusal names the environment this hook reads'

The bash matrix also takes upstream's two prefix-reproduction rows, which assert that the prefix
spelled exactly as the old message told the reader to spell it still BLOCKS — the twin of the
allow rows next door where the same variable really is in the hook's environment.

Ablation — the new assertions can actually fail

Run from the committed state, all three hooks mutated back to the old one-line sentence at once,
with a trap restore on absolute paths:

  • on-disk proof per file: re-run with count 0 to 1, hook itself runs in count 1 to 0, and
    git hash-object differing from the HEAD blob (all three, non-empty hashes both sides).
  • under mutation: 128 passed, 2 failed / 48 passed, 2 failed /
    guard-tree-enum selftest: 36 passed, 2 failed, each failing exactly
    FAIL still says "re-run with" and FAIL missing "hook itself runs in".
  • restore leg: git checkout HEAD -- PATH per file, proven by git diff HEAD being 0 bytes and
    each file's git hash-object equal to its HEAD blob; the three matrices return to
    130 / 50 / 38 passed, 0 failed.

No ablation artefact is left in the tree.

Verification, on the head after the re-sync

Self-tests, verdict lines as the runs print them (exit codes captured before any pipe):

guard-main-checkout-bash   before 126 passed, 0 failed   after 130 passed, 0 failed   exit 0
guard-main-checkout        before 120 passed, 0 failed   after 120 passed, 0 failed   exit 0   (untouched)
guard-shared-stash         before  48 passed, 0 failed   after  50 passed, 0 failed   exit 0
guard-tree-enum            before  36 passed, 0 failed   after  38 passed, 0 failed   exit 0

git grep -n 're-run with' -- .claude/hooks: 5 hits before, 5 after — but a different five. Before:
three live hatch sentences plus the repaired hook's two assertions. After: five self-test assertion
lines and no hook body line at all.

check-upstream-port-parity                     exit 0  11 ported file(s) match, 5 distinct refs
check-upstream-port-parity --self-test         exit 0  58 cases pass
check-lint-coverage                            exit 0  46/46 packages linted
check-entry-guard                              exit 0  93 scripts/ file(s)
check-control-bytes                            exit 0  scanned 7491 tracked text file(s)
check-bash32-floor --self-test / run           exit 0  160 cases pass / 13 shell file(s), floor bash 3.2
check-governed-queue-guard --self-test         exit 0  185 cases pass
check-governed-queue-guard --test (this diff)  exit 3  governed: park as DRAFT, human merges
check-changeset-presence                       exit 0  7 file(s) changed, 0 published source -> none owed
check-shell-escape-residue                     exit 0
check-new-cross-file-line-citations            exit 0  0 new citation(s)

That is every pre-install step of the Lint job plus the gates that read .claude/hooks/**. The
post-install half (pnpm lint, pnpm check, check-vi-mock-override-shape,
check-test-path-roots) is NOT MEASURED locally: this worktree has no node_modules and the card's
surface is plain shell. eslint in particular is not narrowed here, it is empty — its own config's
population is files: ['**/*.{ts,tsx}'] and narrower blocks, and this diff contains zero .ts /
.tsx files. scripts/__tests__/upstream-port-parity-wiring.test.ts has a case "and the tree
itself is at parity right now" that runs the gate through execFileSync; it failed on the previous
head and the gate's exit 0 above is what clears it, but the vitest run itself is left to CI for
the same reason.

Gate derivation was by hand: objectui has no scripts/pm/dispatch-gates.mjs (only
scripts/pm/check-half-states.mjs), so the list comes from this repo's own package.json check:*
aliases and .github/workflows/, filtered to the gates that read .claude/hooks/** or scripts/**.

Acceptance notes

  • guard-shared-stash.sh's header states its self-test's case count with a recipe to re-derive it,
    and hook-selftests.yml's header names that header as the one place such a count belongs. The
    two new assertions move it, so it is re-derived by its own recipe: 46 expect lines + 2 inline
    specials + 2 message assertions = 50, equal to the run's own tail. The pin entry
    selftest-case-count-and-derivation declares that count as a divergence from upstream's 51, so
    its ported text moved with the header in the first commit — otherwise the re-sync in the second
    would have written the stale number straight back.
  • noted, not filed: the card's triage comment states guard-main-checkout-bash.sh is NOT in the
    pin registry. Measured false on 243fc8317 — it is pinned, as are all five of its siblings here,
    which is exactly why the first pass went red. The comment's companion claim, that
    guard-main-checkout-bash.selftest.sh asserts verdicts only and never message text, was true and
    is what this PR repairs.
  • noted, not filed: the ported comment blocks carry a bare #15971, which reads in this repo as a
    nonexistent objectui card rather than the objectstack one. It is upstream's byte, re-applied by
    the re-sync, and the already-landed sibling block at guard-main-checkout.selftest.sh :244
    spells it the same way. Changing it here would become a seventh declared divergence.

维护者速读(草稿)

改了什么 — 三个 PreToolUse 守卫(Bash 写入守卫、stash 守卫、tree-enum 守卫)的拒绝文案末句。
原句教人 re-run with OS_ALLOW_*=1,即在命令前加变量前缀;这种前缀只对那条命令生效,而守卫读的是
它自己进程的环境,所以照做仍然被拦。新句直接点名唯一有效的位置:守卫自身运行的环境(本地 settings
env 项,或启动这个 agent 进程时带的环境)。三个自测各加一对断言,钉住「死办法已消失」和「新句
已点名环境」两个方向。文案不是手写的:这六个文件是 objectstack 同名文件的 pin 化移植,所以走的是
门禁自己的 --resync,ref 重钉到 objectstack 7c2c5aed;pin 台账 scripts/upstream-port-pin.json
的 12 行(六个 ref + 六个摘要)是本 PR 唯一落在 .claude/hooks/** 之外的改动。

为什么改 — 一条做不到的指示,比没有指示更糟:它把人推向绕过守卫。第四个守卫早先已修,于是同一
个逃生口上两个兄弟守卫给出相反的指示,而还在教死办法的那个恰好是走 shell 写入时命中的那个。这三个
自测此前只断言放行/拦截,从不断言文案,所以既没有东西会发现这次分歧,也没有东西会发现修好后又退回去。

风险与代价(含回滚) — 守卫的判定逻辑一行未动,改的只是 heredoc 里的说明文字和自测断言;四个自测
全绿(130 / 120 / 50 / 38,0 失败),并已用消融证明新断言真的会红。pin 重钉经实测是内容中性的:六个
文件重写后与重钉前逐字节相同,因为上游自各自 pin 以来的移动恰好只有这一次改词。回滚是 revert 这两个
commit,不涉及数据、不涉及发布物;没有 changeset(门禁判定「无需」)。

席位意见 — (留空,由席位定稿成评论)

你要做的 — 1) 读一遍三句新文案是否就是你要的规程措辞;2) 确认 pin 重钉到 7c2c5aed 是你要的
provenance(带 --rewrite-governed-file 的写入按名保留给合并受管面的人,本轮由席位在 PM 明确改路后
执行,这一点写在这里供你复核);3) 本 PR 是受管面(.claude/**),停在 draft 等你合并,席位不翻
ready、不入队、不挂 auto-merge。

Session for this implementation: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK


Generated by Claude Code

…riable must be set in, never a command prefix

guard-main-checkout-bash.sh, guard-shared-stash.sh and guard-tree-enum.sh each
ended their refusal with `re-run with OS_ALLOW_*=1`. Followed literally, a
`VAR=1 <cmd>` prefix sets the variable for that command only; the hook reads
`${OS_ALLOW_*:-}` from its OWN environment, where nothing set it, so it blocks
again while echoing the prefix it saw. An instruction that cannot work where it
is printed is an invitation to route around the guard, and after the
guard-main-checkout.sh half landed the two sibling guards for the same hatch
were giving an operator opposite instructions — the Bash guard, reached when an
agent writes through a shell, being the one still advertising the dead remedy.

Each hatch sentence is replaced with the objectstack twin's wording, ported
byte-for-byte so the executable lines of the guards stay identical across the
two repositories. Reversing the pin's declared divergences out of all six
ported files reproduces objectstack@7c2c5aed's blobs exactly.

Each self-test gains the pair that keeps this from being re-lost: `lacks
're-run with'` (the dead remedy is gone) and `says 'hook itself runs in'` (the
sentence names the environment the hook really reads), in the idiom
guard-main-checkout.selftest.sh already uses, together with the stderr_of /
says / lacks helpers those two assertions need. The bash matrix also takes
upstream's two prefix-reproduction rows, which assert that the prefix spelled
exactly as the old message told the reader to spell it still blocks.

guard-shared-stash.sh's self-test case count is re-derived by the recipe the
header itself gives (46 `expect ` lines + 2 inline specials + the 2 new message
assertions = 50, equal to the run's own tail), and the pin entry that declares
that count as a divergence from upstream moves with it.

Self-tests, in this worktree: guard-main-checkout-bash 130 passed 0 failed,
guard-main-checkout 120/0 (untouched), guard-shared-stash 50/0, guard-tree-enum
38/0. `git grep -n 're-run with' -- .claude/hooks` now hits 5 lines, every one
of them a self-test assertion.

check:upstream-port-parity is RED on six stale pin entries and needs a re-sync
at merge: the pinned refs predate this wording upstream, and the re-sync write
path is reserved by name for the human merging governed surface.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK
check-upstream-port-parity red-flagged the previous commit: these guards are
PINNED ports of objectstack's files, so the deliberate act for wording that
moved upstream is a re-sync, not a hand edit. Ran the gate's own procedure, once
per ported path:

  node scripts/check-upstream-port-parity.mjs --resync UPSTREAM_FILE \
    --path .claude/hooks/FILE --ref 7c2c5aedd94d7b0d94c91432bc607862e9c83c6c \
    --rewrite-governed-file

for guard-main-checkout-bash.sh, guard-shared-stash.sh, guard-tree-enum.sh and
their three self-tests, with UPSTREAM_FILE taken from
`git show origin/main:.claude/hooks/FILE` in the objectstack checkout. The
governed-surface flag is required because .claude/** is governed here; the gate
names it and printed the rewrite for each of the six. No pinned digest was
edited by hand.

What the re-sync changed: nothing in any hook. All six files came back
byte-identical to the previous commit's hand-ported text, because objectstack's
movement since each pinned ref is exactly this card's change and nothing else
(+4/-1 per hook, +47/+39/+41 per self-test). The only diff this commit carries
is scripts/upstream-port-pin.json: six `ref` values to 7c2c5aed and six
`upstreamSha256` values, twelve lines. The five other pinned entries keep their
own refs, as the gate guarantees. Every declared divergence was re-applied and
survives — guard-shared-stash.sh still carries its own self-test case count with
the re-derivation recipe (46 `expect ` lines + 2 inline specials + the 2 new
message assertions = 50, equal to the run's tail), and the objectui card
references and worktree example names are intact.

Verified after the re-sync: check-upstream-port-parity exits 0 with ✓ for all 11
entries at 5 distinct refs; its --self-test 58 cases pass; the three hatch
sentences read the upstream form; `git grep -n 're-run with' -- .claude/hooks`
hits 5 lines, all self-test assertions; hook self-tests 130 / 120 / 50 / 38
passed, 0 failed. Also green: check-lint-coverage, check-entry-guard,
check-control-bytes, check-bash32-floor, check-changeset-presence (no changeset
owed), check-governed-queue-guard --self-test.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK
@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

维护者速读

事情:objectui 的四个 guard hook 拒绝时都写着「re-run with OS_ALLOW_*=1」——照做没用,因为命令前缀到不了 hook 自己的环境;guard-main-checkout.sh 早已修好,剩下三个这次补齐,措辞与 objectstack 逐字一致(「set OS_ALLOW_X=1 in the environment this hook itself runs in …」),各加一条自测断言。

改了什么:三个 hook 各一句、三个自测各加两条断言;另外 scripts/upstream-port-pin.json 六个条目的 ref 与摘要更新到 objectstack 7c2c5aed——这六个文件是从 objectstack 钉住的移植件,第一版手改后 parity 门禁红了,改走门禁自己的 --resync,重同步结果与手改逐字节相同,所以第二个 commit 只有 pin 登记表。

要你看一眼的:重同步需要 --rewrite-governed-file,脚本头注把这个旗保留给「合并的人」,这次是 dev 按本席指令敲的,PR 正文写明了;是否把头注改成按条件(可证明内容中性的重同步)而非按人,已立 finding 交下任 skills 席,你也可以直接裁。

风险:低。纯 shell,四个 hook 自测全绿,parity 门禁 11/11 绿;CI 其余项在跑。

只问一字:是否合并?


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant