From 78655556347877db2e50852ad9fc3a70489145d7 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Tue, 25 Aug 2026 19:10:08 -0500 Subject: [PATCH] fix(worktree): a write to the machine-global gate now leaves a record (BACKLOG #1247) The gate install was a bare Copy-Item. When the installed gate's content changed on this box while three sessions ran against it, nobody could say who wrote it -- and four separate mechanisms could each have recorded it. None existed. The change itself moved the gate forward, so this is a governance defect rather than an incident; an unattributable write to a shared safety control is the same class of event whether it upgrades or downgrades. All four are now present, and each has a named test so removing one reds that test rather than quietly restoring the gap: BACKUP the bytes being overwritten are copied to .bak first. On an unattributed write those are the only copy of what was there. RECEIPT a JSON sidecar carrying content_hash, hash_replaced, replaced_ours, written_at_utc, source_repo, source_commit, source_blob. ATTRIBUTION replaced_ours answers the question nobody could answer when this was filed: were the bytes I just overwrote ones THIS installer put there? It is false exactly when a previous write was unattributed. REAL MTIME Copy-Item carries the SOURCE mtime across, so the installed file reported a checkout's timestamp as though it were the install's. That is worse than no timestamp because it reads as one, and this row records an inherited mtime carrying a true finding into retraction. THE RECEIPT HASHES WITH Get-GateHash, NOT Get-FileHash, AND THAT IS THE DETAIL MOST LIKELY TO BE UNDONE. This file already learned it once: a byte-exact digest made every Windows checkout read as STALE while git status called the file clean, because git stores LF and checks out CRLF. The remedy that false STALE printed was a re-install -- which DOWNGRADES a machine-global control when the checkout is older than the installed gate. -Status and tests/test_gate_installed_parity.py already share that function; a third basis in the receipt would reintroduce the same defect one instrument over. A test pins it. -RefuseOnMismatch DEFAULTS OFF, deliberately. A mismatch against the receipt is not proof of tampering -- an older installer, a hand copy or a legitimate out-of-band fix all look identical -- and refusing by default would break first installs and ordinary upgrades. Off, it warns and records the replaced hash; on, it refuses and preserves the bytes for inspection rather than destroying the evidence. THE TESTS ARE STRUCTURAL AND THAT IS FORCED, NOT CHOSEN. install-gate.ps1 refuses to run when CLAUDECODE is set -- "a session that can install its own gate can uninstall it" -- so the write path cannot be executed from a test session at all, sandboxed HOME or otherwise. Unsetting that variable to reach the code would be defeating the control the file exists to be. The existing -Status tests work only because -Status sits above that refusal. Each new test was mutation-checked against the shipped file: removing the mtime stamp, the backup, or the refuse branch reds exactly one test each, restored by byte-copy with the hash verified. ONE OF THOSE TESTS WAS WRONG FIRST, IN THE WAY IT WAS TESTING FOR. It searched the raw installer text for "Get-FileHash" and matched its own comment saying "Get-GateHash, NOT Get-FileHash" -- failing on a file that was correct. Both occurrences in this installer are in comments. It now reads code lines only: a string search cannot see whether a name is being CALLED or RULED OUT, and the mention most likely to exist is the one ruling it out. tests/test_gate_installed_parity.py is untouched: the receipt does not change what installed-versus-source parity means, and it already shares the hash function. Verified: 104 passed across the five gate, wiring, parity and repo-wide inventory test files. ruff clean. install-gate.ps1 parses clean under the PowerShell parser. Co-Authored-By: Claude Opus 5 --- scripts/worktree/install-gate.ps1 | 70 ++++++++++++++++++++++- tests/test_install_gate_wiring.py | 95 +++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/scripts/worktree/install-gate.ps1 b/scripts/worktree/install-gate.ps1 index 0964cb6f..ef32947c 100644 --- a/scripts/worktree/install-gate.ps1 +++ b/scripts/worktree/install-gate.ps1 @@ -67,7 +67,12 @@ param( # than losing it. See docs/SESSION-DRIFT-CONTROLS.md. [switch]$EnterWorktreeGate, # Config dirs to wire the hook into. Default: ~/.claude plus every existing ~/.claude-account-*. - [string[]]$ConfigDir + [string[]]$ConfigDir, + # Refuse to overwrite the installed gate when its content does not match the receipt this + # installer last wrote -- i.e. when SOMETHING ELSE wrote it. Off by default, because the common + # case is a first install or a legitimate upgrade and refusing those would make the installer + # unusable; on, it preserves the unattributable bytes for inspection instead of destroying them. + [switch]$RefuseOnMismatch ) $ErrorActionPreference = "Stop" @@ -438,7 +443,68 @@ $resolved = foreach ($r in $Repo) { } New-Item -ItemType Directory -Force -Path $HooksDir | Out-Null -Copy-Item -LiteralPath (Join-Path $RepoRoot "scripts\hooks\worktree_gate.ps1") -Destination $GateDst -Force + +# A WRITE TO A MACHINE-GLOBAL SAFETY CONTROL NOW LEAVES A RECORD (BACKLOG #1247). The bare Copy-Item +# this replaces left four separate ways to attribute a change, and none of them existed: no backup of +# the overwritten bytes, no receipt, no log line, and Copy-Item PRESERVES THE SOURCE mtime -- so the +# installed file's timestamp described a checkout rather than the install, which is worse than no +# timestamp because it reads as one. +$GateSrc = Join-Path $RepoRoot "scripts\hooks\worktree_gate.ps1" +$ReceiptPath = "$GateDst.receipt.json" + +# The hash BEING REPLACED, captured before anything is written. Null on a first install. +$hashBefore = Get-GateHash $GateDst + +# Get-GateHash, NOT Get-FileHash and NOT a second basis of my own: it folds CRLF on bytes, and its +# own comment records that a byte-exact digest made every Windows checkout read as STALE while +# `git status` called the file clean. tests est_gate_installed_parity.py uses the same function, so +# the receipt, -Status and the test cannot disagree about one file. +$hashSource = Get-GateHash $GateSrc + +# DID SOMETHING ELSE WRITE THIS? Only answerable against a receipt we previously wrote. A mismatch is +# not proof of tampering -- an older installer, a hand-copy or a legitimate out-of-band fix all look +# the same -- so the default RECORDS it and only -RefuseOnMismatch stops. +$priorReceipt = $null +if (Test-Path -LiteralPath $ReceiptPath) { + try { $priorReceipt = Get-Content -LiteralPath $ReceiptPath -Raw | ConvertFrom-Json } catch { $priorReceipt = $null } +} +$unattributed = $null -ne $priorReceipt -and $null -ne $hashBefore -and $priorReceipt.content_hash -ne $hashBefore +if ($unattributed) { + $msg = "the installed gate does not match the receipt this installer last wrote: receipt says " + + "$($priorReceipt.content_hash), on disk is $hashBefore. Something else wrote it." + if ($RefuseOnMismatch) { + throw "$msg Refusing (-RefuseOnMismatch). The bytes are preserved; diff them before re-installing." + } + Write-Warning "$msg Overwriting; the replaced hash is recorded in the receipt." +} + +# BACKUP FIRST. Nothing preserved the overwritten bytes, and this is the only copy of whatever a +# previous unattributed write left behind. +if (Test-Path -LiteralPath $GateDst) { Copy-Item -LiteralPath $GateDst "$GateDst.bak" -Force } + +Copy-Item -LiteralPath $GateSrc -Destination $GateDst -Force + +# THE REAL WRITE TIME. Copy-Item carries the SOURCE mtime across, so without this the installed file +# claims the checkout's timestamp. The row this closes records that inherited mtime carrying a true +# finding into retraction. +$writtenAt = [DateTime]::UtcNow +(Get-Item -LiteralPath $GateDst).LastWriteTimeUtc = $writtenAt + +$srcCommit = (& git -C $RepoRoot rev-parse HEAD 2>$null) +$srcBlob = (& git -C $RepoRoot rev-parse "HEAD:scripts/hooks/worktree_gate.ps1" 2>$null) +[ordered]@{ + schema = 1 + written_at_utc = $writtenAt.ToString("o") + content_hash = Get-GateHash $GateDst + hash_replaced = $hashBefore + # Whether the bytes we just overwrote were ones THIS installer put there. False means a previous + # write is unattributed -- the exact question nobody could answer when this item was filed. + replaced_ours = $null -eq $hashBefore -or (-not $unattributed) + source_repo = $RepoRoot + source_commit = if ($srcCommit) { $srcCommit.Trim() } else { $null } + source_blob = if ($srcBlob) { $srcBlob.Trim() } else { $null } + installer = $PSCommandPath +} | ConvertTo-Json | Set-Content -LiteralPath $ReceiptPath -Encoding utf8 @( "# Primary checkouts governed by the worktree gate (scripts\hooks\worktree_gate.ps1)." diff --git a/tests/test_install_gate_wiring.py b/tests/test_install_gate_wiring.py index 79af36fd..b9a5ebd9 100644 --- a/tests/test_install_gate_wiring.py +++ b/tests/test_install_gate_wiring.py @@ -573,3 +573,98 @@ def test_one_wired_matcher_is_membership_not_a_substring_search(tmp_path: Path) # The contrast that localises it: same dir, same single matcher, but not a substring of it. assert "MultiEdit" in unwired, f"MultiEdit is not wired here either:\n{out}" assert "NotebookEdit" not in unwired, f"NotebookEdit IS wired here:\n{out}" + + +# ------------------------------------------------------- the install receipt (BACKLOG #1247) +# +# STRUCTURAL, AND THAT IS FORCED RATHER THAN CHOSEN. install-gate.ps1 REFUSES to run when +# ``$env:CLAUDECODE`` is set -- "a session that can install its own gate can uninstall it" -- so the +# write path cannot be executed from a test session at all, in a sandboxed HOME or otherwise. +# Unsetting that variable to reach the code would be defeating the control the file exists to be. +# `_status_against` above works only because ``-Status`` sits ABOVE that refusal and writes nothing. +# +# So these read the writer the way the wiring tests above read it, and each pins ONE of the four +# mechanisms BACKLOG #1247 found absent, so removing any one of them reds a named test rather than +# quietly restoring the gap. + + +def _installer_src() -> str: + return INSTALLER.read_text(encoding="utf-8") + + +def test_the_gate_install_backs_up_the_bytes_it_overwrites() -> None: + """Mechanism 1 of 4. Nothing preserved the overwritten bytes, and on an unattributed write those + are the only copy of what was there.""" + src = _installer_src() + assert '$GateDst "$GateDst.bak"' in src, ( + "the gate install no longer backs up the file it replaces; an unattributed write is then " + "unrecoverable as well as unattributable" + ) + + +def test_the_gate_install_writes_a_receipt_carrying_both_hashes() -> None: + """Mechanism 2 of 4, and the hash REPLACED is the half that answers 'who wrote this'.""" + src = _installer_src() + assert "$ReceiptPath" in src, "install-gate.ps1 writes no receipt" + for field in ( + "content_hash", + "hash_replaced", + "replaced_ours", + "written_at_utc", + "source_commit", + ): + assert field in src, f"the install receipt no longer records {field!r}" + + +def test_the_receipt_hashes_on_the_same_basis_as_status_and_the_parity_test() -> None: + """A SECOND hashing basis is the defect this file already fixed once. + + ``Get-GateHash`` folds CRLF on bytes because a byte-exact digest made every Windows checkout read + as STALE while ``git status`` called the file clean -- and the printed remedy was a re-install, + which DOWNGRADES a machine-global control. A receipt hashing with ``Get-FileHash`` would + reintroduce exactly that, one instrument over. + """ + src = _installer_src() + start = src.index("$ReceiptPath") + end = src.index("Set-Content -LiteralPath $ReceiptPath", start) + region = src[start:end] + + # CODE lines only. Both occurrences of "Get-FileHash" in this installer sit in COMMENTS -- one in + # Get-GateHash's own rationale, one in the receipt's comment saying "Get-GateHash, NOT + # Get-FileHash". The first draft of this test searched the raw text and MATCHED ITS OWN + # DISCLAIMER, failing on a file that was correct. A string search cannot see whether a name is + # being CALLED or being RULED OUT, and the mention most likely to appear is the one ruling it out. + code_lines = [ln for ln in region.splitlines() if not ln.lstrip().startswith("#")] + + assert any("Get-GateHash" in ln for ln in code_lines), ( + "the receipt does not use the shared content hash" + ) + assert not any("Get-FileHash" in ln for ln in code_lines), ( + "the receipt CALLS Get-FileHash: a byte-exact digest disagrees with -Status and with " + "test_gate_installed_parity.py on any CRLF checkout" + ) + + +def test_the_installed_gate_carries_the_install_time_not_the_sources() -> None: + """Mechanism 4 of 4, and the one that is worse than absent. + + ``Copy-Item`` carries the SOURCE mtime across, so without an explicit stamp the installed file + reports the checkout's timestamp as though it were the install's. #1247 records that inherited + mtime carrying a true finding into retraction. + """ + assert "LastWriteTimeUtc = $writtenAt" in _installer_src(), ( + "the install no longer stamps the real write time; Copy-Item leaves the source's mtime, " + "which reads as an install time and is not one" + ) + + +def test_a_mismatch_against_the_receipt_can_refuse_rather_than_overwrite() -> None: + """The flag exists AND defaults off. Refusing by default would make first installs and ordinary + upgrades fail, so the default records the replaced hash and only the flag stops.""" + src = _installer_src() + assert "[switch]$RefuseOnMismatch" in src, "no refuse-on-mismatch flag" + assert "if ($RefuseOnMismatch)" in src, "the flag is declared but never consulted" + assert "Write-Warning" in src, ( + "without the warning branch a mismatch would be silent when the flag is off, which is the " + "unattributable-write case this item was filed for" + )