diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 79d385d9..9ead4fe4 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -4859,7 +4859,7 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre ## 1019. install-selfheal.ps1 has no installed-vs-source payload-parity instrument, and it wires the most privileged hook in the estate -> 🔢 **Filed 2026-08-04 — not started.** Value **5/10** · Difficulty **3/10** · _fill-in_. The installer lays down a copy of `worktree-selfheal.ps1` at `~/.claude-hooks/` and wires it as a user-scope SessionStart hook, with no way to detect that the copy and the checkout have diverged: no `-Status`, no version stamp, no hash at the `Copy-Item`, and no test that reads the installed copy. +> 🚧 **PARTLY LANDED 2026-08-04 — the instrument exists now; the installer-side half is deliberately NOT built.** Value **5/10** · Difficulty **3/10** · _fill-in_. **Shipped:** `tests/test_selfheal_installed_parity.py` asserts the installed backstop payload at `~/.claude-hooks/worktree-selfheal.ps1` matches the committed source, folding CRLF on bytes exactly as `Get-GateHash` and `content_hash` do, with a negative control proving the folded comparison still detects a one-character change. **Deliberately not built:** `-Status`, a version stamp, and a hash at the `Copy-Item`. Adding `-Status` would have meant narrowing the `CLAUDECODE` refusal so a session could run it, which is weakening a security control on a broad task bundle that never named it — a sub-agent proposing exactly that was correctly blocked. It is also unnecessary: `install-gate.ps1 -Status` refuses in-session too, so **plain-terminal-only is the precedent, not a gap**, and the observability is delivered from the pytest side, which needs no privilege. `install-selfheal.ps1` is byte-unchanged (verified). **Item stays OPEN** for the installer-side readout, if it is ever wanted. ⚠️ Do not read this as closed. Filed 2026-08-04. The installer lays down a copy of `worktree-selfheal.ps1` at `~/.claude-hooks/` and wires it as a user-scope SessionStart hook, with no way to detect that the copy and the checkout have diverged: no `-Status`, no version stamp, no hash at the `Copy-Item`, and no test that reads the installed copy. **Cluster:** Developer tooling / session-drift controls. **Priority:** P3. **Verdict:** build (small). **Severity:** developer-box tooling integrity, not product or PHI, and nothing here touches a deployment. diff --git a/tests/test_gate_installed_parity.py b/tests/test_gate_installed_parity.py index 0b818749..8b4d5867 100644 --- a/tests/test_gate_installed_parity.py +++ b/tests/test_gate_installed_parity.py @@ -134,13 +134,150 @@ def handled_tools(text: str) -> set[str]: return tools +# The NAME shape of a launcher config dir: ~/.claude is the Desktop app, and every VS Code launcher on +# this box points CLAUDE_CONFIG_DIR at ~/.claude-account- with N decimal. That is not inferred from the +# directory listing -- it is how the launchers BUILD the path: ~/claude-launchers/Launch-Claude-{1,2,3,4} +# .ps1 each assign a literal `.claude-account-`, and Setup-GitHub-SignIn.ps1 assigns +# "...\.claude-account-$Account". A suffix after the number is therefore not an account, because nothing +# can launch from one. +# +# \A and \Z are IN THE PATTERN, not left to the call site. The end anchor is the entire predicate here -- +# unanchored, `.match()` accepts ".claude-account-2.lock" on its prefix and `.search()` accepts it +# anywhere -- so a later call site written with `match` or `search` instead of `fullmatch` would silently +# re-admit the artifact this filter exists to exclude, and every test would stay green while it did. +# Anchoring here makes all three methods agree; test_the_launcher_name_predicate_* asserts that they do. +_ACCOUNT_DIR_NAME = re.compile(r"\A\.claude-account-\d+\Z") + +# What a dir a session has actually launched from carries. Claude Code writes both on first use, so their +# presence is evidence of a LOGIN as opposed to a directory that merely has the right name. Measured +# 2026-08-04: ~/.claude and .claude-account-1/2/3/4 all carry both; .claude-account-2.lock carries +# NEITHER -- its entire contents are settings.json and settings.json.bak. +_LOGIN_MARKERS = (".claude.json", ".credentials.json") + + +def _account_candidates() -> list[Path]: + """Everything the ``.claude-account-*`` glob turns up, before any judgement about what it is.""" + return sorted(Path.home().glob(".claude-account-*")) + + def config_dirs() -> list[Path]: - """Every Claude config dir on this box: ~/.claude plus the ~/.claude-account-* VS Code launchers.""" + """Config dirs a session can LAUNCH from: ~/.claude plus the ~/.claude-account- VS Code launchers. + + The glob alone was too wide, and the directory it over-matched was manufactured by this machinery + itself. Measured 2026-08-04 on this box, ``.claude-account-2.lock``: + + * the directory itself was created 07-22 20:09, with nothing inside it predating 07-24 -- so it was + PROBABLY created empty, which is an inference from the absence of older contents, not a reading; + * its ``settings.json.bak`` was created 07-24 14:44, so the dir was already being wired at or before + then. The ``settings.json`` file object dates from 07-29 13:32 and CANNOT date the first write: + ``Write-Settings`` writes a temp and ``Move-Item``s it into place, replacing the file object on + every run, so its creation time is always the LAST run. The ``.bak`` is overwritten in place, so + its creation time is the FIRST run that found a settings.json to back up. The two writes still + visible are 07-29 13:07 (carried by the ``.bak``'s mtime, which ``Copy-Item`` takes from the + source) and 13:32 -- the same pair ``.claude-account-2`` carries, because ``install-gate.ps1`` + globs ``.claude-account-*`` too (:91) and wires both dirs in lockstep. Account-2 also holds a + ``.bak`` from 07-17, so there were earlier runs than the visible pair on both sides; + * it is 1079 bytes against account-2's 2072, and holds the three gate matcher entries and NOTHING + else -- no ``.claude.json``, no ``.credentials.json``, no ``sessions/``, no ``projects/``, and no + SessionStart selfheal hook (``install-selfheal.ps1`` takes ``-ConfigDir`` as a mandatory single + value, so it never globbed and never reached this dir). + + So the gate wiring the tests were reading back out of it was written INTO it by the installer's own + glob, and matched only because both globs are wrong in the same way. It passes today; it is a stale + snapshot the moment real wiring changes, and the suite would then go red pointing at a directory + nobody launches from -- the reader's next move being a re-install, i.e. the stale-checkout DOWNGRADE + hazard :func:`content_hash` documents, fired to fix nothing. + + WHY A NAME SHAPE AND NOT ``not name.endswith(".lock")``: a blocklist excludes the one artifact that + happens to exist and lets the next one through -- ``.bak``, ``.old``, ``.disabled``, ``-copy``, a + dated backup. The launcher name shape is a positive rule that can be checked against the launchers. + + RISK, stated rather than left to be discovered. Excluding a directory from a wiring check means a + genuinely UN-WIRED launcher can hide behind a name this filter rejects, and it would hide silently -- + an un-wired gate is exactly the condition this module exists to surface. AT LEAST these shapes are at + risk -- a named account (``.claude-account-alpha``), a suffixed one (``.claude-account-2b``, + ``.claude-account-2-work``), or a config dir off the pattern entirely (``.claude-work``, or any + ``CLAUDE_CONFIG_DIR`` pointing outside ``~``, which neither this predicate nor the glob before it + ever saw). + + AND ONE MORE THAT IS LIVE ON THIS BOX RIGHT NOW, which is why the enumeration above is written as + "at least": a RIGHT-shaped name carrying no ``settings.json`` at all. The trailing filter on the + return below drops it before either instrument here sees it, and that filter predates this change. + ``.claude-account-4`` is in exactly that state -- both login markers written 2026-08-04 19:37, built + by ``~/claude-launchers/Launch-Claude-4.ps1``, and NO settings.json, so no gate wiring whatsoever. + It is a live launcher with no gate, and nothing in this module reports it. :func:`unwired_launchers` + exists to make that loud rather than leaving it to this docstring. + + That risk is guarded, not just admitted, in two places -- because a shrinking config-dir list makes + ``test_every_non_optional_rule_is_wired_in_every_config_dir`` vacuously easier to pass, which is the + green-because-we-stopped-looking failure this suite exists to prevent: + + 1. :func:`excluded_config_dirs` is PRINTED by both tests that scan, so an exclusion appears in the + output of the very run it changed rather than being inferred from a count that got smaller. + 2. ``test_nothing_excluded_from_the_wiring_scan_is_a_live_login`` asserts every exclusion made BY + NAME is inert, by a signal INDEPENDENT of that name (:data:`_LOGIN_MARKERS`). A wrongly-excluded + dir that anything actually logs in from fails that test by the evidence it cannot help leaving + behind. Scoped deliberately: it does NOT cover the settings.json filter above, which is why + :func:`unwired_launchers` is reported separately. + """ home = Path.home() - found = [home / ".claude"] + sorted(home.glob(".claude-account-*")) + found = [home / ".claude"] + [ + d for d in _account_candidates() if _ACCOUNT_DIR_NAME.fullmatch(d.name) + ] return [d for d in found if (d / "settings.json").is_file()] +def excluded_config_dirs() -> list[Path]: + """Dirs the glob found, that carry a settings.json, and that :func:`config_dirs` refuses to judge. + + Only the ones with a ``settings.json`` -- a name-shape reject with no settings file was never in the + scanned set and dropping it changes nothing. These are precisely the dirs whose wiring stopped being + checked, which is the set a reader has to see to audit the exclusion. + """ + return [ + d + for d in _account_candidates() + if not _ACCOUNT_DIR_NAME.fullmatch(d.name) and (d / "settings.json").is_file() + ] + + +def unwired_launchers() -> list[Path]: + """Launcher-shaped dirs something LOGS IN from that carry no ``settings.json`` -- so no gate at all. + + This is the gap the name-shape filter does NOT cause and does NOT cover. :func:`config_dirs` has + always ended with ``(d / "settings.json").is_file()``, which silently drops a dir that has every + other mark of a live launcher. Such a dir is not merely unjudged: with no settings.json there is no + ``PreToolUse`` wiring, so the gate does not run there AT ALL, which is a strictly worse condition + than the stale-snapshot case this module was changed to fix. + + Measured 2026-08-04: ``.claude-account-4`` is in this state -- ``.claude.json`` and + ``.credentials.json`` both written 19:37, ``~/claude-launchers/Launch-Claude-4.ps1`` builds it, and + no settings.json. + + REPORTED, NOT ASSERTED, deliberately. Whether a launcher should be wired is the box owner's decision + -- an unused profile is a legitimate reason to leave one alone -- and a test that goes red over a + machine-configuration choice is the crying-wolf failure this suite exists to avoid. Printing it in + the run that scans makes it impossible to not-know, which is the part that was missing. Promote this + to an assertion the moment "every launcher is wired" becomes a rule rather than an observation. + """ + return [ + d + for d in _account_candidates() + if _ACCOUNT_DIR_NAME.fullmatch(d.name) + and not (d / "settings.json").is_file() + and looks_like_a_live_login(d) + ] + + +def looks_like_a_live_login(d: Path) -> bool: + """Is this a dir something actually launches Claude Code from, judged WITHOUT reference to its name? + + The name is what the exclusion turns on, so re-using the name here would make the guard agree with + the thing it is guarding by construction. These files are written by Claude Code itself on first use. + """ + return any((d / marker).is_file() for marker in _LOGIN_MARKERS) + + def wired_matchers(settings: Path) -> set[str]: """Tool names reachable through a PreToolUse entry whose command names the gate.""" try: @@ -282,6 +419,14 @@ def test_every_wired_matcher_names_a_tool_the_gate_handles() -> None: and -- worse -- reads as coverage that does not exist.""" dirs = config_dirs() print(f"scanning {len(dirs)} config dir(s) against {INSTALLED_GATE}") + print( + f" not judged (not a launcher name): {[d.name for d in excluded_config_dirs()] or 'none'}" + ) + # A DIFFERENT gap from the one above, printed beside it so the two are never conflated: these carry + # no settings.json at all, so the gate does not run there. Not caused by the name filter. + print( + f" LAUNCHER WITH NO GATE (no settings.json): {[d.name for d in unwired_launchers()] or 'none'}" + ) if not dirs: pytest.skip("SKIP (nothing scanned): no Claude config dirs on this box -- nothing is wired") if not INSTALLED_GATE.is_file(): @@ -309,6 +454,16 @@ def test_every_non_optional_rule_is_wired_in_every_config_dir() -> None: print( f"scanning {len(dirs)} config dir(s); opt-in (absence is not drift): {sorted(OPT_IN_TOOLS)}" ) + # An exclusion makes this assertion easier to pass, so it is printed in the same breath as the count + # it reduced. A number that got smaller looks like an improvement; the names say what was dropped. + print( + f" not judged (not a launcher name): {[d.name for d in excluded_config_dirs()] or 'none'}" + ) + # A DIFFERENT gap from the one above, printed beside it so the two are never conflated: these carry + # no settings.json at all, so the gate does not run there. Not caused by the name filter. + print( + f" LAUNCHER WITH NO GATE (no settings.json): {[d.name for d in unwired_launchers()] or 'none'}" + ) if not dirs: pytest.skip("SKIP (nothing scanned): no Claude config dirs on this box -- nothing is wired") if not INSTALLED_GATE.is_file(): @@ -330,6 +485,77 @@ def test_every_non_optional_rule_is_wired_in_every_config_dir() -> None: ) +def test_nothing_excluded_from_the_wiring_scan_is_a_live_login() -> None: + """GUARD ON THE EXCLUSION. Narrowing :func:`config_dirs` shrinks the set the two assertions above + scan, and a smaller set is easier to pass -- so prove every dir dropped is one nothing launches from. + + The check deliberately does not consult the NAME, which is what the exclusion turns on; it reads the + files Claude Code writes into a config dir on first use. A dir excluded by name that is nonetheless + logged into fails here, which is the only way a wrongly-excluded launcher gets to announce itself. + + Passes vacuously when nothing is excluded, and says so in its output rather than leaving a bare dot. + """ + excluded = excluded_config_dirs() + print(f"excluded from the wiring scan: {[d.name for d in excluded] or 'none'}") + print(f"judged as launchers: {[d.name for d in config_dirs()]}") + + live = {d.name: sorted(m for m in _LOGIN_MARKERS if (d / m).is_file()) for d in excluded} + for name, markers in live.items(): + print(f" {name}: login markers {markers or '(none -- inert)'}") + + suspects = {name: markers for name, markers in live.items() if markers} + assert not suspects, ( + f"a directory excluded from the wiring scan by NAME carries the files Claude Code writes into a " + f"config dir it is logged into: {suspects}. Something may launch from it, in which case its gate " + f"wiring stopped being checked the moment it was excluded and nothing else looks at it. Either " + f"it is a real launcher -- widen _ACCOUNT_DIR_NAME to admit its shape -- or it is a stale copy " + f"of one, in which case say which and delete it. Do NOT relax this assertion to make it quiet." + ) + + +def test_the_launcher_name_predicate_accepts_launchers_and_rejects_the_artifact() -> None: + """NEGATIVE CONTROL for the name shape, and the record of what it gives up. + + A filter is only evidence if it has been shown to reject the class it was written for AND to keep the + class it must not touch. Exercised against name strings, not against ~ -- the real directories are + machine-global and a test may not create or remove one to make its point. + + The rejected group's second half is the RISK from :func:`config_dirs` written down as a fact instead + of as prose: these are launcher-ish names this predicate drops. If one of them ever becomes a real + config dir, this test is the thing that names it, and widening the regex is then the fix -- not + deleting the case. + """ + accepted = [".claude-account-1", ".claude-account-2", ".claude-account-3", ".claude-account-42"] + rejected = [ + ".claude-account-2.lock", # the measured artifact -- an empty dir the installer's glob wired + ".claude-account-2.bak", # the next artifact shape, which a `.lock` blocklist would have missed + ".claude-account-2-old", + ".claude-account-alpha", # KNOWN COST: a named account would be wrongly excluded + ".claude-account-2b", # KNOWN COST: a suffixed account would be wrongly excluded + ] + + for name in accepted: + assert _ACCOUNT_DIR_NAME.fullmatch(name), f"{name} is a launcher shape and must be judged" + for name in rejected: + assert not _ACCOUNT_DIR_NAME.fullmatch(name), f"{name} must not be judged as a launcher" + + # GUARD THE GUARD. Everything above applies the pattern with fullmatch, so it proves nothing about + # what happens if a future call site reaches for match or search instead -- and on an unanchored + # pattern both would readmit the artifact through its launcher-shaped prefix. The \A and \Z live in + # the pattern precisely so the method cannot matter; assert the three agree rather than trust it. + artifact = ".claude-account-2.lock" + for method in (_ACCOUNT_DIR_NAME.fullmatch, _ACCOUNT_DIR_NAME.match, _ACCOUNT_DIR_NAME.search): + assert not method(artifact), ( + f"{method.__name__}({artifact!r}) matched: the pattern lost an anchor, so applying it any " + f"way other than fullmatch now readmits the artifact on its prefix" + ) + # And prove that agreement is not the trivial kind, where the pattern matches nothing at all. + assert _ACCOUNT_DIR_NAME.search(".claude-account-1"), ( + "the pattern matches no launcher name under search either -- the check above is vacuous" + ) + print(f"accepted {accepted}; rejected {rejected} (last two are the documented cost)") + + def test_the_opt_in_list_only_names_tools_the_gate_actually_has() -> None: """Guard the exemption. A stale name in OPT_IN_TOOLS would silently excuse a future rule that happened to reuse it -- the exemption must track the script, not outlive it.""" diff --git a/tests/test_selfheal_installed_parity.py b/tests/test_selfheal_installed_parity.py new file mode 100644 index 00000000..d1160ad8 --- /dev/null +++ b/tests/test_selfheal_installed_parity.py @@ -0,0 +1,372 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Is the worktree-selfheal payload that RUNS the one in this checkout? + +BACKLOG #1019. ``install-selfheal.ps1`` lays the SessionStart backstop down as an installed COPY: +``Copy-Item`` from ``$PSScriptRoot`` to ``$HookPath``, whose default is ``~/.claude-hooks/worktree- +selfheal.ps1``. The installer takes ``-ConfigDir`` as a MANDATORY single value and never globs, so what +it writes per run is the WIRING for one config dir -- the payload itself is one shared, account-agnostic +file that every config dir's SessionStart command names. One copy therefore governs every session on the +box at once, and it is the copy that runs ``git checkout`` on the shared primary unattended. + +**Nothing reported drift in it.** That absence is the item. Measured 2026-08-04: the installed payload was +1376 folded bytes adrift of ``scripts/worktree/worktree-selfheal.ps1`` and no instrument on this box said +so -- not the suite, not the installer (it has no ``-Status``), not ``git status``, which only ever sees +the source side. It is in sync as this module lands (folded sha ``c41c70ecf885``, 9891 folded / 10050 raw +on both sides) because the owner re-ran the installer from a plain terminal, which is a one-off human act +and not a control. Drift runs both ways and the quiet direction is the bad one: an edit to the source has +no effect until someone re-installs, and a check DELETED from the source keeps firing until then, with +``tests/test_worktree_selfheal_wiring.py`` correctly green throughout -- it drives the SOURCE script. + +WHY A SEPARATE MODULE from ``test_worktree_selfheal_wiring.py``, which already covers this hook. That +module carries a module-level ``skipif(shutil.which("pwsh") is None)`` because MOST of its tests run the +script or the installer as a pwsh subprocess. That gate is right for them and wrong for this test, which +executes nothing -- it reads two files. The over-gating already bites that module's own +``test_both_installers_carry_the_same_refusal`` (:164), a pure two-``read_text`` substring comparison +skipped for want of a shell it never invokes -- so this is an observed cost, not a hypothetical one. +Hosting this test there would attach the same unrelated skip to it, and on any box or CI leg without +PowerShell 7 +the parity check would silently stop running while the file still reported green. That is precisely the +skip-reads-as-pass failure this family of tests exists to remove, so it does not get to be introduced by +where the test was filed. The two modules also have opposite postures: that one is hermetic (tmp repos, +synthetic HOME, nothing real touched), this one reads the real ``~/.claude-hooks`` and is a LOCAL-MACHINE +test. ``test_gate_installed_parity.py`` and ``test_installed_coord_hooks.py`` are likewise one module per +installed mechanism; this is the third. + +LOCAL-MACHINE TESTS. CI installs nothing, so the parity assertion skips there and says so. **What CI +therefore does not guard is exactly this property** -- installed-vs-source parity is a developer-box +condition, not a repository one. The negative controls below DO run everywhere, because they exercise the +predicate against source bytes. + +Every test PRINTS what it compares BEFORE it can skip. A print placed after a skip never runs, and the +repo's pytest config carries no ``-rs`` (``addopts`` is ``--timeout=60 --timeout-method=thread``), so the +reason would not be shown either -- the run renders as a bare ``s`` and "nothing was compared" becomes +indistinguishable from "the comparison passed". + +This module NEVER runs ``install-selfheal.ps1`` and never writes to ``~``. The installer refuses under +``CLAUDECODE`` by design, its remedy is a plain-terminal human act, and the payload it manages is read by +every other session on the box. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "scripts" / "worktree" / "worktree-selfheal.ps1" +INSTALLER = ROOT / "scripts" / "worktree" / "install-selfheal.ps1" + +# The default install location is PARSED out of the installer, not restated here. A test carrying its own +# copy of a path cannot notice the installer moving away from it: it would go on comparing the file at the +# old location -- which stays in sync forever, because nothing writes to it any more -- and report parity +# for a payload nothing runs. Same reasoning as test_installed_coord_hooks.MARKERS/PAYLOADS. +# +# Line-anchored (``(?m)^\s*``) so a commented-out or illustrative line cannot become the source of truth: +# ``re.search`` takes the first hit anywhere in the file and a ``#`` prefix would otherwise satisfy it. +_HOOK_PATH_DEFAULT = re.search( + r"(?m)^\s*if \(-not \$HookPath\).*Join-Path \$homeDir '([^']+)'", + INSTALLER.read_text(encoding="utf-8"), +) +INSTALLER_DEFAULT_REL: str | None = _HOOK_PATH_DEFAULT.group(1) if _HOOK_PATH_DEFAULT else None + +# A SessionStart command names its script as a double-quoted, single-quoted, or bare path. All four +# commands wired on this box today are double-quoted and install-selfheal.ps1:96 only ever emits that +# form, so the other two branches are hardening rather than a live need. +# +# The single-quote branch is NOT redundant with the bare branch, which is the trap: without it, +# `-File 'C:\q\worktree-selfheal.ps1'` matches on the BARE alternative and carries the leading +# apostrophe into the path, which then never resolves. A non-existent path is filtered out downstream as +# "nothing installed here" -- so a config dir running a single-quoted, non-default copy would drop out of +# the comparison entirely and the test would pass on the installer default alone, auditing a file that is +# not the one executing. That is exactly the substitution wired_payloads() exists to prevent. +_PAYLOAD_IN_COMMAND = re.compile( + r'"([^"]*worktree-selfheal\.ps1)"' + r"|'([^']*worktree-selfheal\.ps1)'" + r"|(\S*worktree-selfheal\.ps1)" +) + + +def content_hash(data: bytes) -> str: + """SHA-256 of the payload's CONTENT: raw bytes with CRLF folded to LF. + + Deliberately the SAME basis as ``test_gate_installed_parity.content_hash`` and + ``test_installed_coord_hooks.content_hash``. The reasoning and the measurements behind choosing + content over bytes are stated once, in the first of those; the consequence here is the same + mechanism -- ``install-selfheal.ps1`` copies the payload with ``Copy-Item`` (:57), which translates + nothing, so the installed file carries whatever line endings the checkout that installed it had. On a + box with ``core.autocrlf=true`` a byte-exact digest therefore reports a difference that ``git status`` + reports as clean, about a file with no content change at all, and the prescribed remedy for that false + red is a re-install -- the downgrade hazard, fired to fix nothing. + + Spelled out rather than imported from either sibling: there is no ``tests/__init__.py``, so importing + across test modules would bind this file's correctness to pytest's collection import mode. If this + fold ever diverges from those two, the divergence IS the bug; + ``test_the_selfheal_parity_check_still_detects_a_content_difference`` is what keeps the copy honest. + + WHAT IT GIVES UP: a difference of line endings ALONE becomes invisible. Nothing in this payload reads + its own bytes -- no embedded self-hash, no signature block -- and PowerShell parses both forms + identically, so no branch of the script can differ between them. Revisit here if that stops being + true. + """ + return hashlib.sha256(data.replace(b"\r\n", b"\n")).hexdigest() + + +def _settings_files() -> list[Path]: + """Every user-scope settings file that could carry a wired SessionStart hook.""" + return sorted( + p for d in Path.home().glob(".claude*") if d.is_dir() for p in d.glob("settings*.json") + ) + + +def wired_payloads() -> list[tuple[Path, Path]]: + """(settings file, payload path) for every SessionStart hook whose command names this script. + + Read from the WIRING rather than assumed from the installer default, because the wiring is what + actually executes. Deriving the target only from the default would answer an adjacent question -- "is + the file at the place the installer would write in sync" -- while a config dir pointing somewhere else + ran an unaudited copy. The installer default is unioned in below so the check still has a target on a + box where nothing is wired yet. + """ + found: list[tuple[Path, Path]] = [] + for f in _settings_files(): + try: + data = json.loads(f.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError): + continue + for group in (data.get("hooks") or {}).get("SessionStart") or []: + for h in group.get("hooks") or []: + cmd = str(h.get("command") or "") + for dquoted, squoted, bare in _PAYLOAD_IN_COMMAND.findall(cmd): + found.append((f, Path(dquoted or squoted or bare))) + return found + + +def installer_default_payload() -> Path | None: + """Where ``install-selfheal.ps1`` writes when ``-HookPath`` is not passed, i.e. how an operator runs it.""" + return Path.home() / INSTALLER_DEFAULT_REL if INSTALLER_DEFAULT_REL else None + + +def payload_targets() -> list[Path]: + """Every distinct payload path worth comparing: what is wired, plus where the installer would write. + + Deduplicated with ``os.path.normcase`` because these paths come from two sources that spell them + differently -- the wiring carries a literal Windows path with backslashes and whatever case the + installer interpolated, the default is composed from ``Path.home()``. On Windows those name the same + file; comparing it twice would double every diagnostic line for no added coverage. + """ + seen: dict[str, Path] = {} + for _f, p in wired_payloads(): + seen.setdefault(os.path.normcase(str(p)), p) + default = installer_default_payload() + if default is not None: + seen.setdefault(os.path.normcase(str(default)), default) + return [seen[k] for k in sorted(seen)] + + +def source_is_committed() -> bool: + """Assert parity only against a COMMITTED source. + + Mid-edit the installed copy is *supposed* to differ, and a check that goes red on every keystroke gets + deselected with ``-k`` and then deleted. Same posture as both sibling parity modules. + """ + try: + out = subprocess.run( + ["git", "status", "--porcelain", "--", SOURCE.relative_to(ROOT).as_posix()], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return False + return out.returncode == 0 and not out.stdout.strip() + + +def test_the_payload_target_was_derived_and_not_assumed() -> None: + """Guard the derivation. If the parse of the installer default breaks AND nothing is wired, + :func:`payload_targets` returns an empty list, the parity test below skips for want of a target, and + pytest renders that as a bare ``s`` -- no comparison made, no red, which is the exact ambiguity this + module exists to end. Fail loudly there instead.""" + print(f"source: {SOURCE}") + print(f"installer: {INSTALLER}") + print(f"default -HookPath parsed from the installer: {INSTALLER_DEFAULT_REL or 'PARSE FAILED'}") + assert INSTALLER_DEFAULT_REL, ( + f"no default -HookPath parsed out of {INSTALLER} -- the parity test then has no target unless a " + f"config dir happens to be wired, and reports a SKIP that reads like a pass" + ) + assert INSTALLER_DEFAULT_REL.endswith("worktree-selfheal.ps1"), ( + f"the parsed default points at {INSTALLER_DEFAULT_REL!r}, which is not the payload -- the regex " + f"matched the wrong line and the parity test would compare the wrong file" + ) + assert SOURCE.is_file(), ( + f"no committed source at {SOURCE} -- there is nothing to compare against" + ) + + +def test_the_installed_selfheal_payload_matches_the_committed_source() -> None: + """The assertion BACKLOG #1019 asked for: the payload that RUNS versus this checkout's source. + + It runs from a shared user-scope copy, so one stale file is stale for every config dir and every + session on the box at once, while the source-side tests stay green. + """ + targets = payload_targets() + # Announce everything scanned BEFORE any skip. The wiring lines are part of that: a config dir that + # wires nothing, or wires a path that is absent, is visible here and nowhere else in the suite. + print(f"source: {SOURCE}") + print(f"settings files scanned: {[str(p) for p in _settings_files()] or 'NONE'}") + for f, p in wired_payloads(): + print(f" wired by {f}: {p}") + for p in targets: + print(f"comparing: {p}{'' if p.is_file() else ' (ABSENT -- nothing installed here)'}") + + present = [p for p in targets if p.is_file()] + if not present: + pytest.skip( + "SKIP (nothing compared): no selfheal payload installed at any target printed above. The " + "backstop is installed per box by a human running install-selfheal.ps1 from a plain " + "terminal, and CI installs none -- so on CI this is honest, and on a developer box it means " + "the SessionStart backstop is not present at all." + ) + if not source_is_committed(): + pytest.skip( + f"SKIP (nothing compared): {SOURCE.relative_to(ROOT).as_posix()} has uncommitted changes -- " + f"the installed copy is SUPPOSED to differ mid-edit. Re-run after committing." + ) + + source_bytes = SOURCE.read_bytes() + source_hash = content_hash(source_bytes) + drifted: list[tuple[Path, str]] = [] + for p in present: + installed_bytes = p.read_bytes() + installed_hash = content_hash(installed_bytes) + # Print BOTH bases. The content hashes are what the assertion turns on; the raw byte digests and + # the eol-only flag are diagnostics that keep "differs in content" and "differs in line endings" + # distinguishable in the output. Collapsing them to one number is how the two got confused. + print( + f"compared (content, CRLF folded): installed={installed_hash[:12]} " + f"source={source_hash[:12]} [{p}]" + ) + print( + f" diagnostic raw bytes: installed={hashlib.sha256(installed_bytes).hexdigest()[:12]} " + f"source={hashlib.sha256(source_bytes).hexdigest()[:12]} " + f"line-endings-only difference=" + f"{installed_bytes != source_bytes and installed_hash == source_hash}" + ) + if installed_hash != source_hash: + drifted.append((p, installed_hash)) + + assert not drifted, ( + f"CONTENT DRIFT: the worktree-selfheal payload that RUNS is not this checkout's.\n" + f" source : {SOURCE} content={source_hash[:12]}\n" + + "".join(f" installed: {p} content={h[:12]}\n" for p, h in drifted) + + f"Line endings are folded out of this comparison, so CRLF vs LF alone cannot account for it. " + f"That does NOT make this a difference in code: the fold rewrites \\r\\n and nothing else, so at " + f"least a UTF-8 BOM on one side, a dropped final newline, and CR-only (lone \\r) endings all " + f"still trip this assertion. Diff the two files before concluding anything about which.\n" + f"Until the installed copy is replaced, edits to the source have NO EFFECT -- the hook keeps " + f"running the old logic in every config dir on this box -- and the rest of the suite still " + f"passes, because everything else drives the source.\n" + f"WORK OUT WHICH COPY IS OLDER FIRST. This is a USER-SCOPE file shared by every session on the " + f"box, so installing from a checkout older than the installed payload downgrades it everywhere, " + f"for every account, at once:\n" + f" git log --oneline -5 -- {SOURCE.relative_to(ROOT).as_posix()}\n" + f"Only once THIS checkout is confirmed the newer of the two, from a PLAIN terminal (the " + f"installer refuses under CLAUDECODE, deliberately):\n" + f" pwsh -NoProfile -File scripts\\worktree\\install-selfheal.ps1 -ConfigDir \n" + f"-ConfigDir is mandatory and takes ONE dir, but the payload is a single shared file: one run " + f"re-copies it for every config dir that references it, and further runs are only needed to wire " + f"a dir that is not wired yet." + ) + + +def test_the_selfheal_parity_check_still_detects_a_content_difference() -> None: + """NEGATIVE CONTROL for the assertion above, and the price of folding line endings out of it. + + The parity test is expected to PASS on this box today, and a pass proves nothing unless the predicate + can also fail. Folding is exactly the edit that can turn a false RED into a false GREEN -- a payload + that is genuinely stale, reported in sync -- so prove the weakened predicate still detects what it + exists to detect, and prove the tolerance is not vacuous while doing it. + + Exercised against the predicate on real SOURCE bytes, never by mutating the installed copy: that file + is user-scope, every config dir's SessionStart hook reads it, and a test may not take it out from + under a concurrent session. Same reasoning and shape as + ``test_gate_installed_parity.test_the_parity_check_still_detects_a_content_difference``. + """ + body = SOURCE.read_bytes().replace(b"\r\n", b"\n") + crlf = body.replace(b"\n", b"\r\n") + + # Guard the guard: identical encodings would make the tolerance assertion hold for a trivial reason + # and prove nothing about folding. + assert crlf != body, "the CRLF and LF encodings are identical -- this control would be vacuous" + print(f"eol probes differ in bytes: LF={len(body)} CRLF={len(crlf)}") + assert content_hash(crlf) == content_hash(body), ( + "re-encoding line endings changed the content hash -- the tolerance this module documents does " + "not actually hold" + ) + + added = body + b'\nWrite-Host "mf parity control probe"\n' + assert content_hash(added) != content_hash(body), ( + "appending a line did not change the content hash -- the parity check is decoration" + ) + + # The subtle end of the range: one operator inside the condition that decides whether the backstop is + # allowed to run `git checkout` on the primary. Flipping -eq to -ne turns "repair only a CLEAN tree" + # into "repair only a DIRTY one", which is the difference between a safe branch switch and running one + # unattended on a DIRTY tree. (Not "clobbering": `git checkout` refuses a switch that would overwrite + # conflicting local modifications and carries non-conflicting ones across. The hazard is real without + # being data loss, and overstating it here would be the same defect this module exists to catch.) + # It changes no line count and one character, so a size or line check is not a substitute for this. + # + # Anchored on `-and ... ) {` rather than the bare condition, and the count is ASSERTED rather than + # assumed: a raw text scan does not know code from a comment that quotes it, and `replace(..., 1)` + # silently takes the first hit either way. That is the defect this session fixed in the gate's own + # control, where the probe was mutating a comment while its output called it a rule. + guard = b"-and $dirty.Count -eq 0) {" + occurrences = body.count(guard) + assert occurrences == 1, ( + f"the clean-tree guard occurs {occurrences} times in the payload, not once. At 0 the condition " + f"was reworded and this probe now mutates nothing; above 1 it cannot say WHICH occurrence it " + f"hit. Fix this control." + ) + flipped = body.replace(guard, b"-and $dirty.Count -ne 0) {", 1) + assert content_hash(flipped) != content_hash(body), ( + "a one-character edit to the clean-tree guard did not change the content hash" + ) + print( + "content differences still detected: line appended, and one character changed in the clean-tree " + "guard -- the code, not a comment quoting it" + ) + + +def test_folding_crlf_does_not_hide_a_bom_a_lost_newline_or_cr_only_endings() -> None: + """The failure text above tells a reader that AT LEAST three non-code causes still trip the + assertion. That is a claim the reader will act on -- it is what stops them treating every red as + staleness and reaching for a re-install -- so it is asserted here rather than left as prose. + + Deliberately NOT a completeness claim in either direction: this proves these three are still + detected, not that they are the only ones. + """ + body = SOURCE.read_bytes().replace(b"\r\n", b"\n") + base = content_hash(body) + + cases: list[tuple[str, bytes]] = [ + ("UTF-8 BOM prepended", b"\xef\xbb\xbf" + body), + ("final newline dropped", body.rstrip(b"\n")), + ("CR-only line endings", body.replace(b"\n", b"\r")), + ] + for label, probe in cases: + assert probe != body, ( + f"{label}: the probe is identical to the source -- this case is vacuous" + ) + print(f"{label}: content={content_hash(probe)[:12]} vs source={base[:12]}") + assert content_hash(probe) != base, ( + f"{label} did not change the content hash -- the failure text claims this case still trips " + f"the parity assertion, and it does not" + )