diff --git a/.claude/settings.json b/.claude/settings.json index 18a3e71..3b4bde3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -12,7 +12,7 @@ ], "PreToolUse": [ { - "matcher": "Edit|Write", + "matcher": "Edit|Write|MultiEdit|Bash", "hooks": [ { "type": "command", diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5b1ab21..19a4a33 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -61,6 +61,11 @@ jobs: - name: Install Claude Code CLI (official plugin validator) run: npm install -g @anthropic-ai/claude-code@2.1.258 + # Unit tests for the enforcement tooling itself (the PreToolUse guard and + # the checks). Stdlib unittest, no extra dependency, about a second. + - name: Test enforcement tooling + run: python -m unittest discover -s checks/tests + - name: Run enforcement suite env: # No update checks or telemetry from a CI-only validator run. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e4a28d..8fda87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `skills-claude-ai/` — Claude.ai Agent Skill ports of product-ideation and product-flow (issue #12): bundled prompts with skill-relative paths, conversation-scoped artifact ledger with `product-dev-artifacts.md` export replacing the `.product-dev/` registry ### Changed +- PreToolUse guard (ADR 0012 amendment, 2026-10-12): appending to the end of an existing ADR is now allowed, so amendments and graduation-log entries no longer need the kill-switch; the guard also covers MultiEdit and Bash writes; rejections name moves an agent can actually take. Tests in `checks/tests/`, run in CI - product-ideation: the escape hatch now respects the first explicit request to move on, recording the remaining unvalidated assumptions instead of requiring a second override - ADR 0002: Superseded — MCP server removed; plugin is the sole delivery mechanism diff --git a/checks/README.md b/checks/README.md index e869ed7..556d04d 100644 --- a/checks/README.md +++ b/checks/README.md @@ -34,9 +34,9 @@ Rule (ADR 0012 §5): 7+ consecutive clean days OR one real catch. Then: 1. Flip `"status": "warn"` → `"block"` for the check in `enforcement.config.json` and set `"graduated"` to the date. -2. Append a dated entry to the **Graduation log** in the owning ADR's - Enforcement section (use `PRODUCT_DEV_SKIP_ADR_GUARD=1` — this is a - sanctioned amendment). +2. Append a `## Graduation log entry (YYYY-MM-DD)` section to the end of the + owning ADR, naming the check and the basis. Appends pass the guard; no + kill-switch needed. 3. Add a CHANGELOG note. Demotion (block → warn) is always allowed; log it the same three ways. @@ -56,4 +56,17 @@ Configured in `.claude/settings.json`, scripts in `checks/hooks/`: | Hook | Does | Kill-switch | |------|------|-------------| | Stop-gate | Runs the suite when Claude finishes a turn; BLOCKERs bounce the turn back with the report | `PRODUCT_DEV_SKIP_STOP_GATE=1` | -| PreToolUse guard | Blocks Edit/Write on existing `docs/adrs/*.md` (append-only; new ADR files always allowed) and on the Pattern Guide | `PRODUCT_DEV_SKIP_ADR_GUARD=1` | +| PreToolUse guard | On Edit, Write, MultiEdit, and Bash: blocks rewrites of existing `docs/adrs/NNNN-*.md` and any edit of the Pattern Guide. Appending to the end of an ADR and creating a new ADR file are always allowed | `PRODUCT_DEV_SKIP_ADR_GUARD=1` | + +The kill-switches are environment variables of the Claude Code process: the +person launching the session can set them, an agent cannot. An agent blocked +by the guard appends, writes a new ADR, or stops and asks. + +The Bash coverage is a heuristic (redirects, `sed -i`, `tee`, `rm`, `mv`, +inline scripts that write). It is a guard rail, not a security boundary. + +## Tests + +```bash +python -m unittest discover -s checks/tests +``` diff --git a/checks/hooks/pretooluse_guard.py b/checks/hooks/pretooluse_guard.py index 03bed40..eb99ca7 100644 --- a/checks/hooks/pretooluse_guard.py +++ b/checks/hooks/pretooluse_guard.py @@ -1,70 +1,227 @@ #!/usr/bin/env python3 -"""PreToolUse guard: protect ADR-governed paths from Edit/Write. +"""PreToolUse guard: protect ADR-governed paths from rewrites. -Protected (see ADR 0012): - - docs/adrs/*.md that already exist — accepted ADRs are append-only history. - Creating a NEW ADR file is always allowed (that is the supersession path). +Protected (see ADR 0012 and its 2026-10-12 amendment): + - docs/adrs/NNNN-*.md that already exist — ADRs are append-only history. + * Creating a NEW ADR file is always allowed (the supersession path). + * APPENDING to an existing ADR is allowed: an Edit/Write/MultiEdit whose + result keeps every existing character and only adds text after it. That + is how amendments, graduation-log entries, and supersession notes land. + * Anything else (rewriting prose, changing a Status line) is blocked. - docs/updates/00_ENHANCEMENT_PATTERN.md — the Pattern Guide is the spec (ADR 0009); changing it requires an ADR. -Kill-switch: PRODUCT_DEV_SKIP_ADR_GUARD=1 disables this hook entirely -(use for sanctioned amendments: Enforcement-section appends, graduation-log -entries, supersession notes). +Covers Edit, Write, MultiEdit, and Bash. The Bash coverage is a heuristic +that catches the ordinary ways a shell command rewrites a file (redirection, +sed -i, tee, mv, rm, an inline interpreter script). It is a guard rail, not a +security boundary: review of the diff is what catches a determined rewrite. + +Kill-switch: PRODUCT_DEV_SKIP_ADR_GUARD=1 disables this hook entirely. It is +an environment variable of the Claude Code process, so only the person who +launches the session can set it — an agent cannot. """ +import glob import json import os +import re import sys from pathlib import Path PATTERN_GUIDE = Path("docs/updates/00_ENHANCEMENT_PATTERN.md") ADR_DIR = Path("docs/adrs") +ADR_FILE = re.compile(r"^\d{4}-.+\.md$") +OWNER_ONLY = ( + "This needs the repo owner: stop and ask them to make the edit, or to " + "relaunch the session with PRODUCT_DEV_SKIP_ADR_GUARD=1. Do not route " + "around this guard with another tool." +) +ADR_MOVES = ( + "Legal moves you can take now: (1) APPEND — an edit that leaves all " + "existing text unchanged and adds a dated section at the end of the file " + "(amendment, graduation-log entry, supersession note); (2) write a NEW " + "docs/adrs/NNNN-*.md that supersedes or amends this one. Not available to " + "you: rewriting decision prose or changing a Status line. " + OWNER_ONLY +) -def main(): - if os.environ.get("PRODUCT_DEV_SKIP_ADR_GUARD") == "1": - return 0 +def protected_kind(target, project_dir): + """Return 'adr', 'guide', or None for a path.""" try: - payload = json.load(sys.stdin) - except (json.JSONDecodeError, ValueError): - return 0 - file_path = (payload.get("tool_input") or {}).get("file_path") + rel = target.resolve().relative_to(project_dir) + except ValueError: + return None # outside the project; not ours to police + if rel == PATTERN_GUIDE: + return "guide" + if rel.parent == ADR_DIR and ADR_FILE.match(rel.name): + return "adr" + return None + + +def is_pure_append(before, after): + return after != before and after.startswith(before) + + +def apply_edits(content, edits): + """Simulate Edit/MultiEdit; return the resulting text, or None if an edit + would not apply (the tool itself will reject it — nothing to guard).""" + for e in edits: + old, new = e.get("old_string"), e.get("new_string") + if not isinstance(old, str) or not isinstance(new, str) or old == "" or old not in content: + return None + content = content.replace(old, new) if e.get("replace_all") else content.replace(old, new, 1) + return content + + +def block(message): + print("BLOCKED: " + message, file=sys.stderr) + return 2 + + +def guard_file_tool(tool_name, tool_input, project_dir): + file_path = tool_input.get("file_path") if not file_path: return 0 - - project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR") or Path.cwd()).resolve() target = Path(file_path) if not target.is_absolute(): target = project_dir / target - target = target.resolve() - try: - rel = target.relative_to(project_dir) - except ValueError: - return 0 # outside the project; not ours to police + kind = protected_kind(target, project_dir) + if kind is None: + return 0 + rel = target.resolve().relative_to(project_dir) - if rel == PATTERN_GUIDE: - print( - "BLOCKED: docs/updates/00_ENHANCEMENT_PATTERN.md is the prompt-pattern " - "spec (ADR 0009) and is protected (ADR 0012). Legal moves: propose the " - "change in a new ADR that amends ADR 0009, or for a sanctioned edit rerun " - "with kill-switch PRODUCT_DEV_SKIP_ADR_GUARD=1.", - file=sys.stderr, + if kind == "guide": + return block( + f"{rel} is the prompt-pattern spec (ADR 0009) and is protected " + "(ADR 0012). Legal move you can take now: propose the change in a " + "NEW docs/adrs/NNNN-*.md that amends ADR 0009. Editing the guide " + "itself is not available to you. " + OWNER_ONLY ) - return 2 - - if rel.parent == ADR_DIR and rel.suffix == ".md" and target.exists(): - print( - f"BLOCKED: {rel} is an existing ADR — accepted ADRs are append-only " - "history (ADR 0012). Legal moves: (1) supersede by WRITING A NEW " - "docs/adrs/NNNN-*.md file (always allowed) and adding a supersession " - "note, (2) append an Enforcement-schema amendment or graduation-log " - "entry via kill-switch PRODUCT_DEV_SKIP_ADR_GUARD=1. Never rewrite " - "decision prose.", - file=sys.stderr, - ) - return 2 - return 0 + if not target.exists(): + return 0 # new ADR file: always allowed + + before = target.read_text() + if tool_name == "Write": + after = tool_input.get("content") + elif tool_name == "MultiEdit": + after = apply_edits(before, tool_input.get("edits") or []) + else: + after = apply_edits(before, [tool_input]) + if after is None: + return 0 # edit does not apply; the tool will report that itself + if isinstance(after, str) and is_pure_append(before, after): + return 0 + return block( + f"{rel} is an existing ADR — ADRs are append-only history (ADR 0012), " + "and this edit changes or removes existing text. " + ADR_MOVES + ) + + +# ------------------------------------------------------------------ Bash + +REDIRECT = re.compile(r"(?&\d])\d?>{1,2}\|?\s*([^\s;|&()<>]+)") +SEGMENT_SPLIT = re.compile(r"\|\||&&|[;|&\n]") +DESTRUCTIVE = {"rm", "mv", "tee", "truncate", "unlink", "shred"} +IN_PLACE_EDITORS = {"sed", "gsed", "perl", "ruby"} +DEST_ONLY = {"cp", "install", "ln", "rsync"} +GIT_REWRITERS = {"rm", "mv", "checkout", "restore"} +INTERPRETERS = {"python", "python3", "node", "ruby", "perl", "php", "deno", "bun"} +INLINE_WRITE = re.compile( + r"write_text|write_bytes|\.write\(|open\([^)]*,\s*['\"][wax+]|writeFile|appendFile" + r"|File\.write|os\.remove|os\.rename|os\.replace|shutil\.|\.unlink\(" +) +PROTECTED_MENTION = re.compile(r"docs/adrs|00_ENHANCEMENT_PATTERN") + + +def strip_quotes(token): + return token.strip("\"'`") + + +def existing_protected(token, bases, project_dir): + """Existing protected files named by one shell token (globs expanded). + A new ADR file does not exist yet, so it never matches.""" + token = strip_quotes(token) + if not token or not PROTECTED_MENTION.search(token): + return [] + hits = [] + for base in bases: + candidate = token if os.path.isabs(token) else str(base / token) + for match in glob.glob(candidate) or [candidate]: + p = Path(match) + if p.is_file() and protected_kind(p, project_dir): + hits.append(p.resolve().relative_to(project_dir)) + return hits + + +def bash_violations(command, bases, project_dir): + if not PROTECTED_MENTION.search(command): + return [] + hits = [] + for m in REDIRECT.finditer(command): + hits += existing_protected(m.group(1), bases, project_dir) + for segment in SEGMENT_SPLIT.split(command): + tokens = [strip_quotes(t) for t in segment.split()] + if not tokens: + continue + names = {os.path.basename(t) for t in tokens} + paths = [p for t in tokens for p in existing_protected(t, bases, project_dir)] + in_place = any(re.match(r"^-[a-zA-Z]*i", t) or t.startswith("--in-place") for t in tokens) + if names & DESTRUCTIVE: + hits += paths + elif names & IN_PLACE_EDITORS and in_place: + hits += paths + elif "git" in names and names & GIT_REWRITERS: + hits += paths + elif names & DEST_ONLY: + hits += existing_protected(tokens[-1], bases, project_dir) + elif "dd" in names: + for t in tokens: + if t.startswith("of="): + hits += existing_protected(t[3:], bases, project_dir) + # An inline interpreter script (python -c, a heredoc) that names a + # protected path and contains a write call. The path sits inside code, so + # it cannot be resolved token by token. + words = {os.path.basename(strip_quotes(t)) for t in command.split()} + if not hits and words & INTERPRETERS and INLINE_WRITE.search(command): + hits.append(Path("an inline script that names docs/adrs or the Pattern Guide and writes files")) + return sorted(set(hits), key=str) + + +def guard_bash(tool_input, project_dir, cwd): + command = tool_input.get("command") + if not isinstance(command, str): + return 0 + bases = [project_dir] if cwd in (None, project_dir) else [cwd, project_dir] + hits = bash_violations(command, bases, project_dir) + if not hits: + return 0 + listed = ", ".join(str(h) for h in hits) + return block( + "this shell command would rewrite protected ADR-governed content " + f"({listed}). Existing ADRs are append-only history and the Pattern " + "Guide is the ADR 0009 spec (ADR 0012). Use the Edit tool instead. " + + ADR_MOVES + ) + + +def main(): + if os.environ.get("PRODUCT_DEV_SKIP_ADR_GUARD") == "1": + return 0 + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + return 0 + if not isinstance(payload, dict): + return 0 + tool_input = payload.get("tool_input") or {} + tool_name = payload.get("tool_name") or "" + project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR") or Path.cwd()).resolve() + + if tool_name == "Bash": + cwd = Path(payload["cwd"]).resolve() if payload.get("cwd") else None + return guard_bash(tool_input, project_dir, cwd) + return guard_file_tool(tool_name, tool_input, project_dir) if __name__ == "__main__": diff --git a/checks/tests/test_pretooluse_guard.py b/checks/tests/test_pretooluse_guard.py new file mode 100644 index 0000000..3e02301 --- /dev/null +++ b/checks/tests/test_pretooluse_guard.py @@ -0,0 +1,152 @@ +"""Tests for the PreToolUse ADR guard (ADR 0012, amended 2026-10-12). + +Each test builds a throwaway project, feeds the hook a tool payload on stdin +exactly as Claude Code does, and asserts on the exit code (0 allow, 2 block). +Run: python -m unittest discover -s checks/tests +""" +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +GUARD = Path(__file__).resolve().parents[1] / "hooks" / "pretooluse_guard.py" +SKIP_VAR = "PRODUCT_DEV_SKIP_ADR_GUARD" +ADR_TEXT = "# ADR 0001: Example\n\n## Status\nAccepted\n\n## Decision\nKeep it.\n" + + +class GuardCase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name).resolve() + (self.root / "docs" / "adrs").mkdir(parents=True) + (self.root / "docs" / "updates").mkdir(parents=True) + self.adr = self.root / "docs" / "adrs" / "0001-example.md" + self.adr.write_text(ADR_TEXT) + (self.root / "docs" / "adrs" / "README.md").write_text("# index\n") + (self.root / "docs" / "updates" / "00_ENHANCEMENT_PATTERN.md").write_text("guide\n") + (self.root / "notes.md").write_text("notes\n") + + def tearDown(self): + self._tmp.cleanup() + + def run_guard(self, tool_name, tool_input, skip=False): + env = {k: v for k, v in os.environ.items() if k != SKIP_VAR} + env["CLAUDE_PROJECT_DIR"] = str(self.root) + if skip: + env[SKIP_VAR] = "1" + payload = {"tool_name": tool_name, "tool_input": tool_input, "cwd": str(self.root)} + r = subprocess.run([sys.executable, str(GUARD)], input=json.dumps(payload), + capture_output=True, text=True, env=env) + return r.returncode, r.stderr + + def assertAllowed(self, tool_name, tool_input, **kw): + code, err = self.run_guard(tool_name, tool_input, **kw) + self.assertEqual(code, 0, err) + + def assertBlocked(self, tool_name, tool_input): + code, err = self.run_guard(tool_name, tool_input) + self.assertEqual(code, 2, f"expected a block, got exit {code}") + self.assertIn("ADR 0012", err) + return err + + # ---- Edit / Write / MultiEdit + + def test_edit_appending_at_the_tail_is_allowed(self): + self.assertAllowed("Edit", {"file_path": str(self.adr), "old_string": "Keep it.\n", + "new_string": "Keep it.\n\n---\n\n## Amendment (2026-10-12)\nMore.\n"}) + + def test_edit_rewriting_prose_is_blocked_and_names_a_reachable_move(self): + err = self.assertBlocked("Edit", {"file_path": str(self.adr), "old_string": "Keep it.", + "new_string": "Drop it."}) + self.assertIn("APPEND", err) + self.assertIn("ask them", err) + + def test_edit_changing_the_status_line_is_blocked(self): + self.assertBlocked("Edit", {"file_path": str(self.adr), "old_string": "Accepted", + "new_string": "Superseded by ADR 0002"}) + + def test_edit_inserting_in_the_middle_is_blocked(self): + self.assertBlocked("Edit", {"file_path": str(self.adr), "old_string": "## Decision\n", + "new_string": "## Decision\nInserted.\n"}) + + def test_write_that_keeps_existing_text_and_adds_below_is_allowed(self): + self.assertAllowed("Write", {"file_path": str(self.adr), "content": ADR_TEXT + "\nAppended.\n"}) + + def test_write_that_replaces_the_file_is_blocked(self): + self.assertBlocked("Write", {"file_path": str(self.adr), "content": "# rewritten\n"}) + + def test_multiedit_is_judged_on_its_combined_result(self): + ok = {"file_path": str(self.adr), "edits": [ + {"old_string": "Keep it.\n", "new_string": "Keep it.\nA.\n"}, + {"old_string": "A.\n", "new_string": "A.\nB.\n"}]} + bad = {"file_path": str(self.adr), "edits": [ + {"old_string": "Keep it.\n", "new_string": "Keep it.\nA.\n"}, + {"old_string": "Accepted", "new_string": "Deferred"}]} + self.assertAllowed("MultiEdit", ok) + self.assertBlocked("MultiEdit", bad) + + def test_new_adr_file_is_always_allowed(self): + self.assertAllowed("Write", {"file_path": str(self.root / "docs/adrs/0002-new.md"), "content": "# new\n"}) + + def test_relative_paths_resolve_against_the_project(self): + self.assertBlocked("Write", {"file_path": "docs/adrs/0001-example.md", "content": "x"}) + + def test_adr_index_and_unrelated_files_are_not_protected(self): + self.assertAllowed("Write", {"file_path": str(self.root / "docs/adrs/README.md"), "content": "regenerated\n"}) + self.assertAllowed("Write", {"file_path": str(self.root / "notes.md"), "content": "x"}) + + def test_pattern_guide_is_blocked_even_for_an_append(self): + guide = self.root / "docs/updates/00_ENHANCEMENT_PATTERN.md" + err = self.assertBlocked("Edit", {"file_path": str(guide), "old_string": "guide\n", + "new_string": "guide\nmore\n"}) + self.assertIn("ADR 0009", err) + + def test_kill_switch_disables_the_guard(self): + self.assertAllowed("Write", {"file_path": str(self.adr), "content": "x"}, skip=True) + + # ---- Bash + + def test_bash_writes_to_an_existing_adr_are_blocked(self): + for command in [ + "echo hi > docs/adrs/0001-example.md", + "echo hi >> docs/adrs/0001-example.md", + "sed -i '' 's/Accepted/Deferred/' docs/adrs/0001-example.md", + "perl -pi -e 's/a/b/' docs/adrs/*.md", + "cat notes.md | tee docs/adrs/0001-example.md", + "rm docs/adrs/0001-example.md", + "git mv docs/adrs/0001-example.md docs/adrs/0009-example.md", + "git checkout HEAD~1 -- docs/adrs/0001-example.md", + "cp notes.md docs/adrs/0001-example.md", + "ls; echo x > docs/updates/00_ENHANCEMENT_PATTERN.md", + "python3 -c \"from pathlib import Path; Path('docs/adrs/0001-example.md').write_text('x')\"", + ]: + with self.subTest(command=command): + err = self.assertBlocked("Bash", {"command": command}) + self.assertIn("Edit tool", err) + + def test_bash_reads_and_unrelated_writes_are_allowed(self): + for command in [ + "cat docs/adrs/0001-example.md", + "grep -n Status docs/adrs/*.md > /tmp/adr-status.txt 2>&1", + "cat docs/adrs/0001-example.md | head -5 > notes.md", + "cp docs/adrs/0001-example.md /tmp/copy.md", + "ls docs/adrs && wc -l docs/adrs/*.md", + "git log --oneline -- docs/adrs/0001-example.md", + "git add docs/adrs/0001-example.md && git commit -m 'docs(adr): amend'", + "python3 checks/run_checks.py --ci", + "python3 checks/adr_index.py --write", + "echo hi > notes.md", + "rm notes.md", + ]: + with self.subTest(command=command): + self.assertAllowed("Bash", {"command": command}) + + def test_bash_may_create_a_new_adr_file(self): + self.assertAllowed("Bash", {"command": "cat > docs/adrs/0002-new.md <<'EOF'\n# ADR 0002\nEOF"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/adrs/0012-adr-enforcement-architecture.md b/docs/adrs/0012-adr-enforcement-architecture.md index d861699..cd9a532 100644 --- a/docs/adrs/0012-adr-enforcement-architecture.md +++ b/docs/adrs/0012-adr-enforcement-architecture.md @@ -163,3 +163,55 @@ those requires amendment decisions, not check tuning. - **Not machine-checkable:** Whether graduation decisions (7 clean days, "real catch") are applied honestly — that is a review-time judgment on the log. - **Graduation log:** _(empty)_ + +--- + +## Amendment (2026-10-12): The guard allows appends and covers Bash + +Owner-directed, following a review of this repo's governance against the +constitution-lift experiment (chrispezza/portfolio#106). Two findings applied +to the PreToolUse guard in Decision §4: + +1. **The guard named a legal move the agent could not take.** Appending to an + existing ADR is required by this ADR's own process (Enforcement amendments, + graduation-log entries, supersession notes), but the guard blocked every + edit and pointed at `PRODUCT_DEV_SKIP_ADR_GUARD=1` — an environment variable + of the Claude Code process, which only the person launching the session can + set. The only path left to an agent was a tool the guard did not watch. +2. **The guard watched only Edit and Write.** A shell command (`sed -i`, a + redirect, a heredoc) rewrote an ADR without touching the hook. + +**Decision:** + +- **Appends are allowed.** An Edit, Write, or MultiEdit on an existing + `docs/adrs/NNNN-*.md` passes when its result keeps every existing character + and only adds text after it. Everything else — rewriting prose, changing a + Status line, inserting mid-file — stays blocked. The kill-switch remains, + for the owner, for the edits an append cannot express (for example + Proposed → Accepted). +- **Dated entries go at the end of the file.** Graduation-log entries are + appended as `## Graduation log entry (YYYY-MM-DD)` sections rather than + edited into the Enforcement section's log line, so they are appends too. + `checks/enforcement.config.json` is authoritative for a check's current + status; the "(status: **warn**)" labels inside older Enforcement sections + record the status at the time they were written. +- **The matcher is `Edit|Write|MultiEdit|Bash`.** For Bash the guard blocks + commands that write to an existing protected file: redirection, `tee`, + `sed -i`/`perl -i`, `rm`, `mv`, `cp` onto it, `git checkout`/`restore`/`rm`/ + `mv` of it, and inline interpreter scripts that name a protected path and + contain a write call. Reads, and creating a new ADR file, pass. This is a + heuristic guard rail, not a security boundary — review of the diff is what + catches a determined rewrite. +- **Only numbered ADR files are protected.** `docs/adrs/README.md` (the + generated index) is not. +- **Rejections name moves the agent can take** — append, or write a new ADR — + and say plainly that anything else means stopping to ask the owner. +- **The guard has tests**: `checks/tests/test_pretooluse_guard.py`, run in the + `enforcement` CI job (stdlib `unittest`, under a second). + +The Pattern Guide rule is unchanged: no edits, appends included; propose the +change in a new ADR. + +Not tested by the experiment that prompted this: its harness ran with no +hooks, so it says nothing about whether a hook-based guard holds. The change +rests on the no-legal-move finding and on reading this guard's own code.