Repository navigation
Fold the worktree gate into the shell dispatcher behind a per-call proof - #2427
Conversation
…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.
|
QA -- korus roles/BUILDER.md step 11, at head Level: xhigh, from the brief. Tag: Rounds: 1 review and 1 repair, which is 05f36c9; it carries no review of its own. Findings: 11, all plausible. Fixed 7:
Skipped 4, with reasons:
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. |
|
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:
OPTIONAL in this round, otherwise ledger rows: A3 (write 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).
|
QA -- korus roles/BUILDER.md step 11, at head This answers the Lander's send-back of 2026-10-18T22:49Z at Level: xhigh, as the brief set. Each review printed the tag Reviews, one per commit after the send-back:
0 open. Detectors were armed by mutation. With its check removed, each of these tests failed; with the check restored, each passed:
Tests at this head: 1,350 passed, 2 skipped, 4 xfailed, across:
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 ( Not run: the full suite, and the hosted Windows legs. The Lander reads those on CI. |
|
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. |
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
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_gateas its first argument:~/.claude/hooks/worktree_gate.ps1in-process, but only when the account's settings carry the installer's entry.<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..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 againstorigin/mainthe 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, ininstall-gate.ps1. In short, it waits only when all of these hold:settings.jsonpasses"${CLAUDE_PROJECT_DIR}/scripts/hooks/shell-guard-dispatch.ps1"with"--user-hook=worktree_gate"as the next argument;It then stands down only when the
.ok:.waitfile the entry wrote, which must still exist;.nowfile 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
shell-guard-proof/.okor a deleted.waitpasses.waitmust exist, and the.okmust be no newer than.now; comment corrected.waitset +eu +o posixSHELLOPTS=errexit:nounset, with a control showing it ends an entry without the resetexec {h}<<< "$in" || exit 1, then the gate reads from that descriptor${CLAUDE_CONFIG_DIR:-${USERPROFILE:-$HOME}/.claude}.okonly when the gate read the whole payloadReview rounds after the send-back added two more fixes:
USERPROFILEis not trusted. Tested.-Statusgrades 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_textwrote 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:
tool_use_idscan on large payloads.beganbefore starting either process.Residuals, stated
BASH_ENV,SHELLOPTSandBASHOPTSfrom its environment. These are read before its first line runs.set +eu +o posixundoes errexit, nounset and POSIX mode only.shell-guard-proof/only under a directory named.claude*. That is every account the installer discovers by default, but not an account installed with-ConfigDirpointing elsewhere. Rule 1a covers Write and Edit tools only, not a shell write..waitand writes a matching.okat once is not stopped. Such a process can already edit the account'ssettings.json, which the gate does not cover.maindrops the saving until the next install. Sessions in an updated checkout then run the gate twice per call. Safety is unaffected, and-Statusnow reports it.onFailureneeds 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. Payloadgit status, the real installed gate, on mefordev01, 2026-10-18, at head 11c6f9b.The other-projects row costs about 62 ms more per call, which is bash starting.
After merge
The owner re-runs
install-gate.ps1from 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