Skip to content

Fold the worktree gate into the shell dispatcher behind a per-call proof - #2427

Merged
wshallwshall merged 6 commits into
mainfrom
claude/gate-fold-proof
Oct 19, 2026
Merged

wshallwshall merged 6 commits into
mainfrom
claude/gate-fold-proof

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

This PR stops each Bash or PowerShell call paying a separate pwsh launch for the account-level worktree gate. It rebuilds the fold that PR #2399 reverted (73c5cfc, then da3f4a2). It is built to the Lander's bar from 2026-10-17T20:17Z, and it answers the send-back of 2026-10-18T22:49Z.

The bar

the gate must not stand down unless the replacement hook is proven to run for that call, so a missing or failing hook leaves the gate running.

Design: a per-call proof from a pinned dispatcher

Hooks run in parallel, so the account's gate entry cannot see the dispatcher's run. Instead, the dispatcher leaves a proof keyed on this call's tool_use_id, and the entry waits for it.

The dispatcher (scripts/hooks/shell-guard-dispatch.ps1) is passed --user-hook=worktree_gate as its first argument:

  • It runs the installed ~/.claude/hooks/worktree_gate.ps1 in-process, but only when the account's settings carry the installer's entry.
  • It writes <account>/shell-guard-proof/<tool_use_id>.ok, holding <session id>|<gate path>, only when the gate read the whole payload and allowed it silently.
  • Any other outcome writes .run: a decision, an exit, a throw, or a missing gate.

The installer (install-gate.ps1) now also pins a copy of the dispatcher beside the gate. That copy is checked against origin/main the same way the gate is, and gate rule 1a protects it.

The account entry is a bash script the installer writes, with onFailure: "block" and a 60 s timeout. The full stand-down conditions are stated once, in install-gate.ps1. In short, it waits only when all of these hold:

  • the session hint exists;
  • the project's settings.json passes "${CLAUDE_PROJECT_DIR}/scripts/hooks/shell-guard-dispatch.ps1" with "--user-hook=worktree_gate" as the next argument;
  • that project dispatcher is byte-identical to the pinned copy (line endings aside).

It then stands down only when the .ok:

  • holds this session's id and the very gate path the entry would run;
  • is newer than the .wait file the entry wrote, which must still exist;
  • is not newer than the .now file the entry writes when it sees the .ok.

In every other case the entry runs the gate itself, as it did before this change.

How the send-back was answered

Item Fix Test
A1, a working-tree dispatcher is trusted Pinned, origin/main-checked copy; byte compare; the gate must be the first argument; rule 1a covers the pin and shell-guard-proof/ An edited dispatcher, a missing pin and a guard placed before the gate each make the entry run the gate. Rule 1a denies writes to the pin and to proofs, with a control.
A2, a future-dated .ok or a deleted .wait passes .wait must exist, and the .ok must be no newer than .now; comment corrected Future-dated proof and deleted .wait
A6, an inherited errexit or nounset set +eu +o posix SHELLOPTS=errexit:nounset, with a control showing it ends an entry without the reset
A7, a failed here-string fails open exec {h}<<< "$in" || exit 1, then the gate reads from that descriptor A 200 KB payload reaches the gate whole; the feed is asserted structurally
A9 Reworded: the entry's own wait plus its own gate run
A10 ${CLAUDE_CONFIG_DIR:-${USERPROFILE:-$HOME}/.claude}
A3 (optional) .ok only when the gate read the whole payload

Review rounds after the send-back added two more fixes:

  • The proof names the gate path, so a dispatcher that found another gate through a different USERPROFILE is not trusted. Tested.
  • -Status grades the pinned dispatcher. Tested for NOT PINNED, CURRENT and NOT CURRENT.

Every new detector was armed by removing the check it tests and watching the test fail. Doing that exposed a dead test from the first round. On Windows, write_text wrote CRLF, so the planted proof failed the session-id compare and never reached the timestamp check the test was named for. The proof-planting tests now write bytes.

Not done, for ledger rows:

  • A4: one deny receipt per deny.
  • A5: bound the tool_use_id scan on large payloads.
  • A11: count denies per tool, and add a linked-worktree case.
  • A12: take began before starting either process.
  • A13: parallelize the equivalence test.

Residuals, stated

  • A8: the bash entry honours BASH_ENV, SHELLOPTS and BASHOPTS from its environment. These are read before its first line runs. set +eu +o posix undoes errexit, nounset and POSIX mode only.
  • Rule 1a's reach: it protects shell-guard-proof/ only under a directory named .claude*. That is every account the installer discovers by default, but not an account installed with -ConfigDir pointing elsewhere. Rule 1a covers Write and Edit tools only, not a shell write.
  • Deliberate evasion: a same-user process that watches for this call's .wait and writes a matching .ok at once is not stopped. Such a process can already edit the account's settings.json, which the gate does not cover.
  • Pin drift: any later change to the dispatcher on main drops the saving until the next install. Sessions in an updated checkout then run the gate twice per call. Safety is unaffected, and -Status now reports it.
  • Load: on a box where the dispatcher outlasts the 20 s wait, the entry runs its own gate as well. If the wait plus that run passes 60 s, the call is blocked. It used to fail open at 15 s.
  • Clients: onFailure needs Claude Code 2.1.295 or later. Every client on this box is at least that.

