Skip to content

finding(hooks): guard-main-checkout.sh is the only PreToolUse guard with no self-test — the one both CLAUDE.md files name by path as the enforcement mechanism #6451

Description

@os-warren

Found while porting the worktree-recipe repair (#6208 / PR #6449); out of scope for that card, filed rather than fixed.

The gap

This repo has three PreToolUse guards. Two carry hermetic self-test matrices that .github/workflows/hook-selftests.yml runs on every PR touching .claude/hooks/**:

guard self-test cases (measured 2026-08-26)
.claude/hooks/guard-main-checkout-bash.sh guard-main-checkout-bash.selftest.sh 121 passed, 0 failed
.claude/hooks/guard-shared-stash.sh guard-shared-stash.selftest.sh 41 passed, 0 failed
.claude/hooks/guard-main-checkout.sh none —
$ ls .claude/hooks/
guard-main-checkout-bash.selftest.sh
guard-main-checkout-bash.sh
guard-main-checkout.sh
guard-shared-stash.selftest.sh
guard-shared-stash.sh
$ grep -n 'run:' .github/workflows/hook-selftests.yml
125:        run: .claude/hooks/guard-main-checkout-bash.selftest.sh
129:        run: .claude/hooks/guard-shared-stash.selftest.sh

Nothing in CI exercises guard-main-checkout.sh at all.

Why this one is the awkward omission

guard-main-checkout.sh is the original guard — the Edit/Write/NotebookEdit one. It is the hook both CLAUDE.md and AGENTS.md §9 name by path as the mechanism that enforces worktree-first. The Bash-side guard exists to cover the same rule expressed as a shell command, and guard-main-checkout-bash.sh's own header describes itself as the second line ("Same rule as guard-main-checkout.sh — that hook is registered on Edit|Write|NotebookEdit only, so the identical edit expressed as a shell command used to slip through in silence").

So the covered guard is the follow-on and the uncovered one is the primary. #5754 added the standing CI caller and wired it to the two self-tests that existed; nothing was left recording that the third guard had none.

What the absence actually permits

Both a false-negative and a false-positive path are unguarded:

  • fail-open — a parsing or path-resolution change that stops the guard classifying a shared checkout means edits land on the shared tree in silence, which is the whole failure this repo built worktree-first to stop.
  • fail-closed — an over-broad match blocks edits inside legitimate linked worktrees, which reads to an agent as a broken environment.

Neither has a test that would go red. Concretely: PR #6449 edited the recipe text this guard prints (guard-main-checkout.sh:57) and no gate in this repo could have noticed a mistake in it — the whole scripts/__tests__ tree stayed green at 2318 passed, correctly, because nothing there reads the file.

Not asserting the shape of the fix

The sibling self-test is a ready template (guard-main-checkout-bash.selftest.sh builds a throwaway repo plus a linked worktree and drives the hook through a case matrix, with a jq-less fallback lane). Whether the right move is a third matrix, folding the Edit-side cases into the existing one, or something narrower is for whoever picks this up. Note hook-selftests.yml's header states a per-file case-count-recipe convention that a new self-test would need to follow, and that its recorded counts (100 / 32) are already behind the tree's (121 / 41).

Filed unassigned; no domain:* or type label set.

Generated by Claude Code

Activity

  1. claude commented on Aug 31, 2026

    @claude
    Contributor

    分诊解锁 · pm:blocked → pm:queue —— 阻塞已过期

    本轮「objectui 停放面」复核:本卡的 Blocked-by: 目标已全部关闭,阻塞比阻塞方活得久。

    Blocked-by: 目标 直读结果
    objectstack-ai/objectstack#11800 closed

    ⇒ 声明的前置条件已兑现 ⇒ 解除,回 pm:queue。

    ⚠️ 关于本次读数的来源,如实声明

    check-half-states.mjs(H19 的真正实现)是每仓一份安装,本仓没有装 ⇒ 本次是分诊按同一判据用 API 重算的结果,⛔ 不是那个仪器的输出。若将来本仓装上 sweep 且结论不一致,以 sweep 为准。目标状态是逐个直读的,不是推断的。

    ⚠️ 本席只解除了「机器可读的那条阻塞线」

    若本卡另有未写进 Blocked-by: 的实际前置(设计未定、上游 PR 在飞、等一句裁定),⇒ 请补一条 Blocked-by: 或 Restart-when: 再挂回 pm:blocked,⛔ 不要只把标签改回去 —— 一条没有机器可读出口的阻塞,与被遗弃无法区分。

    ⚠️ 本仓停放面同批读数:pm:on-hold 67 · pm:blocked 44,其中 26 张 on-hold 没有可点火的 Restart-when:。本卡不在那 26 张内。


    Generated by Claude Code

  2. self-assigned this
    on Sep 2, 2026
  3. yinlianghui commented on Sep 2, 2026

    @yinlianghui
    Collaborator

    Claim: PM loop round R40 (batch widened to 5 on the maintainer's instruction)
    Session: session_01BGMDbrVa8JjZcCQ7DWYH1b
    Branch: claude/issue-6451-guard-main-checkout-selftest
    Worktree: objectui-issue-6451
    Domain: domain:devx
    File surface: .claude/hooks/guard-main-checkout.selftest.sh (new, ported from objectstack's matrix) and, only if the header's recorded counts still need it, .github/workflows/hook-selftests.yml header prose (stop on breach; explain in the report). ⛔ No edit to guard-main-checkout.sh itself unless a ported case proves a real divergence — then report, do not patch silently. Governed surface (.claude/**): draft PR only, human merge.
    Container & model: M, mode:subagent, model: opus (governed hooks matrix; judgment in the case set). Tier is this seat's explicit decision (no dispatch-gates.mjs in objectui).
    Clause-②: no
    Serial constraints cleared: unblocked by the 2026-08-31 unlock scan (objectstack#11800 closed); objectstack origin/main carries .claude/hooks/guard-main-checkout.selftest.sh (320 lines) ⇒ the "port the matrix" shape from triage applies. objectui's hook-selftests.yml discovers matrices by find since d42c2bee2 (#7019), so no wiring edit is expected. Recent churn on the hooks: e55fdae6b (#7000), b28a2c5d0 (#6908); the two repos' guard-main-checkout.sh differ by 9 diff lines — the port must be checked against objectui's copy, not assumed identical. No in-flight PR in either lane touches .claude/hooks/**; batch siblings (#6640 test infra · #6705 parity scanner · #6291 derivation gate · #6606 designer-field-key parity) are disjoint by construction.


    Generated by Claude Code

  4. yinlianghui commented on Sep 2, 2026

    @yinlianghui
    Collaborator

    Dev claim (os-dev subagent, dispatched by the PM claim above).

    Session: session_01BGMDbrVa8JjZcCQ7DWYH1b
    Branch: claude/issue-6451-guard-main-checkout-selftest (pushed empty at 67dadd602, before any edit)
    Worktree: /home/user/objectui-issue-6451
    Base: 67dadd602a3a891666ea1513c5de677140784b6a

    Assignee untouched (PM owns it). Planned file surface: .claude/hooks/guard-main-checkout.selftest.sh (new, ported from the objectstack matrix) plus .github/workflows/hook-selftests.yml header prose only if that header's own convention still asks for it. No edit to guard-main-checkout.sh; a ported case that fails gets reported as evidence, not patched away. Governed surface: draft PR, human merge.


    Generated by Claude Code

  5. yinlianghui commented on Sep 2, 2026

    @yinlianghui
    Collaborator

    Port measurement (before filing the two hole cards, so the numbers below are measured, not assumed).

    The sibling matrix (objectstack-ai/objectstack@d63c8a2:.claude/hooks/guard-main-checkout.selftest.sh, 320 lines) was run byte-for-byte unmodified against this repo's hook and settings.json, via its own GUARD_MAIN_CHECKOUT_HOOK / GUARD_MAIN_CHECKOUT_SETTINGS indirection:

    87 passed, 0 failed
    

    Zero cases needed adapting. That is consistent with the diff of the two hooks: 9 diff lines, all of them inside one comment block (objectstack's copy carries an extra "the recipe printed below is the highest-leverage copy of PD#11" note above the heredoc). No executable line differs, and settings.json routes the same Edit|Write|NotebookEdit matcher here.

    Consequence worth stating plainly: the two KNOWN HOLE sections in that matrix are not objectstack-only. They reproduce here identically, which is what the four $ODD cases and the five NotebookEdit cases above measure. Filing them as this repo's own cards next, and the ported matrix will name those numbers rather than the objectstack ones.


    Generated by Claude Code

  6. yinlianghui commented on Sep 2, 2026

    @yinlianghui
    Collaborator

    os-dev-report

    {
      "issue": 6451,
      "status": "done",
      "branch": "claude/issue-6451-guard-main-checkout-selftest",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7264",
      "premise_still_valid": true,
      "summary": "Ported objectstack's guard-main-checkout self-test matrix into objectui as .claude/hooks/guard-main-checkout.selftest.sh (333 lines, mode 100755, 26 top-level `expect` lines plus the inline specials = 87 cases). The port is verbatim and that was measured, not assumed: the two repos' guard-main-checkout.sh differ by 9 diff lines that are ALL inside one comment block, no executable line differs, and both settings.json route the same Edit|Write|NotebookEdit matcher, so the sibling file was first run here BYTE-FOR-BYTE unmodified via its own GUARD_MAIN_CHECKOUT_HOOK / GUARD_MAIN_CHECKOUT_SETTINGS indirection and returned 87 passed, 0 failed. ZERO cases needed adapting; the only 4 diff hunks vs upstream are comment/echo-label text (one added PORTED header paragraph, and the two KNOWN HOLE issue references re-pointed at this repo's own new cards, each keeping the sibling number as a cross-ref). Legs 2 and 3 of the 2026-08-26 triage ruling were re-checked and are both NO-OPS on today's tree, so neither the workflow nor the hook was touched (detail in `deviations`). guard-main-checkout.sh itself is byte-identical to HEAD: the fence held, and the two defects the matrix surfaced were filed rather than patched.",
      "tests": "NEW MATRIX, in place: `.claude/hooks/guard-main-checkout.selftest.sh` -> verdict line `87 passed, 0 failed`, exit 0, 0 skipped (the submodule fixture built, so neither of its 2 cases skipped). SIBLING FILE RUN UNMODIFIED FIRST against objectui's hook+settings -> `87 passed, 0 failed` (this is the evidence that zero adaptation was owed). WHOLE CI STEP BODY, extracted verbatim from hook-selftests.yml:174-206 and run locally on HEAD `3f5d6faf0`: `discovered 4 hook self-test(s)` -> guard-main-checkout-bash 121 passed 0 failed PASS / guard-main-checkout 87 passed 0 failed PASS / guard-shared-stash 41 passed 0 failed PASS / guard-tree-enum 36 passed 0 failed PASS -> `Hook self-tests — all 4 ran and passed`, step exit 0. NON-VACUITY (5 mutation classes, each against a SCRATCH COPY of the hook under $TMPDIR via GUARD_MAIN_CHECKOUT_HOOK — the tracked hook is never mutated, so the restore leg is a scratch-file delete, and `git diff --stat` on guard-main-checkout.sh was empty afterwards with only the new untracked matrix present; EACH mutation was confirmed to have LANDED ON DISK by a marker grep count going 1 -> 0 before its reading was accepted, and a non-landing would have discarded the reading): drop the `*/worktrees/*) exit 0` arm -> 62 passed, 25 failed; replace dirname of the file with CLAUDE_PROJECT_DIR -> 52 passed, 35 failed; drop the OS_ALLOW_MAIN_EDITS line -> 85 passed, 2 failed; final `exit 2` -> `exit 0` -> 45 passed, 42 failed; rename the key in the jq-less grep fallback -> 83 passed, 4 failed. No build/dist step exists for a shell hook, so there is no rebuild leg to confirm. GATES (derived from every .github/workflows/*.yml that can see .claude/hooks/** — only hook-selftests.yml carries a matching paths: filter, the rest are unfiltered; exit code captured by redirecting to a file BEFORE any pipe, and each verdict quoted from the gate's own printed line, never from a bare $?): check-control-bytes exit 0 `✅  check-control-bytes: OK (scanned 5997 tracked text file(s); skipped 85 binary).`; check-shell-escape-residue exit 0 `✅  check-shell-escape-residue: OK (4/4 root(s) resolved ...)`; check-changeset-presence exit 0 `✅  No source or published contract of a released package changed in this range, so no changeset is owed.` (also `1 file(s) changed, 0 of them published source ..., 0 changeset(s) added`); check-upstream-port-parity exit 0 `✓ check-upstream-port-parity: 2 ported file(s) match objectstack-ai/objectstack@bf10debd5 modulo their declared divergences.`; check-governed-queue-guard --self-test exit 0 `OK check-governed-queue-guard self-test: 132 cases pass`. TWO SCOPE HONESTIES, reported as NOT MEASURED rather than as green: (1) check-shell-escape-residue resolves only AGENTS.md, CLAUDE.md, skills/ and content/docs/ — NOT .claude/hooks/ — so its green is true and says nothing about this file; (2) check-control-bytes DOES cover it (tracked, inside the 5997) and an independent manual `grep -naP` over the new file returns 0 hits. NOT RUN, declared: no pnpm install and no vitest/build in this worktree — the change is one shell file that no package imports, eslint.config.js is scoped to ts/tsx throughout and there is no shellcheck in the repo, so no unit/type/lint surface reaches it. The shared verify lock was NOT used for these: its own --status text names `check:*` gate scripts as unlocked sibling work it never covers. One earlier batched attempt did queue behind another dev's vitest shard (position 2/2) and was abandoned in favour of running the light node gates directly rather than waiting; the lock slot was parked, blocking nobody.",
      "mcp_calls": "12 — issue get, issue get_comments, 3 issue comments (claim, port measurement, this report), 2 targeted search_issues (dedup before filing), 3 issue creates, create_pull_request, pull_request_read (full body read-back for sanitizer damage: intact, only entity-encoding of quotes)",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #7259: guard-main-checkout.sh matches any git-dir path containing /worktrees/ as a linked worktree, so a PRIMARY checkout living under a dir named `worktrees` is unguarded — and the verdict flips BY DEPTH, since git prints a relative git-dir at a toplevel and an absolute one from a subdirectory. Reproduces identically in objectui; 4 cases pinned in the new matrix under a KNOWN HOLE banner. Sibling: objectstack#11809.",
        "filed as #7260: settings.json routes NotebookEdit at the hook, but the hook only extracts .tool_input.file_path and NotebookEdit's key is notebook_path, so every notebook edit is judged by CLAUDE_PROJECT_DIR instead of by the file — wrong in 4 of 5 measured directions, two of them fail-open (an agent correctly in a worktree can write a notebook into the shared checkout unguarded). 5 cases pinned. Sibling: objectstack#11810.",
        "filed as #7263 (label `finding`): scripts/upstream-port-pin.json holds only two scripts/ entries, so the verbatim cross-repo ports under .claude/hooks/** have no drift gate — the exact shape check-upstream-port-parity was built for after #6642 measured ~212 KB of silent drift, in the directory where the drift has actually kept recurring (#5459, #5712, #5789, #6042 were each caught by a human, never by a gate). This PR's file could be registered today with 4 declared divergences, but whether the ledger's scope reaches governed surface (where --resync rewrites files in place) is a decision, not a mechanical edit."
      ]
    }

    Generated by Claude Code

  7. yinlianghui commented on Sep 2, 2026

    @yinlianghui
    Collaborator

    Addendum to the report above — the deviations block its summary points at (it was described in prose there but not carried as its own field; recording it here so the GitHub copy is complete, and the returned JSON carries it as deviations).

    1. Leg 2 of the triage ruling ("wire it into hook-selftests.yml") — NO-OP, no edit made. Verified by reading the workflow and by running its run: block: discovery is find .claude/hooks -type f -name '*.selftest.sh' since #7019, and the local run reported discovered 4 hook self-test(s) including the new one, with no workflow edit. The header anticipates this card by name at :51. Matches PM mechanism assumption (c).

    2. Leg 3 ("refresh the header's stale recorded counts, 100/32 vs 121/41") — NO-OP, deliberately not done; this is the one place the dispatch's own instruction and the file's text diverge. The header at :95-101 does not merely tolerate the stale figures, it forbids refreshing them: "Those counts stay, and the distinction is deliberate (objectui#6089): a measurement is HISTORY... a matrix that grows afterwards does not make it false — it makes it older. Both have grown since (121 and 41 as of 2026-08-31, plus a third matrix), which is exactly what a dated measurement is allowed to do." The 2026-08-24 measurement is dated and stays; the 2026-08-31 sentence is also dated and already records 121/41, so it is history too and adding "and a fourth matrix" would convert it into an undated live claim — the exact shape the same header rejects for step names. The live count is published by the thing that cannot get it wrong: the step prints its own discovery count. Dispatch §2(c) invited this check with "follow the header's text, do not invent a convention", so this is a declared falsification of the triage hypothesis, not a skipped leg.

    3. guard-main-checkout.sh untouched — the fence held; no ported case failed, so nothing demanded an adaptation or a hook change. Its header gains no self-test pointer either, which keeps parity: objectstack's copy has had the matrix far longer and carries no pointer.

    4. scripts/upstream-port-pin.json untouched — a breach was considered and declined. This PR's file is a textbook ledger candidate (verbatim port, 4 declared divergences), but the ledger holds only scripts/ entries today, and extending its scope to governed surface (where --resync rewrites the ported file in place) is a decision rather than a mechanical edit. Filed as #7263 instead.

    5. Shared verify lock not used for the gates. Its own --status text names check:* gate scripts as unlocked sibling work it never covers. One batched attempt did queue (position 2/2 behind another dev's vitest shard) and was abandoned in favour of running the light bare-node gates directly; the slot stayed parked, blocking nobody. No heavy build or test was owed — the change is one shell file no package imports.


    Generated by Claude Code

  8. yinlianghui commented on Sep 2, 2026

    @yinlianghui
    Collaborator

    ACCEPT — PR #7264 (draft; governed surface ⇒ human merge)

    Reviewer of record: domain:devx @ objectui execution seat, PM session session_01BGMDbrVa8JjZcCQ7DWYH1b, R40. Verified against GitHub, not against the report.

    Checklist

    Landing path — governed surface (.claude/**): ⛔ not flipped ready, not enqueued, no auto-merge. Review requested from the two authorised approvers os-zhuang and hotlong (this seat's identity is the PR author, which is neither, so both requests are valid). Merge criterion for the approver: all checks green on the current head, then human merge or an APPROVED review pinned to that head lets the queue take it. Card stays pm:dispatched + assigned until MERGED.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repopm:dispatchedtooling

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions