From 68d26af5a7b22eb905f56e49da76a67cff73691e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 04:06:44 +0000 Subject: [PATCH 1/2] fix(hooks): make guard-governed-enqueue's sibling-checkout lookup injectable so its self-test is hermetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-test 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. On a container that does carry a sibling `cloud` checkout the guard resolved it, recomputed the register predicate on it 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. 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 stays the whole admission rule, so a sibling that resolves is audited exactly as before, and a root holding no matching checkout resolves nothing and falls through to the fail-open a box without the sibling already takes. Unset or empty means the parent of this checkout — today's behaviour to the byte, proved by re-running the untouched matrix against the changed hook for output identical to the baseline. The matrix now owns its own premise: the "cannot resolve" case points the lookup at a directory it created and knows is empty, and a new case pins the other half — a sibling that DOES resolve is audited on its own tree, asserted as agreement with the register rather than as a copied verdict, beside a structural assertion that the no-checkout fail-open did not fire. Two things measured on the way and corrected rather than restated: that case never reached the "no checkout … is available" fail-open it claimed to pin (with nothing resolved the register is asked without `--root`, answers about this tree, lifts the path, and the hook leaves at the cleared-predicate exit with empty stderr), and the slug reader keeps a `.git` suffix, so a sibling cloned with the conventional URL does not resolve at all. The first is corrected in the case comment; the second is reported for its own card. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr --- .../hooks/guard-governed-enqueue.selftest.sh | 89 ++++++++++++++++++- .claude/hooks/guard-governed-enqueue.sh | 41 ++++++++- 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/.claude/hooks/guard-governed-enqueue.selftest.sh b/.claude/hooks/guard-governed-enqueue.selftest.sh index 40ca439f934..48dbc2f848f 100755 --- a/.claude/hooks/guard-governed-enqueue.selftest.sh +++ b/.claude/hooks/guard-governed-enqueue.selftest.sh @@ -342,12 +342,93 @@ expect allow 'a PR reporting no changed files is not a governed answer' \ "$(mcp $AUTO 13794)" "OS_GOVERNED_ENQUEUE_FIXTURE=$F_EMPTY" echo "== a generated-exception row on a repo with no checkout to recompute against ==" -# objectstack-ai/cloud has no sibling checkout here, so the register cannot -# recompute the row's provenance on the RIGHT tree. Judging one repo's paths -# against another's files would be worse than not answering: fail open, say so. +# With no checkout of the target repo the register cannot recompute the row's +# provenance on the RIGHT tree, and judging one repo's paths against another's +# files would be worse than not answering: fail open, say so. +# +# ⚠️ THIS CASE'S PREMISE IS INJECTED, AND THE INJECTION IS THE REPAIR. It used to +# rest on a fact about the BOX — "objectstack-ai/cloud has no sibling checkout +# here" — which is a property of the container, not of the hook, and not true +# everywhere: on a box that does carry a sibling `cloud` checkout the guard +# resolved it, recomputed the predicate on it and BLOCKED, so this matrix read +# `54 passed, 1 failed` there and was green in CI only because the runner +# mounts no sibling. A case whose verdict depends on what else happens to sit +# next to the checkout is not hermetic, whatever the step comment says. It now +# points the lookup at a directory it created itself and therefore knows is +# empty, so the "cannot resolve" premise is one this file OWNS on every box. +F_CROSS_REGEN="$(fixture cross-repo-regen "$(files_of skills/objectstack-data/references/_index.md)" "$NO_REVIEWS")" +NO_SIBLING_ROOT="$root/no-sibling-here" # under $root: the existing trap removes it +mkdir -p "$NO_SIBLING_ROOT" +# +# ⚠️ AND IT DOES NOT REACH THE "no checkout … is available" FAIL-OPEN — measured, +# because the comment that used to sit here said it did. With nothing resolved +# the register is asked WITHOUT `--root`, so it answers about THIS tree, where +# this path is byte-exact against its own generator and therefore LIFTED: the +# hook leaves at the cleared-predicate `exit 0` with EMPTY stderr, several +# branches above that fail-open. Pinning a warning here would pin a sentence +# nothing prints. What this case does hold is the property the card is about — +# the verdict must not depend on what else is mounted beside the checkout — and +# the resolved-sibling case below is its other half: same fixture, same payload, +# only the injected root differs. expect allow 'an exception-row path in a repo this container cannot resolve' \ "$(mcp $AUTO 999 objectstack-ai cloud)" \ - "OS_GOVERNED_ENQUEUE_FIXTURE=$(fixture cross-repo-regen "$(files_of skills/objectstack-data/references/_index.md)" "$NO_REVIEWS")" + "OS_GOVERNED_ENQUEUE_FIXTURE=$F_CROSS_REGEN" "OS_GOVERNED_ENQUEUE_SIBLING_ROOT=$NO_SIBLING_ROOT" + +echo "== ...and a sibling checkout that DOES resolve is audited on its own tree ==" +# The other half of that same branch, and the reason the case above needs an +# injection rather than a rename: when a checkout of the target repo IS +# reachable, resolving it and recomputing the predicate there is the DESIGNED +# behaviour — the answer then reflects the DIFF instead of the environment, +# which is the whole point of passing `--root`. Pin only the fail-open and the +# guard stays green after it stops looking for siblings at all. +# +# The sibling is BUILT here rather than borrowed from the box: `git init` plus an +# `origin` naming objectstack-ai/cloud is the entire admission requirement, since +# the hook compares origin slugs and reads nothing else. Measured on a container +# carrying the real read-only /home/user/cloud checkout: this throwaway and that +# checkout hand the register the SAME verdict with the SAME reason — governed, +# `pureRegeneration: false`, "the generator declared no output set … fail closed" +# — because the `gen:skill-refs` toolchain cannot run on either tree. The +# throwaway reproduces the real sibling, so no further tree is needed. +# +# ⛔ The origin URL is the BARE form on purpose — do not "tidy" a `.git` suffix +# onto it. The hook's slug reader keeps that suffix (its path character class +# owns the dot and swallows it, leaving `cloud.git`), so the `.git` spelling +# resolves NOTHING and this case would silently become a second copy of the one +# above. Measured here; filed separately as its own defect, since the same +# reader also derives the slug for a bare `gh pr merge `. +# +# ⭐ Asserted as AGREEMENT with the register, for the reason the pure-regeneration +# case above learned the hard way: `skills/**` leaving the governed fence, or this +# exception row being retired, would flip the verdict for a reason the hook had +# nothing to do with, and a verdict copied from the register makes this matrix a +# second register. Here and in CI today that branch is `block`. +SIBLING_ROOT="$root/sibling-parent" # under $root: the existing trap removes it +mkdir -p "$SIBLING_ROOT/cloud" +git -C "$SIBLING_ROOT/cloud" init -q >/dev/null 2>&1 +git -C "$SIBLING_ROOT/cloud" remote add origin https://github.com/objectstack-ai/cloud >/dev/null 2>&1 +node "$repo_root/scripts/pm/check-governed-merges.mjs" --test --root "$SIBLING_ROOT/cloud" \ + skills/objectstack-data/references/_index.md >/dev/null 2>&1 +sibling_rc=$? +if [ "$sibling_rc" -eq 0 ]; then + sibling_want=allow + sibling_branch='LIFTED on the sibling tree — the hook must answer the same way' +else + sibling_want=block + sibling_branch="GOVERNED on the sibling tree (exit $sibling_rc, fail-closed: the generator cannot run there) — the refusal must stand" +fi +printf ' .. register verdict on the RESOLVED sibling: %s\n' "$sibling_branch" +expect "$sibling_want" 'a sibling checkout that resolves is audited, never waved through' \ + "$(mcp $AUTO 999 objectstack-ai cloud)" \ + "OS_GOVERNED_ENQUEUE_FIXTURE=$F_CROSS_REGEN" "OS_GOVERNED_ENQUEUE_SIBLING_ROOT=$SIBLING_ROOT" +# The structural half, and the one that keeps its meaning whichever way the +# register moves: an allow reached through the no-checkout fail-open is +# indistinguishable from an allow reached by auditing, so assert that fail-open +# did NOT fire. This is what actually pins "the sibling was resolved". +expect_lacks 'no checkout of objectstack-ai/cloud is available' \ + 'the sibling WAS resolved — the no-checkout fail-open did not fire' \ + "$(mcp $AUTO 999 objectstack-ai cloud)" \ + "OS_GOVERNED_ENQUEUE_FIXTURE=$F_CROSS_REGEN" "OS_GOVERNED_ENQUEUE_SIBLING_ROOT=$SIBLING_ROOT" echo "== the deliberate exception switch ==" expect allow 'OS_ALLOW_GOVERNED_ENQUEUE=1 on the blocking case' \ diff --git a/.claude/hooks/guard-governed-enqueue.sh b/.claude/hooks/guard-governed-enqueue.sh index 092b5818bdc..498ddc81bf0 100755 --- a/.claude/hooks/guard-governed-enqueue.sh +++ b/.claude/hooks/guard-governed-enqueue.sh @@ -148,6 +148,34 @@ # change where DATA comes from and nothing else; the open escape hatch above is # the way to actually skip the guard. # +# OS_GOVERNED_ENQUEUE_SIBLING_ROOT= is the third, and it moves WHERE a +# sibling checkout is looked for — never WHAT is accepted as one. The admission +# rule stays the origin-slug comparison further down: a directory becomes the +# tree this guard audits only when its own `origin` declares the target +# `owner/repo`, so a value pointed anywhere wrong resolves NOTHING. It cannot +# widen the audit, and it cannot soften one either — a sibling that does resolve +# is judged exactly as it is today. +# +# unset → `$(dirname "$repo_root")`, the parent of this checkout. Today's +# behaviour, to the byte. +# empty → the same as unset. An empty value is an accident (`export VAR=`, +# or `VAR="$SOMETHING_UNSET"`), and the safe reading of an accident +# is "no override" — never "look nowhere", which would silently +# drop a real audit. +# a directory carrying no matching checkout, one that does not exist included +# → nothing resolves, and the run takes the existing "no checkout of +# the target repo is available" fail-open below, with its existing +# warning. That is the branch a box WITHOUT the sibling has always +# taken; this variable opens no new way out. +# +# Why it exists: the self-test's cross-repo case asserts that fail-open, and its +# premise used to be a fact about the BOX ("objectstack-ai/cloud has no sibling +# checkout here") rather than about the hook. On a container that does carry a +# sibling `cloud` checkout the guard resolved it, recomputed the predicate on it +# and blocked — 54 passed / 1 failed, green in CI only because CI carries no +# sibling. The matrix now sets this variable and owns its own premise, and the +# resolved-sibling BLOCK is pinned beside it as the deliberate behaviour it is. +# # Self-test (no network, no build): .claude/hooks/guard-governed-enqueue.selftest.sh set -uo pipefail @@ -447,11 +475,20 @@ done < "$work/files.txt" slug_of() { git -C "$1" remote get-url origin 2>/dev/null | sed -n 's#.*github\.com[:/]\([A-Za-z0-9._-]*/[A-Za-z0-9._-]*\)\(\.git\)\{0,1\}/*$#\1#p'; } +# WHERE a sibling is looked for is injectable (OS_GOVERNED_ENQUEUE_SIBLING_ROOT, +# header); WHAT is accepted as one is not. The slug comparison below is the whole +# admission rule and is untouched by it, so the variable can only move the +# search — a sibling that resolves is audited exactly as before, and a root +# holding no matching checkout resolves nothing and falls through to the +# "no checkout available" fail-open, the branch a box without the sibling +# already takes. Unset or empty ⇒ the parent of this checkout, as always. +sibling_root="${OS_GOVERNED_ENQUEUE_SIBLING_ROOT:-$(dirname "$repo_root")}" + target_root="" if [ "$(slug_of "$repo_root")" = "$owner/$repo" ]; then target_root="$repo_root" -elif [ "$(slug_of "$(dirname "$repo_root")/$repo")" = "$owner/$repo" ]; then - target_root="$(dirname "$repo_root")/$repo" +elif [ "$(slug_of "$sibling_root/$repo")" = "$owner/$repo" ]; then + target_root="$sibling_root/$repo" fi test_args=(--test --json) From 5391e5c00749febe0b1b125f64a3a7fd8555a3d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 04:10:17 +0000 Subject: [PATCH 2/2] fix(hooks): single-source the cross-repo path and drop a phantom assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two repairs to the case added in the previous commit, both found by running its firing control rather than by reading it. The companion assertion that the "no checkout … is available" text is ABSENT was a phantom: it stayed green under BOTH controls (a not-governed fixture, and a throwaway sibling whose origin names a different repo). When the sibling does not resolve the hook does not print that warning either — it leaves at the cleared-predicate exit with empty stderr — so the assertion held in both worlds and discriminated nothing. The verdict row is the discriminator, and it does go red under both controls; the phantom is removed and the reason recorded so it is not written again. The register leg also spelled the path a second time, so changing the fixture alone would have left it agreeing about a question nobody asked. One CROSS_REPO_PATH now feeds both. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr --- .../hooks/guard-governed-enqueue.selftest.sh | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.claude/hooks/guard-governed-enqueue.selftest.sh b/.claude/hooks/guard-governed-enqueue.selftest.sh index 48dbc2f848f..ecab98961e8 100755 --- a/.claude/hooks/guard-governed-enqueue.selftest.sh +++ b/.claude/hooks/guard-governed-enqueue.selftest.sh @@ -356,7 +356,12 @@ echo "== a generated-exception row on a repo with no checkout to recompute again # next to the checkout is not hermetic, whatever the step comment says. It now # points the lookup at a directory it created itself and therefore knows is # empty, so the "cannot resolve" premise is one this file OWNS on every box. -F_CROSS_REGEN="$(fixture cross-repo-regen "$(files_of skills/objectstack-data/references/_index.md)" "$NO_REVIEWS")" +# ONE spelling of the path, read by the fixture the hook is handed AND by the +# register leg below. Two spellings drift: change the fixture alone and the +# agreement leg goes on asking about the old path, which is agreement with a +# question nobody asked. +CROSS_REPO_PATH=skills/objectstack-data/references/_index.md +F_CROSS_REGEN="$(fixture cross-repo-regen "$(files_of "$CROSS_REPO_PATH")" "$NO_REVIEWS")" NO_SIBLING_ROOT="$root/no-sibling-here" # under $root: the existing trap removes it mkdir -p "$NO_SIBLING_ROOT" # @@ -408,7 +413,7 @@ mkdir -p "$SIBLING_ROOT/cloud" git -C "$SIBLING_ROOT/cloud" init -q >/dev/null 2>&1 git -C "$SIBLING_ROOT/cloud" remote add origin https://github.com/objectstack-ai/cloud >/dev/null 2>&1 node "$repo_root/scripts/pm/check-governed-merges.mjs" --test --root "$SIBLING_ROOT/cloud" \ - skills/objectstack-data/references/_index.md >/dev/null 2>&1 + "$CROSS_REPO_PATH" >/dev/null 2>&1 sibling_rc=$? if [ "$sibling_rc" -eq 0 ]; then sibling_want=allow @@ -421,14 +426,12 @@ printf ' .. register verdict on the RESOLVED sibling: %s\n' "$sibling_branch" expect "$sibling_want" 'a sibling checkout that resolves is audited, never waved through' \ "$(mcp $AUTO 999 objectstack-ai cloud)" \ "OS_GOVERNED_ENQUEUE_FIXTURE=$F_CROSS_REGEN" "OS_GOVERNED_ENQUEUE_SIBLING_ROOT=$SIBLING_ROOT" -# The structural half, and the one that keeps its meaning whichever way the -# register moves: an allow reached through the no-checkout fail-open is -# indistinguishable from an allow reached by auditing, so assert that fail-open -# did NOT fire. This is what actually pins "the sibling was resolved". -expect_lacks 'no checkout of objectstack-ai/cloud is available' \ - 'the sibling WAS resolved — the no-checkout fail-open did not fire' \ - "$(mcp $AUTO 999 objectstack-ai cloud)" \ - "OS_GOVERNED_ENQUEUE_FIXTURE=$F_CROSS_REGEN" "OS_GOVERNED_ENQUEUE_SIBLING_ROOT=$SIBLING_ROOT" +# ⛔ No companion assertion that the fail-open text is ABSENT here — it was +# written, and measured to be a phantom: when the sibling does NOT resolve the +# hook does not print that warning either (it leaves at the cleared-predicate +# exit with empty stderr, per the case above), so the assertion passed in both +# worlds and discriminated nothing. The verdict row above is the discriminator, +# and it is the one that goes red when the sibling stops being resolved. echo "== the deliberate exception switch ==" expect allow 'OS_ALLOW_GOVERNED_ENQUEUE=1 on the blocking case' \