From 0bd5d2d8e28e3d5b3cf75e9cd62e2cc54a6efc51 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 17:53:01 -0700 Subject: [PATCH 01/11] Stamp Each Host With What It Carries, and Reject the Flags the Installer Ignored Rolling the safety kit to a fleet needs an answer to "is this machine current", and there was none. The checklist in #365 tracked machines by tick, a tick records that someone ran something once, and the kit has changed three times since the earliest one. Verification meant typing three grep commands per host and reading them by eye. Each run now writes ~/.claude/agent-safety-stamp.json naming the host, the hub commit it installed from, a digest of the bytes it installed, and the marker versions actually found in CLAUDE.md afterwards. It prints the same thing as one pasteable line. `--report` answers the question read-only, from a fresh hub checkout, comparing that machine against that checkout: CURRENT, STALE with the reasons, or NOT INSTALLED. It returns before creating anything, so a report on a clean machine leaves it clean. The comparison is on the payload digest rather than the commit. A machine installed from an older commit whose kit bytes never changed is current, and calling it stale sends someone to re-run an installer that would write the same file. The digest is taken over the installed bytes for the same reason: a clean commit and a dirty checkout install different content under the same SHA, and a dirty install is recorded as such. Blocks are read back off disk rather than assumed from what was written, so a block edited or deleted by hand since the install reports stale. A start marker without its end does not count as present, which is the half-written case a presence check reads as success. `main()` also took no arguments while both wrappers passed `"$@"` through, so every flag was silently discarded and `install.py --help` performed a full install instead of printing usage. It now parses, and an unknown flag exits 2 having changed nothing. test_install.py proves each verdict by reintroducing the state it reports, including that the printed remedy clears it, and that every file the kit deploys is covered by the digest. Wired into the self-test step, since a test that runs nowhere is the defect this repo just found in another gate. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/validate-task.yml | 1 + host-setup/agent-safety/install.py | 189 +++++++++++++++++++++++- host-setup/agent-safety/test_install.py | 167 +++++++++++++++++++++ 3 files changed, 349 insertions(+), 8 deletions(-) create mode 100644 host-setup/agent-safety/test_install.py diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index a26b53bf..1194ed60 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -64,6 +64,7 @@ jobs: python3 scripts/test_pr_review.py python3 spec/audit.py --selftest python3 host-setup/agent-safety/gh-write-guard.py --selftest + python3 host-setup/agent-safety/test_install.py - name: Check repo gates step run: python3 scripts/repo_gate.py diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py index 8594ad54..eb4b6d61 100644 --- a/host-setup/agent-safety/install.py +++ b/host-setup/agent-safety/install.py @@ -6,19 +6,39 @@ update in place), and self-tests the hook before registering it. The bash and PowerShell wrappers both call this, so every OS runs one tested code path. +Every run records a stamp at ~/.claude/agent-safety-stamp.json naming the machine, what was +installed, and the hub commit it came from, so a fleet rollout can be tracked from the hosts +rather than from memory. `--report` reads that stamp against this checkout and answers whether +the machine is current, without changing anything. + Usage: python3 install.py (installs to ~/.claude) + python3 install.py --report (read-only: is this machine current?) CLAUDE_HOME=/x python3 install.py (override target, for testing) """ +import argparse +import datetime +import hashlib import json import os import pathlib +import platform import re import shutil +import socket import subprocess import sys HERE = pathlib.Path(__file__).resolve().parent +# The stamp's own format version, separate from the content it describes. +# A reader that predates a field needs to know the shape changed rather than infer it from a missing key. +STAMP_VERSION = 1 + +# The files whose bytes this kit actually places on a machine. +# The digest is taken over these rather than over the commit, since it is the content that runs. +# A clean commit and a dirty checkout install different bytes while reporting the same SHA. +PAYLOAD_FILES = ("gh-write-guard.py", "claude-md-safety.md", "claude-md-fleet.md") + # Distinguishes an absent key from one holding an explicit null, which `dict.get` reports alike. # The two need different answers, since a gap is filled and a null is a settings error. MISSING = object() @@ -66,7 +86,153 @@ def hook_launcher(): return sys.executable +def host_facts(): + """Name and kind of this machine, enough to tell one host in the fleet from another. + + The distro is read from /etc/os-release rather than from `platform`, which reports the kernel + and cannot tell Debian from Ubuntu. WSL is named because it is a distinct rollout target that + otherwise reports as the Linux it runs. + """ + facts = {"hostname": socket.gethostname(), "system": platform.system(), "release": platform.release()} + osr = pathlib.Path("/etc/os-release") + if osr.exists(): + fields = {} + for line in osr.read_text(encoding="utf-8", errors="replace").splitlines(): + key, sep, value = line.partition("=") + if sep: + fields[key] = value.strip().strip('"') + if fields.get("PRETTY_NAME"): + facts["distro"] = fields["PRETTY_NAME"] + if "microsoft" in platform.release().lower(): + facts["wsl"] = True + return facts + + +def source_ref(): + """The hub commit this installer is running from, and whether the tree is dirty. + + A dirty tree is reported rather than hidden: the SHA still names a commit, but the bytes + installed are not that commit's, and a stamp that claims otherwise is the thing this exists + to prevent. A checkout that is not a git tree at all (an extracted tarball) says so. + """ + def git(*args): + r = subprocess.run(["git", "-C", str(HERE), *args], capture_output=True, text=True) + return r.stdout.strip() if r.returncode == 0 else None + + sha = git("rev-parse", "HEAD") + if not sha: + return {"vcs": "none"} + ref = {"vcs": "git", "commit": sha} + branch = git("rev-parse", "--abbrev-ref", "HEAD") + if branch and branch != "HEAD": + ref["branch"] = branch + status = git("status", "--porcelain", "--", *PAYLOAD_FILES) + ref["dirty"] = bool(status) + return ref + + +def payload_digest(): + """One digest over the bytes this kit installs, in a fixed order. + + Fixed order because a set of files has none, and a digest that depends on directory listing + order reports drift on a machine where nothing changed. + """ + h = hashlib.sha256() + for name in PAYLOAD_FILES: + h.update((HERE / name).read_bytes()) + return h.hexdigest()[:16] + + +def blocks_present(claude_md): + """The marker version of each block actually in CLAUDE.md, by name. + + Read from the file rather than from what the installer meant to write, since the question the + stamp answers is what is on the machine. + """ + if not claude_md.exists(): + return {} + text = claude_md.read_text(encoding="utf-8", errors="replace") + found = {} + for marker in ("agent-safety", "fleet-bootstrap"): + # A start marker alone is a half-written block, which a presence check reads as installed. + starts = re.findall(rf"", text) + ends = re.findall(rf"", text) + if starts and starts == ends: + found[marker] = starts[0] + return found + + +def build_stamp(claude_home, installed): + """The record written to the machine after an install, or computed live for a report.""" + return { + "stampVersion": STAMP_VERSION, + "host": host_facts(), + "source": source_ref(), + "payloadDigest": payload_digest(), + "blocks": blocks_present(claude_home / "CLAUDE.md"), + "installedUtc": installed, + } + + +def stamp_line(stamp): + """One line naming the machine and what it carries, short enough to paste into a checklist.""" + host = stamp["host"] + src = stamp["source"] + where = host.get("distro") or f"{host['system']} {host['release']}" + if host.get("wsl"): + where += " (WSL)" + commit = src.get("commit", "unknown")[:7] + ("-dirty" if src.get("dirty") else "") + blocks = ", ".join(f"{k} {v}" for k, v in sorted(stamp["blocks"].items())) or "none" + return f"{host['hostname']} | {where} | hub {commit} | payload {stamp['payloadDigest']} | {blocks} | {stamp['installedUtc']}" + + +def report(claude_home): + """Answer whether this machine matches this checkout, reading only. + + Compared on the payload digest rather than on the commit, because a machine installed from an + older commit whose kit bytes never changed is current, and reporting it as stale sends someone + to re-run an installer that would write the same file. + """ + path = claude_home / "agent-safety-stamp.json" + current = payload_digest() + print(f"This checkout: payload {current}, hub {source_ref().get('commit', 'unknown')[:7]}") + if not path.exists(): + print(f"NOT INSTALLED: no stamp at {path}") + print(" Run the installer with no arguments to install and stamp this machine.") + return 2 + try: + stamp = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + sys.stderr.write(f"Stamp at {path} is unreadable ({e}). Re-run the installer to rewrite it.\n") + return 2 + print(f"This machine: {stamp_line(stamp)}") + # The stamp says what was installed; the file says what is there now. + # A block edited or deleted by hand since the install makes both true and only the second current. + live = blocks_present(claude_home / "CLAUDE.md") + problems = [] + if stamp.get("payloadDigest") != current: + problems.append("payload digest differs from this checkout") + if live != stamp.get("blocks"): + problems.append(f"CLAUDE.md now holds {live or 'no blocks'}, where the stamp recorded {stamp.get('blocks') or 'none'}") + if stamp.get("source", {}).get("dirty"): + problems.append("installed from a dirty checkout, so the recorded commit does not identify the bytes") + if problems: + print("STALE:") + for p in problems: + print(f" - {p}") + print(" Re-run the installer with no arguments. It is idempotent.") + return 1 + print("CURRENT: this machine matches this checkout.") + return 0 + + def main(): + parser = argparse.ArgumentParser( + description="Install the agent write-safety kit, or report whether this machine is current.") + parser.add_argument("--report", action="store_true", + help="read-only: compare this machine's stamp against this checkout and exit") + args = parser.parse_args() + if sys.version_info < (3, 7): sys.stderr.write("This installer and the hook require Python 3.7+. Run it with python3.\n") return 1 @@ -78,6 +244,10 @@ def main(): settings = claude_home / "settings.json" claude_md = claude_home / "CLAUDE.md" + # Reported before anything is created, so a report on an uninstalled machine does not install it. + if args.report: + return report(claude_home) + print(f"Installing agent write-safety kit into: {claude_home}") hooks_dir.mkdir(parents=True, exist_ok=True) @@ -224,14 +394,17 @@ def reject(where, held, want): print(f" CLAUDE.md -> {claude_md} ({marker} block {action})") claude_md.write_bytes(existing.replace("\n", newline).encode("utf-8")) - print("\nDone. Verify:") - print(f" {launcher} \"{hook_dst}\" --selftest") - print(f" grep -c 'agent-safety v' \"{claude_md}\" # expect 2") - print(f" grep -c 'fleet-bootstrap v' \"{claude_md}\" # expect 2") - # One line per rule, matching the rule itself rather than a word inside it. - # A hint naming a fixed word would stop matching the moment a rule that lacks it is added. - for _, rule in MANAGED_PERMISSIONS: - print(f" grep -cF '{rule}' \"{settings}\" # expect 1") + # 5. Stamp the machine, written last so it records a completed install rather than an attempted one. + # The blocks are read back off disk here, so the stamp reports what CLAUDE.md holds rather than what was intended. + stamp_path = claude_home / "agent-safety-stamp.json" + stamp = build_stamp(claude_home, datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")) + stamp_path.write_text(json.dumps(stamp, indent=2) + "\n", encoding="utf-8") + print(f" stamp -> {stamp_path}") + + print("\nDone. This machine:") + print(f" {stamp_line(stamp)}") + print("\nRe-check at any time, from a fresh hub checkout, without changing anything:") + print(f" {launcher} \"{HERE / 'install.py'}\" --report") print("Restart Claude Code sessions on this machine so the hook and CLAUDE.md load.") return 0 diff --git a/host-setup/agent-safety/test_install.py b/host-setup/agent-safety/test_install.py new file mode 100644 index 00000000..7af09595 --- /dev/null +++ b/host-setup/agent-safety/test_install.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Self-test for install.py, proving each stamp verdict by reintroducing the state it reports. + +Every case runs against a throwaway CLAUDE_HOME, never the invoking user's. The installer writes to +a real home by default, so a test that forgot the override would rewrite the developer's own kit. + +Standard library only, matching the rest of the gates, so CI needs no install step. +""" +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +HERE = pathlib.Path(__file__).resolve().parent +INSTALL = HERE / "install.py" + +sys.path.insert(0, str(HERE)) +import install # noqa: E402 + + +def run(home, *args): + """Invoke the installer as a subprocess, the way a host actually runs it.""" + env = dict(os.environ, CLAUDE_HOME=str(home)) + return subprocess.run([sys.executable, str(INSTALL), *args], + capture_output=True, text=True, env=env) + + +class StampCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmp, True) + self.home = pathlib.Path(self.tmp) / "claude" + self.stamp = self.home / "agent-safety-stamp.json" + self.md = self.home / "CLAUDE.md" + + def install(self): + r = run(self.home) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + return r + + +class TestReportVerdicts(StampCase): + def test_report_on_a_machine_that_never_installed_says_so_and_installs_nothing(self): + r = run(self.home, "--report") + self.assertEqual(r.returncode, 2) + self.assertIn("NOT INSTALLED", r.stdout) + # The report path returns before the directory is created, so a read-only check stays read-only. + self.assertFalse(self.home.exists()) + + def test_install_then_report_is_current(self): + self.install() + self.assertTrue(self.stamp.exists()) + r = run(self.home, "--report") + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertIn("CURRENT", r.stdout) + + def test_a_changed_payload_reports_stale(self): + self.install() + target = HERE / "claude-md-safety.md" + original = target.read_bytes() + self.addCleanup(target.write_bytes, original) + target.write_bytes(original + b"\n\n") + r = run(self.home, "--report") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("payload digest differs", r.stdout) + + def test_a_block_deleted_by_hand_reports_stale(self): + self.install() + text = self.md.read_text(encoding="utf-8") + self.md.write_text( + re.sub(r".*?", + "", text, flags=re.DOTALL), encoding="utf-8") + r = run(self.home, "--report") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("CLAUDE.md now holds", r.stdout) + + def test_reinstalling_clears_a_stale_verdict(self): + """The remedy the report prints has to actually work, or the verdict is a dead end.""" + self.install() + text = self.md.read_text(encoding="utf-8") + self.md.write_text(re.sub(r".*?", + "", text, flags=re.DOTALL), encoding="utf-8") + self.assertEqual(run(self.home, "--report").returncode, 1) + self.install() + self.assertEqual(run(self.home, "--report").returncode, 0) + + +class TestArgumentHandling(StampCase): + def test_an_unknown_flag_is_rejected_rather_than_ignored(self): + """The defect this closes: main() took no arguments, so the wrappers' pass-through was + discarded and `install.py --help` performed a full install instead of printing usage.""" + self.install() + before = self.stamp.read_text(encoding="utf-8") + r = run(self.home, "--bogus") + self.assertEqual(r.returncode, 2) + self.assertIn("unrecognized arguments", r.stderr) + self.assertEqual(self.stamp.read_text(encoding="utf-8"), before) + + def test_help_prints_usage_and_installs_nothing(self): + r = run(self.home, "--help") + self.assertEqual(r.returncode, 0) + self.assertIn("--report", r.stdout) + self.assertFalse(self.home.exists()) + + +class TestBlocksPresent(StampCase): + def test_a_half_written_block_does_not_count_as_present(self): + """A start marker with no end is the failure a presence check reads as success.""" + self.install() + text = self.md.read_text(encoding="utf-8") + self.md.write_text(re.sub(r"", "", text), encoding="utf-8") + found = install.blocks_present(self.md) + self.assertNotIn("agent-safety", found) + self.assertIn("fleet-bootstrap", found) + + def test_an_absent_file_yields_no_blocks_rather_than_raising(self): + self.assertEqual(install.blocks_present(self.home / "nothing.md"), {}) + + +class TestStampContent(StampCase): + def test_the_stamp_names_the_machine_the_source_and_what_was_installed(self): + self.install() + stamp = json.loads(self.stamp.read_text(encoding="utf-8")) + self.assertEqual(stamp["stampVersion"], install.STAMP_VERSION) + self.assertTrue(stamp["host"]["hostname"]) + self.assertTrue(stamp["payloadDigest"]) + self.assertEqual(stamp["blocks"], {"agent-safety": "v1", "fleet-bootstrap": "v1"}) + # Recorded from a real hub checkout, so the commit is present rather than the tarball fallback. + self.assertIn(stamp["source"]["vcs"], ("git", "none")) + + def test_the_digest_covers_every_file_the_kit_installs(self): + """A file added to the kit but left out of the digest is drift the report cannot see.""" + baseline = install.payload_digest() + for name in install.PAYLOAD_FILES: + target = HERE / name + original = target.read_bytes() + try: + target.write_bytes(original + b"\n") + self.assertNotEqual(install.payload_digest(), baseline, + f"{name} is in PAYLOAD_FILES but changing it did not move the digest") + finally: + target.write_bytes(original) + + def test_every_deployed_file_is_in_the_digest(self): + """The inverse: the kit copies gh-write-guard.py and both snippets, and each must be covered.""" + source = INSTALL.read_text(encoding="utf-8") + for name in re.findall(r'HERE / "([^"]+\.(?:py|md))"', source): + if name == "install.py": + continue + self.assertIn(name, install.PAYLOAD_FILES, + f"install.py reads {name} but PAYLOAD_FILES omits it, so the digest misses it") + + def test_the_one_line_summary_names_the_host_and_the_commit(self): + self.install() + stamp = json.loads(self.stamp.read_text(encoding="utf-8")) + line = install.stamp_line(stamp) + self.assertIn(stamp["host"]["hostname"], line) + self.assertIn(stamp["payloadDigest"], line) + + +if __name__ == "__main__": + unittest.main(verbosity=1) From f938b0ec7d0142c8be71202279123f25ca2da83c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 17:55:36 -0700 Subject: [PATCH 02/11] Pin the New Test to LF, and Name It in the Line That Enumerates the Pins `test_install.py` carries a shebang, and `.gitattributes` pins each shebang-executable `.py` by path rather than by glob, so a new one is unspecified to git until it is listed. The repo gate's shebang floor caught it. `.editorconfig` already covered it, matching `host-setup/agent-safety/*.py` as a glob. The two files disagreeing that way is why the gate exists: the editor would have written LF while git enforced nothing. The comment above the block enumerates what the pins cover and said "the agent-safety hook and its installer", which stopped being the whole list the moment the installer gained tests. Co-Authored-By: Claude Opus 5 (1M context) --- .gitattributes | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 53f037cc..5badab2a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,13 +18,14 @@ catalog/snippets/husky/pre-commit text eol=lf # Vanilla `.py` follows the CRLF default, since Python's universal newlines accept CRLF and it is commonly edited on Windows. # Pin LF only for a `.py` executed directly via its shebang, by path. -# Those are the CI validation entry point, the fleet-audit runner, the agent-safety hook and its installer, and the repo lint and review scripts with their tests. +# Those are the CI validation entry point, the fleet-audit runner, the agent-safety hook and its installer with the installer's tests, and the repo lint and review scripts with their tests. # Do not re-add a blanket `*.py text eol=lf`. spec/validate.py text eol=lf spec/audit.py text eol=lf spec/fidelity_honesty.py text eol=lf host-setup/agent-safety/gh-write-guard.py text eol=lf host-setup/agent-safety/install.py text eol=lf +host-setup/agent-safety/test_install.py text eol=lf scripts/prose_lint.py text eol=lf scripts/repo_gate.py text eol=lf scripts/pr_review.py text eol=lf From 6583586eaeb6193df83ffbb9c609fcbbdf47193f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 18:04:54 -0700 Subject: [PATCH 03/11] Compare the Installed Bytes, Not Just the Markers That Delimit Them Four review findings, all real. `--report` compared marker versions and the source payload, and never what is actually on the machine. A block edited between its own markers left the version untouched and reported CURRENT, which is most of what "has someone weakened this by hand" means. The deployed hook is not marker-delimited at all, so a modified or deleted one was invisible the same way. The installed bytes are now digested and compared against what this checkout would write: the hook, and each block as it appears in CLAUDE.md. Line endings are normalized first, so a machine holding identical text with CRLF is current rather than drifted. `blocks_present` accepted any equal number of start and end markers, so a duplicated block reported present and named the first version while the second silently governed. It now requires exactly one pair. `source_ref` let FileNotFoundError escape when git is absent, crashing both the install and the read-only report on exactly the minimal host a tarball install targets. It records `vcs: none` instead. `report` read a parsed stamp straight into `stamp_line`, so a hand-edited or partially written file raised KeyError rather than returning a verdict. Required keys are checked first. Ten cases added. Nine fail against the previous code. Co-Authored-By: Claude Opus 5 (1M context) --- host-setup/agent-safety/install.py | 66 ++++++++++++++++- host-setup/agent-safety/test_install.py | 97 +++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py index eb4b6d61..133710fd 100644 --- a/host-setup/agent-safety/install.py +++ b/host-setup/agent-safety/install.py @@ -116,7 +116,12 @@ def source_ref(): to prevent. A checkout that is not a git tree at all (an extracted tarball) says so. """ def git(*args): - r = subprocess.run(["git", "-C", str(HERE), *args], capture_output=True, text=True) + # A host with no git is the normal case for a tarball install, and it is not an error here. + # Letting FileNotFoundError escape would crash both the install and the read-only report. + try: + r = subprocess.run(["git", "-C", str(HERE), *args], capture_output=True, text=True) + except OSError: + return None return r.stdout.strip() if r.returncode == 0 else None sha = git("rev-parse", "HEAD") @@ -155,13 +160,49 @@ def blocks_present(claude_md): found = {} for marker in ("agent-safety", "fleet-bootstrap"): # A start marker alone is a half-written block, which a presence check reads as installed. + # Exactly one pair, since the installer writes one and a duplicate is a corrupted file. + # Two blocks mean the second silently governs, and reporting the first as current hides that. starts = re.findall(rf"", text) ends = re.findall(rf"", text) - if starts and starts == ends: + if len(starts) == 1 and starts == ends: found[marker] = starts[0] return found +def installed_digest(claude_home): + """A digest over the bytes actually on this machine, or None where the kit is not fully there. + + Markers and versions answer whether a block is present, and nothing about its content, so a + block edited between its own markers reports current under a presence check. The hook is not + marker-delimited at all, so a modified or deleted one is invisible the same way. + + Line endings are normalized first: CLAUDE.md keeps whatever endings it had, and a machine that + holds identical text with CRLF is current rather than drifted. + """ + hook = claude_home / "hooks" / "gh-write-guard.py" + claude_md = claude_home / "CLAUDE.md" + if not hook.is_file() or not claude_md.is_file(): + return None + h = hashlib.sha256() + h.update(hook.read_bytes().replace(b"\r\n", b"\n")) + text = claude_md.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n") + for marker in ("agent-safety", "fleet-bootstrap"): + found = re.search(rf".*?", text, re.DOTALL) + if not found: + return None + h.update(found.group(0).encode("utf-8")) + return h.hexdigest()[:16] + + +def expected_installed_digest(): + """The same digest computed from this checkout, naming what a run here would leave behind.""" + h = hashlib.sha256() + h.update((HERE / "gh-write-guard.py").read_bytes().replace(b"\r\n", b"\n")) + for filename in ("claude-md-safety.md", "claude-md-fleet.md"): + h.update((HERE / filename).read_text(encoding="utf-8").strip().replace("\r\n", "\n").encode("utf-8")) + return h.hexdigest()[:16] + + def build_stamp(claude_home, installed): """The record written to the machine after an install, or computed live for a report.""" return { @@ -169,11 +210,17 @@ def build_stamp(claude_home, installed): "host": host_facts(), "source": source_ref(), "payloadDigest": payload_digest(), + "installedDigest": installed_digest(claude_home), "blocks": blocks_present(claude_home / "CLAUDE.md"), "installedUtc": installed, } +# Checked before a stamp is read, so a hand-edited or older-format file gives a verdict rather than a traceback. +# A partial write produces valid JSON with keys missing. +STAMP_REQUIRED = ("host", "source", "payloadDigest", "blocks", "installedUtc") + + def stamp_line(stamp): """One line naming the machine and what it carries, short enough to paste into a checklist.""" host = stamp["host"] @@ -205,13 +252,26 @@ def report(claude_home): except (json.JSONDecodeError, OSError) as e: sys.stderr.write(f"Stamp at {path} is unreadable ({e}). Re-run the installer to rewrite it.\n") return 2 + # Valid JSON is not a usable stamp: a hand edit or an older format parses and then breaks the read. + missing = [k for k in STAMP_REQUIRED if k not in stamp] if isinstance(stamp, dict) else ["everything"] + if missing: + sys.stderr.write(f"Stamp at {path} is missing {', '.join(missing)}. " + "Re-run the installer to rewrite it.\n") + return 2 print(f"This machine: {stamp_line(stamp)}") - # The stamp says what was installed; the file says what is there now. + # The stamp says what was installed; the machine says what is there now. # A block edited or deleted by hand since the install makes both true and only the second current. live = blocks_present(claude_home / "CLAUDE.md") problems = [] if stamp.get("payloadDigest") != current: problems.append("payload digest differs from this checkout") + # Markers answer presence and say nothing about content, so the installed bytes are compared too. + # This is what catches a block edited between its own markers, and a modified or deleted hook. + live_installed = installed_digest(claude_home) + if live_installed is None: + problems.append("the deployed hook or CLAUDE.md is missing, so the kit is not fully installed") + elif live_installed != expected_installed_digest(): + problems.append("the installed content differs from what this checkout would write") if live != stamp.get("blocks"): problems.append(f"CLAUDE.md now holds {live or 'no blocks'}, where the stamp recorded {stamp.get('blocks') or 'none'}") if stamp.get("source", {}).get("dirty"): diff --git a/host-setup/agent-safety/test_install.py b/host-setup/agent-safety/test_install.py index 7af09595..4f99d6ed 100644 --- a/host-setup/agent-safety/test_install.py +++ b/host-setup/agent-safety/test_install.py @@ -122,6 +122,103 @@ def test_an_absent_file_yields_no_blocks_rather_than_raising(self): self.assertEqual(install.blocks_present(self.home / "nothing.md"), {}) +class TestInstalledContent(StampCase): + """Presence is not currency. These are the cases markers and versions cannot see.""" + + def test_a_block_edited_between_its_own_markers_reports_stale(self): + """The marker and version are untouched, so a presence check calls this machine current.""" + self.install() + text = self.md.read_text(encoding="utf-8") + edited = text.replace("", + "\nSomeone weakened this rule by hand.") + self.assertNotEqual(edited, text) + self.md.write_text(edited, encoding="utf-8") + # Presence is unchanged: the markers and versions still read exactly as before. + self.assertEqual(install.blocks_present(self.md), {"agent-safety": "v1", "fleet-bootstrap": "v1"}) + r = run(self.home, "--report") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("installed content differs", r.stdout) + + def test_a_modified_hook_reports_stale(self): + """The hook is not marker-delimited, so nothing else on this machine would notice.""" + self.install() + hook = self.home / "hooks" / "gh-write-guard.py" + hook.write_text(hook.read_text(encoding="utf-8") + "\n# neutered\n", encoding="utf-8") + r = run(self.home, "--report") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("installed content differs", r.stdout) + + def test_a_deleted_hook_reports_stale_rather_than_crashing(self): + self.install() + (self.home / "hooks" / "gh-write-guard.py").unlink() + r = run(self.home, "--report") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("not fully installed", r.stdout) + + def test_identical_content_with_crlf_is_current_rather_than_stale(self): + """CLAUDE.md keeps the endings it had, and a Windows host is not drifted for that alone.""" + self.install() + raw = self.md.read_bytes() + self.md.write_bytes(raw.replace(b"\n", b"\r\n")) + r = run(self.home, "--report") + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertIn("CURRENT", r.stdout) + + def test_reinstalling_clears_an_edited_block(self): + self.install() + text = self.md.read_text(encoding="utf-8") + self.md.write_text(text.replace("", + "\nedited"), encoding="utf-8") + self.assertEqual(run(self.home, "--report").returncode, 1) + self.install() + self.assertEqual(run(self.home, "--report").returncode, 0) + + +class TestDuplicateBlocks(StampCase): + def test_a_duplicated_block_is_not_reported_as_present(self): + """Two blocks mean the second silently governs, and naming the first hides that.""" + self.install() + text = self.md.read_text(encoding="utf-8") + block = re.search(r".*?", + text, re.DOTALL).group(0) + self.md.write_text(text + "\n" + block + "\n", encoding="utf-8") + self.assertNotIn("agent-safety", install.blocks_present(self.md)) + + def test_a_duplicated_block_reports_stale_rather_than_current(self): + self.install() + text = self.md.read_text(encoding="utf-8") + block = re.search(r".*?", + text, re.DOTALL).group(0) + self.md.write_text(text + "\n" + block + "\n", encoding="utf-8") + r = run(self.home, "--report") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + + +class TestDegradedEnvironments(StampCase): + def test_a_host_without_git_stamps_rather_than_crashing(self): + """A tarball install on a minimal host has no git, which is normal rather than an error.""" + env = dict(os.environ, CLAUDE_HOME=str(self.home), PATH="") + r = subprocess.run([sys.executable, str(INSTALL)], capture_output=True, text=True, env=env) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + stamp = json.loads(self.stamp.read_text(encoding="utf-8")) + self.assertEqual(stamp["source"]["vcs"], "none") + + def test_a_stamp_missing_required_keys_gives_a_verdict_rather_than_a_traceback(self): + self.install() + self.stamp.write_text(json.dumps({"stampVersion": 1}) + "\n", encoding="utf-8") + r = run(self.home, "--report") + self.assertEqual(r.returncode, 2) + self.assertIn("missing", r.stderr) + self.assertNotIn("Traceback", r.stderr) + + def test_a_stamp_holding_a_non_object_gives_a_verdict_rather_than_a_traceback(self): + self.install() + self.stamp.write_text("[]\n", encoding="utf-8") + r = run(self.home, "--report") + self.assertEqual(r.returncode, 2) + self.assertNotIn("Traceback", r.stderr) + + class TestStampContent(StampCase): def test_the_stamp_names_the_machine_the_source_and_what_was_installed(self): self.install() From 065bbe66cb6db270c4bc682c1e04c8331d6552e4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 18:06:13 -0700 Subject: [PATCH 04/11] Say Where These Gates Live, Now That Three of Seven Are Not in scripts/ The comment above the self-test step read "Each gate in scripts/", which stopped being the whole list when the write-guard self-test joined it and is wronger again with the installer's tests. The claim worth keeping is that each gate is proven by a case that reintroduces the fault it catches, which holds wherever the gate lives. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/validate-task.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index e536f937..d9a72c6e 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -60,7 +60,8 @@ jobs: done python3 spec/validate.py - # Each gate in scripts/ is proven by a case that reintroduces the fault it catches. + # Each gate here is proven by a case that reintroduces the fault it catches, wherever it lives. + # Three of these are host-setup/ rather than scripts/, since the agent-safety kit is gated the same way. # Standard library only, so no install step and no dependency to pin. # The audit engine self-test is offline, so it runs here rather than only on an owner sweep. # The write-guard self-test is offline too, and it otherwise runs only when a host installs the hook, which is where a regression in it would surface as a broken machine. From 6ca851e9408f572f5ed9c12a63ecfd020b834059 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 18:12:54 -0700 Subject: [PATCH 05/11] Digest What Gets Installed, Not the Bytes on Either Side of the Stripping Two more review findings, and the first exposed a redundancy worth removing. `payload_digest` hashed raw file bytes while the installer embeds each snippet with `.strip()`. A trailing newline therefore moved the digest while the installed block stayed identical, so `--report` said STALE and sent the operator to re-run an installer that would write the same file. A verdict whose remedy changes nothing is worse than no verdict. It now normalizes the way the installer writes: line endings first, then stripping for the snippets and not for the hook, which is copied byte for byte. That makes it identical to `expected_installed_digest`, added an hour ago for the installed-content comparison, so that function is gone and the one digest serves both. The order already matched, being the hook and then each block, which is why the two were the same function written twice. `test_the_digest_covers_every_file_the_kit_installs` appended a bare newline, which under the fix is correctly no change at all, so the test would have asserted the wrong thing. It appends a non-whitespace sentinel now. Two cases added around the boundary: trailing whitespace on a snippet is not drift, and an edit that does reach the installed block still is. Co-Authored-By: Claude Opus 5 (1M context) --- host-setup/agent-safety/install.py | 26 ++++++++++-------- host-setup/agent-safety/test_install.py | 35 +++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py index 133710fd..fc04bf6f 100644 --- a/host-setup/agent-safety/install.py +++ b/host-setup/agent-safety/install.py @@ -137,14 +137,25 @@ def git(*args): def payload_digest(): - """One digest over the bytes this kit installs, in a fixed order. + """One digest over the content this kit installs, normalized the way the installer writes it. + + Over raw bytes this reported drift a reinstall could not clear: the snippets are embedded with + `.strip()`, so a trailing newline moved the digest while the installed block stayed identical, + and the machine was told to re-run something that would write the same file. Line endings + normalize for the same reason. Fixed order because a set of files has none, and a digest that depends on directory listing - order reports drift on a machine where nothing changed. + order reports drift on a machine where nothing changed. The order matches the one + `installed_digest` reads, so the two are directly comparable: the hook, then each block. """ h = hashlib.sha256() for name in PAYLOAD_FILES: - h.update((HERE / name).read_bytes()) + raw = (HERE / name).read_bytes().replace(b"\r\n", b"\n") + # A snippet is embedded stripped, so trailing whitespace is not installed content. + # The hook is copied byte for byte, so nothing about it is stripped. + if name.endswith(".md"): + raw = raw.decode("utf-8").strip().encode("utf-8") + h.update(raw) return h.hexdigest()[:16] @@ -194,13 +205,6 @@ def installed_digest(claude_home): return h.hexdigest()[:16] -def expected_installed_digest(): - """The same digest computed from this checkout, naming what a run here would leave behind.""" - h = hashlib.sha256() - h.update((HERE / "gh-write-guard.py").read_bytes().replace(b"\r\n", b"\n")) - for filename in ("claude-md-safety.md", "claude-md-fleet.md"): - h.update((HERE / filename).read_text(encoding="utf-8").strip().replace("\r\n", "\n").encode("utf-8")) - return h.hexdigest()[:16] def build_stamp(claude_home, installed): @@ -270,7 +274,7 @@ def report(claude_home): live_installed = installed_digest(claude_home) if live_installed is None: problems.append("the deployed hook or CLAUDE.md is missing, so the kit is not fully installed") - elif live_installed != expected_installed_digest(): + elif live_installed != current: problems.append("the installed content differs from what this checkout would write") if live != stamp.get("blocks"): problems.append(f"CLAUDE.md now holds {live or 'no blocks'}, where the stamp recorded {stamp.get('blocks') or 'none'}") diff --git a/host-setup/agent-safety/test_install.py b/host-setup/agent-safety/test_install.py index 4f99d6ed..32623ba1 100644 --- a/host-setup/agent-safety/test_install.py +++ b/host-setup/agent-safety/test_install.py @@ -231,18 +231,49 @@ def test_the_stamp_names_the_machine_the_source_and_what_was_installed(self): self.assertIn(stamp["source"]["vcs"], ("git", "none")) def test_the_digest_covers_every_file_the_kit_installs(self): - """A file added to the kit but left out of the digest is drift the report cannot see.""" + """A file added to the kit but left out of the digest is drift the report cannot see. + + The sentinel is non-whitespace deliberately. A snippet is embedded stripped, so appending a + newline is not a change to installed content and this would assert the wrong thing. + """ baseline = install.payload_digest() for name in install.PAYLOAD_FILES: target = HERE / name original = target.read_bytes() try: - target.write_bytes(original + b"\n") + target.write_bytes(original + b"\n# sentinel\n") self.assertNotEqual(install.payload_digest(), baseline, f"{name} is in PAYLOAD_FILES but changing it did not move the digest") finally: target.write_bytes(original) + def test_trailing_whitespace_on_a_snippet_is_not_reported_as_drift(self): + """The installer strips a snippet before embedding it, so this changes nothing installed. + + Hashing raw bytes reported STALE here and sent the operator to re-run an installer that + would write the identical block. + """ + baseline = install.payload_digest() + target = HERE / "claude-md-safety.md" + original = target.read_bytes() + try: + target.write_bytes(original + b"\n\n") + self.assertEqual(install.payload_digest(), baseline) + finally: + target.write_bytes(original) + + def test_a_real_edit_to_a_snippet_is_still_reported(self): + """The normalization must not swallow a change that does reach the installed block.""" + baseline = install.payload_digest() + target = HERE / "claude-md-safety.md" + original = target.read_bytes() + try: + target.write_bytes(original.replace(b"", + b"Weakened by hand.\n")) + self.assertNotEqual(install.payload_digest(), baseline) + finally: + target.write_bytes(original) + def test_every_deployed_file_is_in_the_digest(self): """The inverse: the kit copies gh-write-guard.py and both snippets, and each must be covered.""" source = INSTALL.read_text(encoding="utf-8") From bf5abb0b3ebc8e18c523363023b7fabcd1c0ebaa Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 18:22:00 -0700 Subject: [PATCH 06/11] Validate the Stamp's Shape, Not Just Which Keys It Has The key check added last round passed `"source": "git"` and then raised inside the line that formats it, which is exactly the traceback it was added to prevent. Presence and shape are different questions, and a hand edit produces the second failure rather than the first. `stamp_problems` checks the type held under each required key and names every fault in reading order, so one run tells the operator all of what is wrong rather than the first thing. `stamp_line` is now total as well. Every read carries a fallback, so a formatter cannot raise even on a stamp the validator would have rejected. Belt and braces on purpose: the caller validates first, and a verdict about a broken stamp must not itself crash. Seven cases added, five of them subtests covering each required key holding the wrong type, plus four shapes asserting the formatter stays printable. All fail against the previous code. Co-Authored-By: Claude Opus 5 (1M context) --- host-setup/agent-safety/install.py | 51 +++++++++++++++++++------ host-setup/agent-safety/test_install.py | 22 +++++++++++ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py index fc04bf6f..b79ca088 100644 --- a/host-setup/agent-safety/install.py +++ b/host-setup/agent-safety/install.py @@ -221,20 +221,47 @@ def build_stamp(claude_home, installed): # Checked before a stamp is read, so a hand-edited or older-format file gives a verdict rather than a traceback. -# A partial write produces valid JSON with keys missing. -STAMP_REQUIRED = ("host", "source", "payloadDigest", "blocks", "installedUtc") +# Shape rather than presence: a partial write leaves keys missing, and a hand edit leaves a key holding the wrong type. +# A key check alone passes `"source": "git"` and then raises inside the line that formats it, which is the crash it was added to prevent. +STAMP_SHAPE = { + "host": dict, + "source": dict, + "payloadDigest": str, + "blocks": dict, + "installedUtc": str, +} + + +def stamp_problems(stamp): + """What makes this stamp unusable, in reading order, or an empty list where it is fine.""" + if not isinstance(stamp, dict): + return [f"its root is {type(stamp).__name__} where an object is required"] + out = [] + for key, want in STAMP_SHAPE.items(): + if key not in stamp: + out.append(f"{key} is missing") + elif not isinstance(stamp[key], want): + out.append(f"{key} is {type(stamp[key]).__name__} where {want.__name__} is required") + return out def stamp_line(stamp): - """One line naming the machine and what it carries, short enough to paste into a checklist.""" - host = stamp["host"] - src = stamp["source"] - where = host.get("distro") or f"{host['system']} {host['release']}" + """One line naming the machine and what it carries, short enough to paste into a checklist. + + Every read is total. The caller validates the shape first, and this stays printable anyway, + since a formatter that raises turns a verdict about a broken stamp into a traceback. + """ + host = stamp.get("host") or {} + src = stamp.get("source") or {} + where = host.get("distro") or f"{host.get('system', 'unknown')} {host.get('release', '')}".strip() if host.get("wsl"): where += " (WSL)" - commit = src.get("commit", "unknown")[:7] + ("-dirty" if src.get("dirty") else "") - blocks = ", ".join(f"{k} {v}" for k, v in sorted(stamp["blocks"].items())) or "none" - return f"{host['hostname']} | {where} | hub {commit} | payload {stamp['payloadDigest']} | {blocks} | {stamp['installedUtc']}" + commit = str(src.get("commit", "unknown"))[:7] + ("-dirty" if src.get("dirty") else "") + held = stamp.get("blocks") + blocks = ", ".join(f"{k} {v}" for k, v in sorted(held.items())) if isinstance(held, dict) and held else "none" + return (f"{host.get('hostname', 'unknown')} | {where} | hub {commit} | " + f"payload {stamp.get('payloadDigest', 'unknown')} | {blocks} | " + f"{stamp.get('installedUtc', 'unknown')}") def report(claude_home): @@ -257,9 +284,9 @@ def report(claude_home): sys.stderr.write(f"Stamp at {path} is unreadable ({e}). Re-run the installer to rewrite it.\n") return 2 # Valid JSON is not a usable stamp: a hand edit or an older format parses and then breaks the read. - missing = [k for k in STAMP_REQUIRED if k not in stamp] if isinstance(stamp, dict) else ["everything"] - if missing: - sys.stderr.write(f"Stamp at {path} is missing {', '.join(missing)}. " + problems = stamp_problems(stamp) + if problems: + sys.stderr.write(f"Stamp at {path} is unusable: {'; '.join(problems)}. " "Re-run the installer to rewrite it.\n") return 2 print(f"This machine: {stamp_line(stamp)}") diff --git a/host-setup/agent-safety/test_install.py b/host-setup/agent-safety/test_install.py index 32623ba1..d9464f4b 100644 --- a/host-setup/agent-safety/test_install.py +++ b/host-setup/agent-safety/test_install.py @@ -218,6 +218,28 @@ def test_a_stamp_holding_a_non_object_gives_a_verdict_rather_than_a_traceback(se self.assertEqual(r.returncode, 2) self.assertNotIn("Traceback", r.stderr) + def test_every_required_key_holding_the_wrong_type_gives_a_verdict(self): + """Presence is not shape. Each of these carries every key and crashes a key-only check.""" + self.install() + good = json.loads(self.stamp.read_text(encoding="utf-8")) + for key, bad in (("host", "server"), ("source", "git"), ("payloadDigest", 12), + ("blocks", ["agent-safety"]), ("installedUtc", None)): + with self.subTest(key=key): + broken = dict(good, **{key: bad}) + self.stamp.write_text(json.dumps(broken) + "\n", encoding="utf-8") + r = run(self.home, "--report") + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn(key, r.stderr) + self.assertNotIn("Traceback", r.stderr) + + def test_the_formatter_stays_printable_on_a_stamp_the_validator_would_reject(self): + """Belt and braces: a formatter that raises turns a verdict into the crash it reports on.""" + for broken in ({}, {"host": None, "source": None}, + {"host": {}, "source": {}, "blocks": None}, + {"host": {"hostname": "h"}, "source": {"commit": 12345}}): + with self.subTest(stamp=broken): + self.assertIsInstance(install.stamp_line(broken), str) + class TestStampContent(StampCase): def test_the_stamp_names_the_machine_the_source_and_what_was_installed(self): From dc0451a286454ecde6738e579ba7fa6960136e59 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 18:27:46 -0700 Subject: [PATCH 07/11] Check That the Kit Is Wired In, Not Only That Its Bytes Are Right `--report` compared the deployed hook and the CLAUDE.md blocks and never looked at settings.json, where the hook is registered and the permission rules live. A machine with every byte correct and the PreToolUse entry removed carries a complete, current, entirely inert kit, and every check here called it CURRENT. That is the worst verdict this tool can give, since the whole question it answers is whether the guard is in force on this machine. `registration_problems` now reads settings.json and reports an unregistered hook, a hook registered more than once, a missing managed permission rule, and a settings file that is absent or unreadable. `stampVersion` was written into every stamp, described in its own comment as the way a reader detects a format change, and never validated. A stamp from another format version now says so rather than being read as valid. Nine cases added, all failing against the previous code, including that re-running clears an unregistered hook. Co-Authored-By: Claude Opus 5 (1M context) --- host-setup/agent-safety/install.py | 44 ++++++++++++++ host-setup/agent-safety/test_install.py | 76 +++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py index b79ca088..2cba52a5 100644 --- a/host-setup/agent-safety/install.py +++ b/host-setup/agent-safety/install.py @@ -224,6 +224,7 @@ def build_stamp(claude_home, installed): # Shape rather than presence: a partial write leaves keys missing, and a hand edit leaves a key holding the wrong type. # A key check alone passes `"source": "git"` and then raises inside the line that formats it, which is the crash it was added to prevent. STAMP_SHAPE = { + "stampVersion": int, "host": dict, "source": dict, "payloadDigest": str, @@ -242,6 +243,47 @@ def stamp_problems(stamp): out.append(f"{key} is missing") elif not isinstance(stamp[key], want): out.append(f"{key} is {type(stamp[key]).__name__} where {want.__name__} is required") + # The version carries the format rather than the content, so a mismatch either way is unreadable. + # A newer stamp holds fields this code does not know, and an older one lacks fields it reads. + # Carrying the field and never checking it is the version telling nobody anything. + if stamp.get("stampVersion") not in (None, STAMP_VERSION) and isinstance(stamp.get("stampVersion"), int): + out.append(f"stampVersion is {stamp['stampVersion']} where this installer writes {STAMP_VERSION}") + return out + + +def registration_problems(claude_home): + """Whether settings.json still wires the kit in, which decides if any of it actually runs. + + The hook's bytes being correct says nothing about whether Claude Code invokes it. An entry + removed from settings.json leaves a machine carrying a complete, current, and entirely inert + kit, which every other check here reports as fine. + """ + settings = claude_home / "settings.json" + if not settings.is_file(): + return ["settings.json is missing, so the hook is not registered"] + try: + data = json.loads(settings.read_text(encoding="utf-8") or "{}") + except (json.JSONDecodeError, OSError) as e: + return [f"settings.json cannot be read ({e})"] + if not isinstance(data, dict): + return ["settings.json does not hold an object at its root"] + out = [] + groups = data.get("hooks", {}).get("PreToolUse") if isinstance(data.get("hooks"), dict) else None + registered = 0 + for group in groups or []: + if not isinstance(group, dict): + continue + for hook in group.get("hooks") or []: + if isinstance(hook, dict) and "gh-write-guard" in str(hook.get("command", "")): + registered += 1 + if registered == 0: + out.append("the PreToolUse hook is not registered in settings.json, so the guard never runs") + elif registered > 1: + out.append(f"the PreToolUse hook is registered {registered} times, so it runs more than once") + allow = data.get("permissions", {}).get("allow") if isinstance(data.get("permissions"), dict) else None + for _, rule in MANAGED_PERMISSIONS: + if not isinstance(allow, list) or rule not in allow: + out.append(f"the permission rule {rule} is absent from settings.json") return out @@ -303,6 +345,8 @@ def report(claude_home): problems.append("the deployed hook or CLAUDE.md is missing, so the kit is not fully installed") elif live_installed != current: problems.append("the installed content differs from what this checkout would write") + # Correct bytes on disk are not a running guard, so the wiring is checked as well. + problems.extend(registration_problems(claude_home)) if live != stamp.get("blocks"): problems.append(f"CLAUDE.md now holds {live or 'no blocks'}, where the stamp recorded {stamp.get('blocks') or 'none'}") if stamp.get("source", {}).get("dirty"): diff --git a/host-setup/agent-safety/test_install.py b/host-setup/agent-safety/test_install.py index d9464f4b..79cd96dd 100644 --- a/host-setup/agent-safety/test_install.py +++ b/host-setup/agent-safety/test_install.py @@ -241,6 +241,82 @@ def test_the_formatter_stays_printable_on_a_stamp_the_validator_would_reject(sel self.assertIsInstance(install.stamp_line(broken), str) +class TestRegistration(StampCase): + """Correct bytes on disk are not a running guard. These are the inert-kit cases.""" + + def _settings(self): + return json.loads((self.home / "settings.json").read_text(encoding="utf-8")) + + def _write(self, data): + (self.home / "settings.json").write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + def test_an_unregistered_hook_reports_stale_rather_than_current(self): + """Every byte is correct and the guard never runs, which every other check calls fine.""" + self.install() + data = self._settings() + data["hooks"]["PreToolUse"] = [] + self._write(data) + r = run(self.home, "--report") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("never runs", r.stdout) + + def test_a_removed_permission_rule_reports_stale(self): + self.install() + data = self._settings() + data["permissions"]["allow"] = [] + self._write(data) + r = run(self.home, "--report") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("permission rule", r.stdout) + + def test_a_duplicated_hook_registration_reports_stale(self): + self.install() + data = self._settings() + group = data["hooks"]["PreToolUse"][0] + group["hooks"].append(dict(group["hooks"][0])) + self._write(data) + r = run(self.home, "--report") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("more than once", r.stdout) + + def test_a_deleted_settings_file_reports_stale_rather_than_crashing(self): + self.install() + (self.home / "settings.json").unlink() + r = run(self.home, "--report") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertNotIn("Traceback", r.stderr) + + def test_reinstalling_clears_an_unregistered_hook(self): + self.install() + data = self._settings() + data["hooks"]["PreToolUse"] = [] + self._write(data) + self.assertEqual(run(self.home, "--report").returncode, 1) + self.install() + self.assertEqual(run(self.home, "--report").returncode, 0) + + +class TestStampVersion(StampCase): + def test_a_stamp_from_a_different_format_version_is_rejected(self): + """The field exists so a shape change is detectable, which needs it to be read.""" + self.install() + stamp = json.loads(self.stamp.read_text(encoding="utf-8")) + stamp["stampVersion"] = install.STAMP_VERSION + 1 + self.stamp.write_text(json.dumps(stamp) + "\n", encoding="utf-8") + r = run(self.home, "--report") + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn("stampVersion", r.stderr) + + def test_a_stamp_version_of_the_wrong_type_is_rejected(self): + self.install() + stamp = json.loads(self.stamp.read_text(encoding="utf-8")) + stamp["stampVersion"] = "1" + self.stamp.write_text(json.dumps(stamp) + "\n", encoding="utf-8") + r = run(self.home, "--report") + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn("stampVersion", r.stderr) + + class TestStampContent(StampCase): def test_the_stamp_names_the_machine_the_source_and_what_was_installed(self): self.install() From 23e80acf5d9405e0133ff93e5b1f81bf99ff5554 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 18:33:41 -0700 Subject: [PATCH 08/11] Judge Marker Corruption Against the File, and Collapse a Duplicate on Install An install onto an already-corrupted CLAUDE.md wrote the corruption into the stamp. `blocks_present` returns nothing for a duplicated or half-written block, the stamp records that same nothing, and every later run found them equal and reported CURRENT. Two wrong answers agreeing read as a match. `marker_corruption` judges the file alone. Markers present that yield no valid block are reported regardless of what the stamp recorded. The installer also could not clear it. `re.sub` replaced every match with the snippet, so a file arriving with two blocks kept two, and the remedy the report prints was a dead end for the one case that most needs it. It now keeps the first and drops the rest, and says how many it removed. Five cases added, all failing against the previous code, including that a stamp hand-edited to record no blocks cannot agree its way to a clean verdict. Co-Authored-By: Claude Opus 5 (1M context) --- host-setup/agent-safety/install.py | 33 +++++++++++++++- host-setup/agent-safety/test_install.py | 52 +++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py index 2cba52a5..d5a074a6 100644 --- a/host-setup/agent-safety/install.py +++ b/host-setup/agent-safety/install.py @@ -180,6 +180,24 @@ def blocks_present(claude_md): return found +def marker_corruption(claude_md): + """Markers present in the file that yield no valid block, meaning duplicated or half-written. + + Judged against the file alone, never against the stamp. An install onto an already-corrupted + CLAUDE.md records the same empty block set it reads, so the stamp and the file agree and the + corruption reads as a match. Two wrong answers agreeing is the failure this exists to catch. + """ + if not claude_md.is_file(): + return [] + text = claude_md.read_text(encoding="utf-8", errors="replace") + valid = blocks_present(claude_md) + out = [] + for marker in ("agent-safety", "fleet-bootstrap"): + if re.search(rf"", text) and marker not in valid: + out.append(f"the {marker} markers in CLAUDE.md are duplicated or incomplete") + return out + + def installed_digest(claude_home): """A digest over the bytes actually on this machine, or None where the kit is not fully there. @@ -347,6 +365,9 @@ def report(claude_home): problems.append("the installed content differs from what this checkout would write") # Correct bytes on disk are not a running guard, so the wiring is checked as well. problems.extend(registration_problems(claude_home)) + # Read from the file rather than compared against the stamp. + # An install onto a corrupted file writes the corruption into the stamp, and the two then agree. + problems.extend(marker_corruption(claude_home / "CLAUDE.md")) if live != stamp.get("blocks"): problems.append(f"CLAUDE.md now holds {live or 'no blocks'}, where the stamp recorded {stamp.get('blocks') or 'none'}") if stamp.get("source", {}).get("dirty"): @@ -522,7 +543,17 @@ def reject(where, held, want): snippet = (HERE / filename).read_text(encoding="utf-8").strip() block_re = re.compile(rf".*?", re.DOTALL) if block_re.search(existing): - existing, action = block_re.sub(lambda _: snippet, existing), "updated" + # Keep the first occurrence and drop any duplicate, rather than rewriting each in place. + # Substituting every match preserved the duplication, so a file arriving with two blocks kept two. + # The report's own remedy of re-running could then never clear it. + written = [] + + def once(_match, _snippet=snippet, _written=written): + _written.append(True) + return _snippet if len(_written) == 1 else "" + + existing = block_re.sub(once, existing) + action = "updated" if len(written) == 1 else f"updated, {len(written) - 1} duplicate(s) removed" else: sep = "" if existing == "" or existing.endswith("\n\n") else ("\n" if existing.endswith("\n") else "\n\n") existing, action = existing + sep + snippet + "\n", "appended" diff --git a/host-setup/agent-safety/test_install.py b/host-setup/agent-safety/test_install.py index 79cd96dd..1bcb5dac 100644 --- a/host-setup/agent-safety/test_install.py +++ b/host-setup/agent-safety/test_install.py @@ -296,6 +296,58 @@ def test_reinstalling_clears_an_unregistered_hook(self): self.assertEqual(run(self.home, "--report").returncode, 0) +class TestPreexistingCorruption(StampCase): + """A file corrupted before the install, where the stamp records the corruption and agrees.""" + + def _duplicate(self, marker="agent-safety"): + text = self.md.read_text(encoding="utf-8") + block = re.search(rf".*?", + text, re.DOTALL).group(0) + self.md.write_text(text + "\n" + block + "\n", encoding="utf-8") + + def test_installing_onto_a_duplicated_block_does_not_report_current(self): + """The stamp is built from the same empty block set the file yields, so both agree.""" + self.install() + self._duplicate() + # Install again: the stamp is now written from a file that already carries the duplicate. + self.install() + r = run(self.home, "--report") + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + # The install collapsed it, which is why this is CURRENT rather than a standing STALE. + self.assertEqual(install.blocks_present(self.md), {"agent-safety": "v1", "fleet-bootstrap": "v1"}) + + def test_the_installer_collapses_a_duplicate_rather_than_preserving_it(self): + """Substituting every match kept both blocks, so the printed remedy never worked.""" + self.install() + self._duplicate() + self.assertEqual(install.blocks_present(self.md), {"fleet-bootstrap": "v1"}) + self.install() + text = self.md.read_text(encoding="utf-8") + self.assertEqual(len(re.findall(r"", text)), 1) + + def test_markers_that_yield_no_valid_block_are_reported_regardless_of_the_stamp(self): + """A stamp recording no blocks must not agree its way into a clean verdict.""" + self.install() + self._duplicate() + stamp = json.loads(self.stamp.read_text(encoding="utf-8")) + stamp["blocks"] = {} + self.stamp.write_text(json.dumps(stamp) + "\n", encoding="utf-8") + r = run(self.home, "--report") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn("duplicated or incomplete", r.stdout) + + def test_a_half_written_block_is_reported_as_corruption(self): + self.install() + text = self.md.read_text(encoding="utf-8") + self.md.write_text(re.sub(r"", "", text), encoding="utf-8") + self.assertEqual(install.marker_corruption(self.md), + ["the agent-safety markers in CLAUDE.md are duplicated or incomplete"]) + + def test_a_clean_file_reports_no_corruption(self): + self.install() + self.assertEqual(install.marker_corruption(self.md), []) + + class TestStampVersion(StampCase): def test_a_stamp_from_a_different_format_version_is_rejected(self): """The field exists so a shape change is detectable, which needs it to be read.""" From 689414699ed1f2eb3131e8c93c4704edc22b620d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 18:40:43 -0700 Subject: [PATCH 09/11] Normalize a Bare CR Too, Through One Helper Both Digests Share `payload_digest` reduced CRLF and left a bare CR alone, while the installer reads a snippet in text mode, where a bare CR arrives as a newline and installs as one. A snippet carrying CR-only endings therefore installed normalized content and hashed to something else, and the machine was reported STALE against its own content. Four sites were doing this replacement by hand and two of them disagreed, which is the arrangement that produced the defect. They all call one helper now, so the two digests cannot normalize differently. Two cases added. The first builds the CR-only variant from the normalized form rather than by replacing newlines in the file, because these snippets are CRLF in this repository and a blind replace produces a doubled CR rather than a bare one. My first version of that test made exactly that mistake and failed against the fixed code. Co-Authored-By: Claude Opus 5 (1M context) --- host-setup/agent-safety/install.py | 21 +++++++++++++++++---- host-setup/agent-safety/test_install.py | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py index d5a074a6..3b9b0be7 100644 --- a/host-setup/agent-safety/install.py +++ b/host-setup/agent-safety/install.py @@ -136,6 +136,19 @@ def git(*args): return ref +def normalized(data): + """Line endings reduced to newlines, covering CRLF and a bare CR. + + One helper rather than a replace at each site. Both digests have to normalize identically or a + machine drifts on nothing, and a site that handled CRLF while missing CR did exactly that: the + installer reads a snippet in text mode, so a bare CR arrives as a newline and installs as one, + while a digest that left it alone reported the machine STALE against its own content. + """ + if isinstance(data, bytes): + return data.replace(b"\r\n", b"\n").replace(b"\r", b"\n") + return data.replace("\r\n", "\n").replace("\r", "\n") + + def payload_digest(): """One digest over the content this kit installs, normalized the way the installer writes it. @@ -150,7 +163,7 @@ def payload_digest(): """ h = hashlib.sha256() for name in PAYLOAD_FILES: - raw = (HERE / name).read_bytes().replace(b"\r\n", b"\n") + raw = normalized((HERE / name).read_bytes()) # A snippet is embedded stripped, so trailing whitespace is not installed content. # The hook is copied byte for byte, so nothing about it is stripped. if name.endswith(".md"): @@ -213,8 +226,8 @@ def installed_digest(claude_home): if not hook.is_file() or not claude_md.is_file(): return None h = hashlib.sha256() - h.update(hook.read_bytes().replace(b"\r\n", b"\n")) - text = claude_md.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n") + h.update(normalized(hook.read_bytes())) + text = normalized(claude_md.read_text(encoding="utf-8", errors="replace")) for marker in ("agent-safety", "fleet-bootstrap"): found = re.search(rf".*?", text, re.DOTALL) if not found: @@ -536,7 +549,7 @@ def reject(where, held, want): if claude_md.exists(): raw = claude_md.read_bytes() newline = "\r\n" if b"\r\n" in raw else "\n" - existing = raw.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") + existing = normalized(raw.decode("utf-8")) else: newline, existing = "\n", "" for marker, filename in blocks: diff --git a/host-setup/agent-safety/test_install.py b/host-setup/agent-safety/test_install.py index 1bcb5dac..9e9c234f 100644 --- a/host-setup/agent-safety/test_install.py +++ b/host-setup/agent-safety/test_install.py @@ -412,6 +412,29 @@ def test_trailing_whitespace_on_a_snippet_is_not_reported_as_drift(self): finally: target.write_bytes(original) + def test_a_bare_cr_in_a_snippet_is_not_reported_as_drift(self): + """The installer reads snippets in text mode, so a bare CR arrives and installs as a newline. + + A digest normalizing CRLF but not CR reported the machine STALE against its own content. + """ + baseline = install.payload_digest() + target = HERE / "claude-md-safety.md" + original = target.read_bytes() + try: + # Built from the normalized form, since the snippets are CRLF in this repo. + # A blind newline replace would turn each CRLF into a doubled CR rather than a bare one. + target.write_bytes(install.normalized(original).replace(b"\n", b"\r")) + self.assertEqual(install.payload_digest(), baseline) + finally: + target.write_bytes(original) + + def test_every_normalization_site_agrees(self): + """The two digests must normalize identically, or a machine drifts against nothing.""" + for raw, want in ((b"a\r\nb", b"a\nb"), (b"a\rb", b"a\nb"), (b"a\nb", b"a\nb")): + self.assertEqual(install.normalized(raw), want) + for raw, want in (("a\r\nb", "a\nb"), ("a\rb", "a\nb"), ("a\nb", "a\nb")): + self.assertEqual(install.normalized(raw), want) + def test_a_real_edit_to_a_snippet_is_still_reported(self): """The normalization must not swallow a change that does reach the installed block.""" baseline = install.payload_digest() From 3894a8789bd8946d0176cba556d142926d7238a3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 18:46:26 -0700 Subject: [PATCH 10/11] Catch the Decode Error Too, at Both Reads That Parse a File `report` and `registration_problems` each caught JSONDecodeError and OSError, and a file holding bytes no decoder accepts raises UnicodeDecodeError before the JSON parser is reached. A partially written stamp therefore crashed the read-only report with a traceback, which is what those handlers exist to prevent. Both catch ValueError now, which covers JSONDecodeError and UnicodeDecodeError alike, rather than naming the subclass and missing its sibling. Two cases added, one per call site, both failing against the previous code. The settings one is included because the finding named only the stamp and the same shape was two hundred lines above it. Co-Authored-By: Claude Opus 5 (1M context) --- host-setup/agent-safety/install.py | 8 ++++++-- host-setup/agent-safety/test_install.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py index 3b9b0be7..40a16c19 100644 --- a/host-setup/agent-safety/install.py +++ b/host-setup/agent-safety/install.py @@ -294,7 +294,9 @@ def registration_problems(claude_home): return ["settings.json is missing, so the hook is not registered"] try: data = json.loads(settings.read_text(encoding="utf-8") or "{}") - except (json.JSONDecodeError, OSError) as e: + # ValueError rather than JSONDecodeError, since it also covers UnicodeDecodeError. + # A partially written or non-UTF-8 file raises that before the JSON parser is ever reached. + except (ValueError, OSError) as e: return [f"settings.json cannot be read ({e})"] if not isinstance(data, dict): return ["settings.json does not hold an object at its root"] @@ -353,7 +355,9 @@ def report(claude_home): return 2 try: stamp = json.loads(path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError) as e: + # ValueError rather than JSONDecodeError, since it also covers UnicodeDecodeError. + # A partially written or non-UTF-8 file raises that before the JSON parser is ever reached. + except (ValueError, OSError) as e: sys.stderr.write(f"Stamp at {path} is unreadable ({e}). Re-run the installer to rewrite it.\n") return 2 # Valid JSON is not a usable stamp: a hand edit or an older format parses and then breaks the read. diff --git a/host-setup/agent-safety/test_install.py b/host-setup/agent-safety/test_install.py index 9e9c234f..eff65370 100644 --- a/host-setup/agent-safety/test_install.py +++ b/host-setup/agent-safety/test_install.py @@ -211,6 +211,23 @@ def test_a_stamp_missing_required_keys_gives_a_verdict_rather_than_a_traceback(s self.assertIn("missing", r.stderr) self.assertNotIn("Traceback", r.stderr) + def test_a_stamp_holding_invalid_utf8_gives_a_verdict_rather_than_a_traceback(self): + """A partial write leaves bytes no decoder accepts, which raises before JSON is reached.""" + self.install() + self.stamp.write_bytes(b'{"host": "\xff\xfe not utf-8"}') + r = run(self.home, "--report") + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn("unreadable", r.stderr) + self.assertNotIn("Traceback", r.stderr) + + def test_settings_holding_invalid_utf8_reports_stale_rather_than_a_traceback(self): + """The registration read has the same shape and needed the same widening.""" + self.install() + (self.home / "settings.json").write_bytes(b'{"hooks": "\xff\xfe"}') + r = run(self.home, "--report") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertNotIn("Traceback", r.stderr) + def test_a_stamp_holding_a_non_object_gives_a_verdict_rather_than_a_traceback(self): self.install() self.stamp.write_text("[]\n", encoding="utf-8") From 25cf480fdbf3f74cb636615dd288a34812995947 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 18:54:05 -0700 Subject: [PATCH 11/11] Derive the Payload List From the Block List, So a New Snippet Cannot Escape the Digest `test_every_deployed_file_is_in_the_digest` matched only literal `HERE / "..."` reads, which is the hook and nothing else. The snippet names came from a list inside `main`, so the test never saw them and a new snippet would have passed while being absent from the digest. It asserted coverage and measured one file. The structural fix rather than a wider regex: `CLAUDE_MD_BLOCKS` is a module constant and `PAYLOAD_FILES` derives from it, so adding a block enters the digest with no second edit. Two lists maintained by hand are two lists that drift. Three readers each carried their own copy of the marker pair, which is the same hazard one level down, and all three now read `BLOCK_MARKERS`. Three cases: the scan covers both sources and asserts it matched more than the hook, the derivation holds, and no reader carries its own pair. Co-Authored-By: Claude Opus 5 (1M context) --- host-setup/agent-safety/install.py | 24 ++++++++++++++------ host-setup/agent-safety/test_install.py | 29 +++++++++++++++++++++---- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py index 40a16c19..5156c773 100644 --- a/host-setup/agent-safety/install.py +++ b/host-setup/agent-safety/install.py @@ -34,10 +34,21 @@ # A reader that predates a field needs to know the shape changed rather than infer it from a missing key. STAMP_VERSION = 1 -# The files whose bytes this kit actually places on a machine. +# The marker-delimited blocks this kit maintains in CLAUDE.md, in the order they are written and hashed. +# One list rather than the marker pair repeated at each reader. +# A block added to one reader and not the others is installed and then never checked by what reports on it. +CLAUDE_MD_BLOCKS = ( + ("agent-safety", "claude-md-safety.md"), + ("fleet-bootstrap", "claude-md-fleet.md"), +) +BLOCK_MARKERS = tuple(marker for marker, _ in CLAUDE_MD_BLOCKS) + +# The files whose bytes this kit actually places on a machine, the hook first and then each block. +# Derived rather than listed, so a block added above enters the digest without a second edit. +# Written out, this list and the block list drifted apart silently and the digest stopped covering a file. # The digest is taken over these rather than over the commit, since it is the content that runs. # A clean commit and a dirty checkout install different bytes while reporting the same SHA. -PAYLOAD_FILES = ("gh-write-guard.py", "claude-md-safety.md", "claude-md-fleet.md") +PAYLOAD_FILES = ("gh-write-guard.py",) + tuple(filename for _, filename in CLAUDE_MD_BLOCKS) # Distinguishes an absent key from one holding an explicit null, which `dict.get` reports alike. # The two need different answers, since a gap is filled and a null is a settings error. @@ -182,7 +193,7 @@ def blocks_present(claude_md): return {} text = claude_md.read_text(encoding="utf-8", errors="replace") found = {} - for marker in ("agent-safety", "fleet-bootstrap"): + for marker in BLOCK_MARKERS: # A start marker alone is a half-written block, which a presence check reads as installed. # Exactly one pair, since the installer writes one and a duplicate is a corrupted file. # Two blocks mean the second silently governs, and reporting the first as current hides that. @@ -205,7 +216,7 @@ def marker_corruption(claude_md): text = claude_md.read_text(encoding="utf-8", errors="replace") valid = blocks_present(claude_md) out = [] - for marker in ("agent-safety", "fleet-bootstrap"): + for marker in BLOCK_MARKERS: if re.search(rf"", text) and marker not in valid: out.append(f"the {marker} markers in CLAUDE.md are duplicated or incomplete") return out @@ -228,7 +239,7 @@ def installed_digest(claude_home): h = hashlib.sha256() h.update(normalized(hook.read_bytes())) text = normalized(claude_md.read_text(encoding="utf-8", errors="replace")) - for marker in ("agent-safety", "fleet-bootstrap"): + for marker in BLOCK_MARKERS: found = re.search(rf".*?", text, re.DOTALL) if not found: return None @@ -548,7 +559,6 @@ def reject(where, held, want): # The two blocks install and update independently, so one can change without rewriting the other. # The safety block states restrictions only. # The fleet block enables, so it stays separate from a block whose own text says nothing in it widens a permission. - blocks = [("agent-safety", "claude-md-safety.md"), ("fleet-bootstrap", "claude-md-fleet.md")] # Preserve CLAUDE.md's existing line endings: work in \n internally, write back with its own ending. if claude_md.exists(): raw = claude_md.read_bytes() @@ -556,7 +566,7 @@ def reject(where, held, want): existing = normalized(raw.decode("utf-8")) else: newline, existing = "\n", "" - for marker, filename in blocks: + for marker, filename in CLAUDE_MD_BLOCKS: snippet = (HERE / filename).read_text(encoding="utf-8").strip() block_re = re.compile(rf".*?", re.DOTALL) if block_re.search(existing): diff --git a/host-setup/agent-safety/test_install.py b/host-setup/agent-safety/test_install.py index eff65370..550faaaf 100644 --- a/host-setup/agent-safety/test_install.py +++ b/host-setup/agent-safety/test_install.py @@ -465,14 +465,35 @@ def test_a_real_edit_to_a_snippet_is_still_reported(self): target.write_bytes(original) def test_every_deployed_file_is_in_the_digest(self): - """The inverse: the kit copies gh-write-guard.py and both snippets, and each must be covered.""" + """The inverse: the kit copies gh-write-guard.py and every snippet, and each must be covered. + + The source scan alone was a false positive. It matched only literal `HERE / "..."` reads, + which is the hook and nothing else, while the snippet names came from a list inside `main`. + A new snippet passed it while being absent from the digest, so the two sources of truth are + both checked now, and the derivation below is what actually makes the gap impossible. + """ source = INSTALL.read_text(encoding="utf-8") - for name in re.findall(r'HERE / "([^"]+\.(?:py|md))"', source): - if name == "install.py": - continue + named = {name for name in re.findall(r'HERE / "([^"]+\.(?:py|md))"', source)} + named |= {filename for _, filename in install.CLAUDE_MD_BLOCKS} + named.discard("install.py") + # The hook is the only literal read; every other entry arrives from the block list. + self.assertGreater(len(named), 1, "the scan matched only one file, so it is not covering the blocks") + for name in sorted(named): self.assertIn(name, install.PAYLOAD_FILES, f"install.py reads {name} but PAYLOAD_FILES omits it, so the digest misses it") + def test_the_payload_list_is_derived_from_the_block_list(self): + """Written out by hand, the two drifted and the digest stopped covering a deployed file.""" + self.assertEqual(install.PAYLOAD_FILES, + ("gh-write-guard.py",) + tuple(f for _, f in install.CLAUDE_MD_BLOCKS)) + + def test_every_reader_uses_the_same_marker_list(self): + """Three readers each carried their own marker pair, so a new block could reach one only.""" + source = INSTALL.read_text(encoding="utf-8") + self.assertNotIn('("agent-safety", "fleet-bootstrap")', source, + "a reader is carrying its own marker pair instead of BLOCK_MARKERS") + self.assertEqual(install.BLOCK_MARKERS, tuple(m for m, _ in install.CLAUDE_MD_BLOCKS)) + def test_the_one_line_summary_names_the_host_and_the_commit(self): self.install() stamp = json.loads(self.stamp.read_text(encoding="utf-8"))