Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions scripts/worktree/install-gate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)."
Expand Down
95 changes: 95 additions & 0 deletions tests/test_install_gate_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Loading