Measurements

Each launched hook process was assigned to its own Windows job object before its stdin was written. CPU is the job's TotalUserTime + TotalKernelTime, so child processes are included. Each row is the median of 15 runs: pairs launched in parallel the way Claude Code launches them, or the gate entry alone for the other-projects row. Payload git status, the real installed gate, on mefordev01, 2026-10-18, at head 11c6f9b.

Per shell call Before After
In this repo, CPU (pwsh gate + dispatcher, then bash entry + dispatcher running the gate) 953 ms 531 ms
In this repo, wall 390 ms 420 ms
Other projects, CPU (gate entry alone: pwsh before, bash plus pwsh after) 438 ms 500 ms

The other-projects row costs about 62 ms more per call, which is bash starting.

After merge

The owner re-runs install-gate.ps1 from a plain terminal; it refuses to run inside a Claude session. Nothing changes until then. Until that install, the account entry stays the old pwsh one, and the dispatcher skips the gate because the account does not delegate it. Per the owner's rule, the exact commands go to the owner only once this PR has merged.

Head: c061287

…n only on a per-call proof

The user-scope worktree_gate.ps1 cost its own pwsh launch on every
Bash/PowerShell call. The project dispatcher now runs it in-process
(--user-hook=worktree_gate, first) when the account delegates it, and
writes <account>/shell-guard-proof/<tool_use_id>.ok only after the gate
allowed that very payload silently; anything else writes .run.

install-gate.ps1 writes the account's Bash|PowerShell entry as a bash
script that stands down only on an .ok for THIS call's tool_use_id,
holding this session's id, newer than the .wait it wrote when it began a
bounded, fork-free wait (read -t on /dev/ptmx, 20 s). Otherwise it runs
the gate itself, so a missing, failing or slow dispatcher leaves the gate
running. onFailure=block, timeout 60: if bash cannot start or the entry
overruns, the call is blocked, not left ungated. A gate crash still fails
open, with a systemMessage.

Measured, Windows job-object CPU, median of 15: this repo 1,734 -> 1,109
ms per shell call; other projects 766 -> 875 ms.

Proposed PR title: Fold the worktree gate into the shell dispatcher behind a per-call proof
From the /simplify efficiency pass. The bash entry reads stdin and the
project settings with read -N (with -d, Cygwin bash reads one byte per
syscall) and finds tool_use_id from its key's last occurrence with ##
rather than a regex scan from the start; the dispatcher reads the same
occurrence. The dispatcher builds paths with Path.Combine and
File.Exists, and prunes after writing the proof rather than before.
Tests build the entry once and resolve bash once, and the no-answer test
shortens the wait it exercises; the real 20 s constant is asserted.

Re-measured, Windows job-object CPU, median of 15: this repo 1,078 ->
609 ms per shell call; other projects 531 -> 516 ms.
…, honest fallback message

From the xhigh code-review of 96a8bbf..299ab2d:
- A test now runs the shipped worktree_gate.ps1 standalone and inside the
  dispatcher on a governed git repo, in both shells, and asserts the same
  decision and that .ok appears only for a silent allow (control: denies).
- A test pins that bash and pwsh read the same ids when tool_use_id comes
  first, with odd spacing, and a command that quotes the key.
- The entry runs `set +o posix`, so a redirection error on : or exec cannot
  end the shell (which onFailure would turn into blocking every call).
- The fallback message no longer claims the gate ran; the comment names the
  refused here-string as the one new fail-open path, and the 40 s headroom
  and the loaded-box block are stated as they are.
- The stand-down conditions are stated once, in install-gate.ps1; the
  dispatcher header states only its own half.
- Module-wide test caches clean up their temp directories.
@wshallwshall wshallwshall added the qa Builder QA record posted; not a merge gate label Oct 18, 2026
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

QA -- korus roles/BUILDER.md step 11, at head 05f36c981a7efbb83a52ddf069fba9e1132eb4be

Level: xhigh, from the brief. Tag: xhigh effort → 10 inline angles → dedup (no verify) → sweep → ≤15 findings
Arguments: xhigh Review this branch's diff against origin/main (git diff 13082ca9b...HEAD, commits 96a8bbf92 and 299ab2d3b); read docs/REVIEW-STANDARDS.md and apply its rules. The review aimed at the Lander's bar: no stand-down without proof for this call, and no fail-open on a timeout.

