Commit 8b81ab6
fix(hooks): guard-governed-enqueue's sibling-checkout lookup is injectable, so its self-test is hermetic on a box that carries the sibling (#18365)
Fixes #18321
## The defect
`.claude/hooks/guard-governed-enqueue.selftest.sh`'s case "an
exception-row path in a repo
this container cannot resolve" rested on a premise about the **box**,
not about the hook:
that no `objectstack-ai/cloud` checkout sits beside this one. The hook
resolves a sibling
checkout by comparing origin slugs under the parent directory of its own
repo root, so on a
container that *does* carry a sibling `cloud` checkout the guard
resolved it, recomputed the
register predicate on that tree with `--root`, got the governed answer
and **blocked**. The
matrix read `54 passed, 1 failed` there, and was green in CI only
because the runner mounts
no sibling. `lint.yml`'s step comment calls these matrices hermetic;
this case was not.
## What changed
**The hook** now reads `OS_GOVERNED_ENQUEUE_SIBLING_ROOT` for the
directory a sibling is
resolved under. It moves **where** the search looks and nothing else —
the origin-slug
comparison remains the entire admission rule, so a sibling that resolves
is audited exactly
as before, and a root holding no matching checkout resolves nothing. No
fail-open path was
added or widened.
| value | meaning |
|:---|:---|
| unset | the parent of this checkout — today's behaviour, to the byte |
| empty | identical to unset; an empty value is an accident, and the
safe reading of an accident is "no override", never "look nowhere" |
| a directory carrying no matching checkout (one that does not exist
included) | nothing resolves; the run proceeds exactly as on a box
without the sibling |
**The matrix** now owns its own premise: the "cannot resolve" case
points the lookup at a
directory it creates and knows is empty, and one new case pins the other
half — a sibling
that *does* resolve is audited on its own tree. The throwaway sibling is
built in the
self-test (`git init` plus an `origin` naming the target repo is the
whole admission
requirement) and removed by the existing `trap` on the matrix's own temp
root, so no new
cleanup path was needed. Both temp directories live under that root by
construction.
The new case is written as **agreement with the register**, not as a
copied verdict — the
shape this file's own header says it learned the hard way, where a
hard-coded `expect allow`
went red over an upstream register change the hook had nothing to do
with.
## Reverse verification
BEFORE, on `origin/main` `1411cf2c`, this container, `/home/user/cloud`
present:
```
54 passed, 1 failed
FAIL want=allow got=block an exception-row path in a repo this container cannot resolve
```
AFTER, at `5391e5c0`, same container, `/home/user/cloud` still present —
four injection
states, all `exit 0`:
```
UNSET exit=0 56 passed, 0 failed
EMPTYDIR exit=0 56 passed, 0 failed
NONEXISTENT exit=0 56 passed, 0 failed
EMPTYSTRING exit=0 56 passed, 0 failed
```
**"Unset changes nothing" is proved directly**, not inferred: the
*untouched* matrix was run
against the *changed* hook, and its output was byte-identical to the
baseline log
(`diff` empty — still `54 passed, 1 failed`, still the same one case).
The variable's semantics were also measured against the hook directly,
with the real
sibling present, on a payload targeting `objectstack-ai/cloud`:
```
unset -> block (resolves the real sibling)
empty string -> block (identical to unset)
=/home/user -> block (explicit, same directory as the default)
empty dir -> allow (nothing resolves)
non-existent -> allow (nothing resolves)
```
**Firing control.** Two were run, each mutating the committed file,
proving the mutation
landed on disk by hash, and restoring with `git checkout HEAD --` under
a `trap`
(`git diff HEAD` empty afterwards, blob hash back to the HEAD blob):
- *the control this card prescribed* — give the new case a
**not-governed** fixture:
**stays green**, `56 passed, 0 failed`. It cannot fire, and that is a
property of the
assertion rather than a gap: an agreement assertion flips the register
leg and the hook
together, so they still agree. Reported rather than papered over.
- *a control that targets the property under test* — the throwaway
sibling's `origin` names
a different repo, so it is no longer admitted: **red**, `55 passed, 1
failed`,
`FAIL want=block got=allow a sibling checkout that resolves is audited,
never waved through`.
This is the mutation that corresponds to "the hook stopped consulting
the sibling tree",
which is what the case exists to catch.
Diff confined to the two files in the declared surface:
```
.claude/hooks/guard-governed-enqueue.selftest.sh | 92 +++++++++++++++++++---
.claude/hooks/guard-governed-enqueue.sh | 41 +++++++++-
2 files changed
```
## Gates
`dispatch-gates.mjs` derives 13 families from the real change set (2
paths, three-dot vs
merge base `1411cf2c6`); all 13 ran and all recorded `exit 0`.
Reconciliation:
```
Run reconciliation — 13 derived, 13 run, 0 NOT-MEASURED, 0 UNRUN.
```
`check-doc-formula-expressions` first returned **exit 3 (PREREQUISITE
NOT MET — nothing
measured)** because two workspace packages were unbuilt; it was re-run
to `exit 0` after a
targeted build, and only that second reading is recorded. The path face
`check-governed-merges.mjs --test` answers **GOVERNED** (`.claude/**`),
as expected.
`shellcheck` is **not measured**: the tree wires no shellcheck step
(`lint.yml` runs the hook
matrices, it does not lint them) and the binary is absent from this
container. `bash -n`
parses both files and `check:bash32-floor` passes.
## Acceptance notes
Two things measured on the way, both **out of scope for this PR** and
neither fixed here:
1. **The case never reached the fail-open it claimed to pin.** The
comment that used to sit
on it described the "no checkout of the target repo is available"
branch. It does not
reach that branch, in any environment including CI: with nothing
resolved the register is
asked *without* `--root`, answers about *this* tree, finds the path
byte-exact against its
own generator and lifts it, so the hook leaves at the cleared-predicate
`exit 0` with
**empty stderr**. That branch is reachable — a path hitting the
exception row but absent
from the generator's declared output set returns governed with a
non-empty `exceptions`
list — but no case in the matrix reaches it today. The stale comment is
corrected here
because it sits on the case being re-keyed; pinning the branch is
separate work.
2. **The slug reader keeps a `.git` suffix.** Its path character class
owns the dot and is
greedy, so an origin of `https://github.com/objectstack-ai/cloud.git`
yields
`objectstack-ai/cloud.git` and matches nothing. A sibling cloned with
the conventional URL
therefore does not resolve. The same expression also derives the slug
for a bare
`gh pr merge NUMBER`, where a non-matching slug makes the API read 404
and the guard fail
open. Measured here; reported for its own card. The new case uses the
bare URL form on
purpose and says so in place, so nobody "tidies" a `.git` onto it.
Also noted, not filed: `lint.yml`'s step is named for the two matrices
that existed when it
was written, while discovery now picks up more. The step comment is
explicit that discovery
is the contract and that a hard-coded list would be the defect, so the
name is illustrative
rather than a ledger — nothing to reconcile. Whoever adds the next hook
matrix is the one who
reads it. `lint.yml` is outside this PR's declared surface and was read,
not edited; its
hermetic claim becomes true rather than edited.
Serial context: PR #18317 touched `.claude/settings.json` and landed
before this branch was
cut; it is unrelated to either hook file and nothing here depends on it.
## 维护者速读(草稿)
**改了什么** — 给这个 PreToolUse 守卫加了一个环境变量,用来指定「到哪个目录下去找兄弟仓
checkout」。默认不设时行为与今天逐字节一致。自测脚本据此改写了一个用例,并新增一个用例。
**为什么改** — 这个自测用例原本依赖「这台机器上没有 cloud 仓的 checkout」这个环境事实,而不
是依赖守卫本身的行为。凡是挂了 `/home/user/cloud` 的机器上它就红,CI 绿只是因为 runner 上
恰好没有。测试的结论必须由被测代码决定,不能由机器上还挂了什么决定。
**风险与代价(含回滚)** — 风险低。变量只改变「去哪里找」,不改变「找到了算不算数」——
判定仍然是比对 origin slug,所以误设一个值只会让它找不到(退回到本来就存在的放行分支),
不会让它放过本该拦截的东西。已逐项实测 unset / 空串 / 空目录 / 不存在目录四种取值。回滚就是
还原这两个文件,无数据迁移、无发布物、无下游依赖。
**席位意见** — (待席位填写)
**你要做的** — `.claude/**` 属受管面,按 Prime Directive #14 需要一次授权的 APPROVED 审核;
本 PR 保持 draft,未请求任何 reviewer,未触碰 auto-merge。除批准外无需其他动作。
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
---
_Generated by [Claude
Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 05f1cdd commit 8b81ab6
2 files changed
Lines changed: 127 additions & 6 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
342 | 342 | | |
343 | 343 | | |
344 | 344 | | |
345 | | - | |
346 | | - | |
347 | | - | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
348 | 378 | | |
349 | 379 | | |
350 | | - | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
351 | 435 | | |
352 | 436 | | |
353 | 437 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
148 | 148 | | |
149 | 149 | | |
150 | 150 | | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
151 | 179 | | |
152 | 180 | | |
153 | 181 | | |
| |||
447 | 475 | | |
448 | 476 | | |
449 | 477 | | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
450 | 487 | | |
451 | 488 | | |
452 | 489 | | |
453 | | - | |
454 | | - | |
| 490 | + | |
| 491 | + | |
455 | 492 | | |
456 | 493 | | |
457 | 494 | | |
| |||
0 commit comments