From 0e1549d4c7f5be7c77b488022a0b3114ef90c8fc Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Tue, 4 Aug 2026 22:26:32 -0500 Subject: [PATCH 1/3] test(gate): judge launcher config dirs by name shape, and report the two gaps that leaves config_dirs() globbed .claude-account-*, which matched ~/.claude-account-2.lock. The tests passed only because that directory carried a full copy of account-2's gate wiring -- and it carried it because install-gate.ps1:91 globs .claude-account-* too and wired both dirs in lockstep. So the suite was reading back, as evidence, wiring that this same machinery had written into an artifact nobody launches from. The two globs agreed because both are wrong the same way. It passes today and is a stale snapshot: when real wiring changes the .lock will not follow, the suite goes red pointing at a directory nobody uses, and the reader's next move is a re-install -- the stale-checkout DOWNGRADE hazard content_hash documents, fired to fix nothing. Replaced with a positive name shape (\A\.claude-account-\d+\Z) rather than a .lock blocklist, which would exclude the one artifact that exists and admit .bak/.old/-copy next time. The launchers BUILD that path, so the shape is checkable against them, not inferred from a listing. Anchors live in the pattern: unanchored, a later call site using match() re-admits the artifact on its prefix and every test stays green. Excluding dirs from a wiring check can hide an un-wired launcher, so the risk is guarded rather than just admitted. Exclusions are PRINTED by both scanning tests in the run that dropped them, and test_nothing_excluded_from_the_wiring_scan_is_a_live_login asserts each is inert using login markers Claude Code writes itself -- a signal independent of the name the exclusion turns on, so the guard cannot agree with the thing it guards by construction. Both were proved able to fire. Also reports a SECOND, pre-existing gap this change did not cause and does not close: config_dirs() has always ended with a settings.json filter, which silently drops a launcher-shaped dir that has none. Such a dir has no PreToolUse wiring at all, so the gate does not run there -- strictly worse than the stale-snapshot case above. .claude-account-4 is in that state today. Reported, not asserted: whether a profile should be wired is the box owner's call, and a test that goes red over a machine-configuration choice is the crying-wolf failure this suite exists to avoid. --- tests/test_gate_installed_parity.py | 230 +++++++++++++++++++++++++++- 1 file changed, 228 insertions(+), 2 deletions(-) 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.""" From db00bd0afddadd12f53ce7181ce5fdba29e86677 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Tue, 4 Aug 2026 22:44:16 -0500 Subject: [PATCH 2/3] test(selfheal): assert the installed backstop payload matches the committed source The selfheal hook runs from an installed copy at ~/.claude-hooks/worktree-selfheal.ps1, and nothing compared it to source. Measured this session: it was drifted by 1376 folded bytes, and the only reason anyone noticed is that a human went looking. A check ADDED to the source had no effect until someone re-installed; a check DELETED from it kept firing; and tests/test_worktree_selfheal_wiring.py stayed correctly green throughout, because it drives the SOURCE script. This closes that (the gap filed as backlog 1019 -- ledger row is the coordinator's, which holds the allocation). Compared as CRLF-folded CONTENT on the basis 32d0cef9 established: install-selfheal.ps1:57 is a Copy-Item, which translates nothing, so the installed copy carries whatever line endings the installing checkout had and raw bytes answer an adjacent question. The target is derived from the WIRING -- every SessionStart command in every ~/.claude*/settings*.json naming the script -- unioned with the installer's parsed default, not restated. Comparing only the default would answer "is the file the installer WOULD write in sync" while a config dir pointing elsewhere ran an unaudited copy. The regex accepts double-quoted, single-quoted and bare paths: with no single-quote branch, that form matches the BARE alternative, carries the apostrophe into the path, fails to resolve, and drops out as "nothing installed here" -- the exact silent substitution the wiring-derived target exists to prevent. Separate module rather than test_worktree_selfheal_wiring.py, which is gated module-wide on pwsh because most of its tests run a subprocess. This one executes nothing. Hosting it there would let the parity check stop running on any box without PowerShell 7 while the file still reported green. That over-gating is already observable there: test_both_installers_carry_the_same_refusal is a pure text comparison skipped for want of a shell it never invokes. Ships with its negative controls, because a passing parity check proves nothing unless it can fail. Verified by mutating the PREDICATE, never the installed file (user-scope, read by every session): a constant hash, a byte-exact hash, a truncating hash and a CR-blind hash each make the controls fire, and the tolerance leg proves the fold is doing work rather than being inert. --- tests/test_selfheal_installed_parity.py | 372 ++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 tests/test_selfheal_installed_parity.py 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" + ) From 1a47d5d6b8633876efcfd286145d1e8af8d6aaaa Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Tue, 4 Aug 2026 23:09:29 -0500 Subject: [PATCH 3/3] backlog: #1019 flips to PARTLY LANDED -- the instrument ships, the installer-side half does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shipped: tests/test_selfheal_installed_parity.py asserts the installed backstop payload 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 -- weakening a security control under a broad task bundle that never named it, and 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 rather than a gap, and the observability lands on the pytest side, which needs no privilege. install-selfheal.ps1 is byte-unchanged -- verified independently with `git diff --quiet origin/main..HEAD -- scripts/worktree/install-selfheal.ps1` rather than taken on report. The banner stays OPEN and says so twice, because the item's own body lists four absent things and only one of them is now present. Census is unchanged at 105 for the same reason: nothing closed. ONE BANNER, not two. The first attempt left a 🚧 and a 🔢 in the same blockquote block. backlog_status_check.py passed it -- it checks that an item declares exactly one STATUS (open versus closed) and two OPEN banners are still one status -- so the gate is not the guard here; the project's one-banner-per-item invariant is, and #328 is the precedent for carrying partial-landing detail inside a single banner. --- docs/BACKLOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.