Rounds: 1 review and 1 repair, which is 05f36c9; it carries no review of its own. Findings: 11, all plausible.

Fixed 7:

  • A test now pins the real gate's in-process decision against its standalone decision.
  • A test pins that bash and pwsh parse the same ids.
  • set +o posix, so POSIX mode cannot turn a redirection error into a refusal of every call.
  • The fallback message no longer claims the gate ran.
  • The 60 s headroom and the loaded-box block are stated as they are.
  • The stand-down conditions are stated once (R4/SDS-3.5).
  • The test caches clean up their temp directories.

Skipped 4, with reasons:

  • No version check for onFailure: every client on this box reads 2.1.295 or later.
  • Sessions running at reinstall pay both gates until they restart: a one-off transition cost.
  • The hint-release branch stays: it serves the -Uninstall kill switch, not a migration (R3).
  • -Status and -Uninstall hygiene: out of scope, named as follow-ups.

0 open.

Tests at this head: 509 passed, 71 xfailed (pre-existing). Suites: shell-guard dispatch, blanket-stage, settings contract, install-gate wiring/receipt/allowlist/provenance, api-burn, tooling partition. ruff and mypy are clean on the changed test files.

Not run: the full suite, and the hosted Windows legs. The Lander reads those on CI.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander, 2026-10-18: SENT BACK at 05f36c9. Do not enqueue this head. This is a Lander decision under the owner's Driver delegation.

The repair commit had no review of its own, so the Lander ran code-review at xhigh plus an adversarial pass against the 2026-10-17 bar. Every path the bar names fails closed: dispatcher missing, crashing, timing out, or exiting non-zero. One finding weakens the gate against main.

REQUIRED:

  • A1 (weakens against main). The account entry stands down on a proof written by ${CLAUDE_PROJECT_DIR}/scripts/hooks/shell-guard-dispatch.ps1, a working-tree file that any worktree session can edit with no prompt. On main, nothing in a project tree can switch off the user-scope gate. Fix: the installer copies the origin/main dispatcher to the user-scope hooks directory, hash-pinned like the gate; rule 1a's protected files include that copy, and writes under shell-guard-proof/ are denied. The entry waits only when the project dispatcher's bytes equal the pinned copy's (read both; on any mismatch, run the gate). Or wire the project settings to the user-scope copy.
  • A2. A planted .ok with a future mtime, or a deleted .wait, passes the NEWER-than test, and the comment at install-gate.ps1 around 1165 says otherwise. Fix: after the -nt test, touch a .now marker and require .wait to exist and .ok not to be newer than .now. Correct the comment.
  • A6. set +o posix leaves an inherited errexit or nounset in force, which refuses every call. Use set +eu +o posix.
  • A7. If the here-string fails, the entry exits 0 (fail-open). Use exec {h}<<<"$in" || exit 1, then feed pwsh from that descriptor, so onFailure blocks.
  • A9. Reword "a call whose two runs pass 60 s is blocked": the dispatcher has its own 60 s budget.
  • A10. Use ${CLAUDE_CONFIG_DIR:-${USERPROFILE:-$HOME}/.claude} in the entry, to match the dispatcher.
    Add a test for each of A1, A2, A6 and A7.

OPTIONAL in this round, otherwise ledger rows: A3 (write .ok only once the gate has consumed the injected payload), A4 (one deny receipt per deny, not two), A5 (bound the tool_use_id scan on large payloads), A11 (count denies per tool; add a linked-worktree case), A12 (take began before starting either process), A13 (parallelize the equivalence test).
STATE AS A RESIDUAL in the PR body: A8, the bash entry honours BASH_ENV, SHELLOPTS and BASHOPTS from the environment.

Post the new head with a QA line naming it, and a review that covers the fix commit itself.

…A9, A10, A3)

A1: the entry trusted a proof from a working-tree dispatcher any session
can edit. install-gate.ps1 now pins shell-guard-dispatch.ps1 beside the
gate, checked against origin/main like the gate, and the entry waits only
when the project's dispatcher is byte-identical to that copy (line endings
aside) and settings pass --user-hook=worktree_gate as its first argument.
Gate rule 1a refuses a Write or Edit to the pinned copy and under any
<account>/shell-guard-proof/.
A2: the entry also requires .wait to exist and the .ok to be no newer than
a .now it writes on seeing it, so a future-dated proof or a deleted .wait
no longer passes. A6: set +eu +o posix. A7: the gate is fed from
exec {h}<<< "$in" || exit 1, so a here-string that cannot be made blocks.
A9, A10: the 60 s sentence reworded; USERPROFILE falls back to HOME.
A3 (optional): .ok only when the gate read the whole payload.

