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" + )