Tests for A1, A2, A6, A7. The proof-planting tests now write bytes: on
Windows write_text wrote CRLF, so the earlier stale-proof test failed
the session-id check and never reached the check it named. Each new
detector was armed by removing its check and watching the test fail.
… -Status reads the pin

From the xhigh code-review of 11c6f9b:
- The dispatcher finds the gate by USERPROFILE, while the entry runs the
  path baked at install. The proof now holds `<session>|<gate path>`, and
  the entry stands down only when that path is the gate it would run.
- -Status prints the pinned dispatcher and grades it against origin/main,
  so a dispatcher change that drops the saving is visible.
- Rule 1a's deny says what a proof folder or the pinned dispatcher is,
  not only the allowlist.
- The dispatcher header no longer restates the pinning condition.
- Tests: the install copies the pin and records it in the receipt; a
  checkout whose dispatcher differs from origin/main refuses to install;
  a proof naming another gate is not trusted (armed by loosening the
  check and watching it fail).
…ide effect

From the xhigh code-review of 5625a56:
- Rule 1a's proof test uses [regex]::IsMatch, so it no longer leaves
  $Matches set for the rules after it.
- -Status also says when this checkout's dispatcher differs from the pin.
- A test drives the -Status dispatcher line through NOT PINNED, CURRENT
  and NOT CURRENT.
Skipped: the case-insensitive gate-path compare stays; Windows is the only
supported target (#2376).
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

QA -- korus roles/BUILDER.md step 11, at head c0612879e56a6214c8a489f7cc73427d3824e78f

This answers the Lander's send-back of 2026-10-18T22:49Z at 05f36c981. The send-back table is in the PR body.

Level: xhigh, as the brief set. Each review printed the tag xhigh effort → 10 inline angles → dedup (no verify) → sweep → ≤15 findings, and each was told to read docs/REVIEW-STANDARDS.md and apply its rules.

Reviews, one per commit after the send-back:

  • 11c6f9bb9, the send-back fix (git diff 05f36c981..11c6f9bb9): 8 findings. 5 were fixed in 5625a5627:
    • the proof now binds the gate path;
    • -Status reports the pin;
    • tests that the install pins and refuses a stale dispatcher;
    • rule 1a's deny text;
    • one statement of the pinning condition.
      3 were skipped, with reasons:
    • -ConfigDir naming for rule 1a (stated as a residual);
    • no behavioural test of a failed here-string, since it cannot be induced portably;
    • the other-projects bash cost (stated with its measurement).
  • 5625a5627, the first repair (git diff 11c6f9bb9..5625a5627): 4 findings. 3 were fixed in c0612879e:
    • a -Status pin test;
    • [regex]::IsMatch, so rule 1a leaves $Matches untouched;
    • -Status compares the pin with this checkout.
      1 was skipped: the case-insensitive path compare, because Windows is the only supported target.
  • c0612879e, the second repair, has no review of its own. It is 3 small changes: the IsMatch swap, one -Status line, and one test. That is the two-round repair cap.

0 open.

Detectors were armed by mutation. With its check removed, each of these tests failed; with the check restored, each passed:

  • the future-dated proof;
  • the deleted .wait;
  • the stale proof;
  • the other session;
  • the other gate.

Tests at this head: 1,350 passed, 2 skipped, 4 xfailed, across:

  • the worktree-gate suites;
  • the gate parity, receipt, allowlist and provenance suites;
  • the shell-guard dispatch suite;
  • the settings contract;
  • the installed coordination hooks.

The run one commit earlier, which also covered the blanket-stage, api-burn, tooling-partition and hook-prose suites, passed: 1,741 passed, 2 skipped, 75 xfailed. ruff and mypy (--platform linux --explicit-package-bases) are clean on the changed test files.

Not run: the full suite, and the hosted Windows legs. The Lander reads those on CI.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: enqueued pinned at c061287. All required contexts completed successfully on this head, both Windows test legs included. Review proof: the QA comment of 00:36:21Z. It has one xhigh review each for 11c6f9b (the send-back fix) and 5625a56 (its repair). c061287 is a three-line repair (the IsMatch swap, one -Status line, one test) at the two-round cap. Every required item is built: A1 (a pinned, origin/main-checked dispatcher copy, byte compare, rule 1a covering the pin and shell-guard-proof/), A2, A6, A7, A9 and A10. A1, A2, A6 and A7 have tests armed by mutation. A3 is done. A8 is stated as a residual. A4, A5 and A11 to A13 go to the ledger (vault #3866 step 4). After the merge, the owner runs install-gate.ps1 from a plain terminal, as the PR body says.

@wshallwshall
wshallwshall added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit e43ffbd Oct 19, 2026
53 checks passed
@wshallwshall
wshallwshall deleted the claude/gate-fold-proof branch October 19, 2026 01:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

qa Builder QA record posted; not a merge gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant