diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..321c1dbf --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,40 @@ +{ + "permissions": { + "allow": [ + "PowerShell(*ruff.exe *)", + "PowerShell(*mypy.exe *)", + "PowerShell(Select-Object *)", + "PowerShell(git checkout main 2>&1)", + "PowerShell(git pull --ff-only 2>&1)" + ], + "deny": [ + "Read(.env)", + "Read(.env.*)", + "Read(secrets/**)", + "Read(*.key)", + "Read(*.pem)", + "Read(*.pfx)", + "Read(*.db)", + "Read(*.db-wal)", + "Read(*.db-shm)", + "Read(bootstrap-admin.txt)", + "Edit(.env)", + "Edit(.env.*)", + "Edit(secrets/**)", + "Edit(*.db)", + "Write(.env)", + "Write(secrets/**)", + "PowerShell(Get-Content *.env*)", + "PowerShell(Get-Content *secrets*)", + "PowerShell(Get-Content *.db*)", + "Bash(rm -rf:*)", + "Bash(git push --force:*)", + "Bash(git push -f:*)", + "Bash(git reset --hard:*)", + "PowerShell(Remove-Item -Recurse -Force:*)", + "PowerShell(git push --force:*)", + "PowerShell(git push -f:*)", + "PowerShell(git reset --hard:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index aa2c5f7f..31328cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -81,7 +81,11 @@ base-tree/ dast-auth-receipt.json canary-*.json -# Claude Code: settings.json is shared/tracked; settings.local.json is machine-local (never commit) +# Claude Code: settings.json is shared/tracked; settings.local.json is machine-local (never commit). +# That first clause was FALSE from the day `/.claude/` landed until the publishing-boundary block +# below was reshaped to `/.claude/*` plus a negation -- BACKLOG #327's DONE note carried the +# contradiction to the owner rather than editing it in that lane. It is true again now. The rule +# below is redundant with `/.claude/*` and is kept as the statement of intent for this one file. .claude/settings.local.json # Local reference notes pointing at machine-specific Claude Code transcript paths — never commit @@ -139,7 +143,29 @@ scripts/security/scan-tokens.local.txt # two-line redaction). CLAUDE.md in particular MUST be tracked: it is gitignored-by-default's worst # case here, because `git worktree add` cannot deliver an untracked file, so every worktree silently # came up with ZERO project conventions loaded. -/.claude/ +# +# `.claude/` is ignored by CONTENTS (`/.claude/*`), NOT as a directory, so `settings.json` can be +# re-included on the next line. Ignoring the directory itself would make that negation a silent +# no-op: git cannot re-include a file whose parent directory is excluded. Everything else under +# `.claude/` stays ignored by the same star rule -- `settings.local.json` (machine-local, also +# named at line 85), `worktrees/` (session state, not configuration), and anything Claude Code +# adds there later, which is ignored by DEFAULT rather than by enumeration. +# +# WHY settings.json IS TRACKED. It carries ENFORCED controls -- the deny-list covering `.env`, +# `secrets/**`, keys and the local `*.db` store, plus the `block-blanket-git-stage` PreToolUse +# guard -- and settings are enforced by the client where CLAUDE.md is only context, so section 5's +# prose is not a substitute for it. Untracked, it hit the exact CLAUDE.md failure described above: +# measured 2026-08-13, of 62 local checkouts carrying CLAUDE.md only 12 had `.claude/settings.json`, +# so 50 ran with no deny-list and no staging guard. `git worktree add` delivers tracked files only. +/.claude/* +!/.claude/settings.json +# Named explicitly rather than left to `/.claude/*` above. Until this line, the only travelling +# protection for the session tree was that wildcard -- `.git/info/exclude` carries it too, but that +# file is per-clone and reaches nobody else. A later edit that narrows the wildcard or adds a second +# negation would expose full nested checkouts carrying `.venv`, `messagefoundry.db` and the caches. +# NOTHING under `.claude/` may be negated except `settings.json`; tests/test_private_paths_stay_ignored.py +# pins the tracked set so widening it fails a required check instead of shipping. +/.claude/worktrees/ /TRANSCRIPTS.md /docs/security/ /docs/reviews/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3af78f0a..088c99ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,6 +127,30 @@ concrete features go in **Issues**; security vulnerabilities go through a Building two changes in parallel? Don't share one checkout — give each its own **git worktree** (`scripts\worktree\new.ps1 -Name `). See [docs/WORKTREES.md](docs/WORKTREES.md). +### If you use Claude Code: this repo ships two hooks + +[`.claude/settings.json`](.claude/settings.json) is **tracked**, so cloning this repo configures +Claude Code, and you should read it before you trust it. It is the only tracked file under +`.claude/`; everything else there is session state and stays ignored. + +- **It wires two PowerShell scripts to run automatically.** + [`scripts/hooks/block-blanket-git-stage.ps1`](scripts/hooks/block-blanket-git-stage.ps1) runs + before any git command the agent issues, and + [`scripts/worktree/session-context.ps1`](scripts/worktree/session-context.ps1) runs at session + start. Both are in-repo, reviewable, and covered by the same review as any other script here. +- **They need PowerShell 7 (`pwsh`).** A hook that cannot start is **non-blocking** — the action + proceeds and you get a notice, not a refusal. So on a machine without `pwsh` the staging guard is + absent rather than failing loudly. Do not treat it as coverage you can rely on; the leak gate + above is the control that fails closed. +- **The deny rules cover the directory you started the agent in.** They keep `.env`, `secrets/`, + keys and the local `*.db` store away from the agent's file tools at any depth *below that + directory*. A session started in one checkout that writes into a sibling worktree by absolute path + is outside them. The rules are a guard against accident and drift, not against a determined + operator, and they are not a substitute for the leak gate. + +None of this is required to contribute. Delete the file locally if you would rather configure your +own; `git update-index --skip-worktree .claude/settings.json` keeps that local. + ## PHI / safety This engine carries PHI in real deployments. **Never** commit real patient data — tests and diff --git a/scripts/docs/link_check.py b/scripts/docs/link_check.py index 597d8da0..333297d9 100644 --- a/scripts/docs/link_check.py +++ b/scripts/docs/link_check.py @@ -20,12 +20,11 @@ repository invariant. * **Fragments.** ``#some-anchor`` is not validated here; only the path is. Heading slugs churn on every retitle and would make this noisy. -* **Withheld directories.** ``docs/security/``, ``docs/reviews/``, ``docs/marketing/``, - ``docs/releases/`` and ``.claude/`` are gitignored. The master test plan states a missing path - there is a deliberate publishing boundary, not a defect, so flagging them would train readers to - ignore the gate. ``.claude/`` is the instructive one: it is *present* in a long-lived local - checkout and absent from CI's clean clone, so omitting it makes this checker pass locally and fail - on the runner. +* **Withheld directories.** ``docs/security/``, ``docs/reviews/``, ``docs/marketing/`` and + ``docs/releases/`` are gitignored. The master test plan states a missing path there is a + deliberate publishing boundary, not a defect, so flagging them would train readers to ignore the + gate. ``.claude/`` was a fifth entry until ``.claude/settings.json`` became tracked; every link + the exemption covered pointed at that one file, so they are now checked like any other. * **Fenced code.** A path inside ``` is sample output being shown, not a link to follow. * **Inline code.** A link inside backticks is being *displayed*, not offered -- the same argument as fenced code, at smaller scale. Four real sites turn on it: a regex whose character class contains @@ -59,24 +58,22 @@ # docs/releases/ joined when ADR 0160 Phase 1 untracked it (.gitignore carries "/docs/releases/") -- # an archived throughput doc still cites the v0.1 plan that moved out with it. # -# .claude/ (.gitignore:142) is exempt on the SAME publishing-boundary grounds -- 7 docs link to -# .claude/settings.json, which no clone has. +# .claude/ WAS a fifth entry, exempt because 7 links pointed at .claude/settings.json and no clone +# had it. It is gone because the premise is: settings.json is tracked now, so those 7 resolve +# through tracked_paths() like every other link and are counted rather than skipped. # -# It is listed here as POLICY, not as protection. It was originally added as protection, because a -# filesystem fallback made those 7 links pass in a long-lived local checkout and fail on CI's clean -# clone -- the first repo-wide measurement was taken in such a checkout and undercounted by exactly -# 7. That hazard is now closed STRUCTURALLY in tracked_paths(): resolution never consults the -# filesystem, so no gitignored-but-present path can pass locally and fail on the runner, listed here -# or not. Removing this entry would make those 7 links fail honestly and identically everywhere. -# Keeping an enumerated exemption as the reason a control holds is the compensating-control-on-a- -# false-premise shape (CLAUDE.md section 11, SDS-3.7); the enumeration expresses intent, the -# resolver provides the guarantee. +# Measured before removing it: all 7 markdown links whose href names a .claude/ path name +# settings.json and nothing else, so nothing else loses its exemption. That mattered, because the +# exemption `continue`s BEFORE `checked += 1` -- a withheld href is not merely resolved, it is never +# counted, which #327 demonstrated by planting a missing path under .claude/ and watching the total +# stay at 5359 and the run stay green. An exemption that hides its own coverage gap is the +# compensating-control-on-a-false-premise shape (CLAUDE.md section 11, SDS-3.7). Keep this tuple to +# genuinely unpublished trees; a path that ships belongs in the gate. WITHHELD = ( "docs/security/", "docs/reviews/", "docs/marketing/", "docs/releases/", - ".claude/", ) _LINK = re.compile(r"\]\((?P[^)\s]+?)(?P#[^)\s]*)?\)") diff --git a/tests/test_claude_settings_contract.py b/tests/test_claude_settings_contract.py new file mode 100644 index 00000000..f35e3c66 --- /dev/null +++ b/tests/test_claude_settings_contract.py @@ -0,0 +1,178 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""`.claude/settings.json` is now a TRACKED control, so its shape gets a test. + +Tracking the file (see `tests/test_private_paths_stay_ignored.py` for the boundary half) is what +carries the deny-list and the `block-blanket-git-stage` guard to a fresh clone and to every +`git worktree add`. That only buys anything if the payload still works when it arrives, and the two +ways it silently stops working are both invisible to review: + + * **A hook that cannot start does not block.** Claude Code's hooks reference is explicit that a + command hook which fails to launch "lands in the same non-blocking bucket" and that for most + events "the action proceeds". A hook path written bare, as `scripts/hooks/x.ps1`, resolves + against the session's current directory, not the repo — so in any session started outside the + repo root it never runs, the guard reads as enforced in the file, and nothing reports it. The + fix is `${CLAUDE_PROJECT_DIR}` in exec form, and this file pins it. + * **A deny rule anchored at `./` covers one directory.** Bare patterns follow gitignore semantics + and match at any depth; `Read(./.env)` matches `/.env` and nothing below it. The `./` form + looks equivalent and is strictly narrower, which is the worst combination for a control whose + whole job is to be broad. + +Neither is caught by JSON validity, by `pre-commit`, or by reading the diff. Both are caught here. + +The deny-list is also the only half of this file that auto mode cannot touch: permission deny rules +are evaluated before the classifier, and unlike `allow` rules they are not gated on the workspace +trust dialog. That is why the pinned subset below is the deny rules and not the allow rules. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_SETTINGS = _ROOT / ".claude" / "settings.json" + +# The rules whose loss would be silent and would matter. Not the whole deny-list: the point is a +# floor under the PHI, secret and local-store rules that CLAUDE.md section 5 and section 9 promise +# are enforced, so prose and mechanism cannot drift apart without a red test. +_REQUIRED_DENIES = frozenset( + { + "Read(.env)", + "Read(secrets/**)", + "Read(*.db)", + "Edit(.env)", + "Edit(secrets/**)", + "Write(.env)", + "Write(secrets/**)", + } +) + +_PLACEHOLDER = "${CLAUDE_PROJECT_DIR}" + + +def _load() -> dict[str, Any]: + return json.loads(_SETTINGS.read_text(encoding="utf-8")) + + +def _hook_handlers(settings: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: + """Flatten `hooks.[].hooks[]` into (event, handler) pairs.""" + out: list[tuple[str, dict[str, Any]]] = [] + for event, groups in settings.get("hooks", {}).items(): + for group in groups: + for handler in group.get("hooks", []): + out.append((event, handler)) + return out + + +def _repo_script_refs(handler: dict[str, Any]) -> list[str]: + """Every token in a handler that names a file under the repo's script trees.""" + tokens = [handler.get("command", ""), *handler.get("args", [])] + return [t for t in tokens if isinstance(t, str) and (".ps1" in t or ".py" in t)] + + +def _unanchored_refs(settings: dict[str, Any]) -> list[str]: + return [ + f"{event}: {ref}" + for event, handler in _hook_handlers(settings) + for ref in _repo_script_refs(handler) + if not ref.startswith(_PLACEHOLDER) + ] + + +def _dot_anchored_denies(settings: dict[str, Any]) -> list[str]: + return [r for r in settings["permissions"]["deny"] if "(./" in r] + + +def test_settings_is_valid_json() -> None: + """A malformed tracked settings file is a repo-wide outage, not a local one.""" + assert _load()["permissions"], "permissions block is missing or empty" + + +def test_the_phi_and_secret_denies_are_all_present() -> None: + deny = set(_load()["permissions"]["deny"]) + missing = _REQUIRED_DENIES - deny + assert not missing, ( + f"{len(missing)} required deny rule(s) are gone: {sorted(missing)}.\n" + "These are what CLAUDE.md sections 5 and 9 point at when they say secrets and the local " + "store are off limits. Removing one makes that prose false. Deny rules cost nothing when " + "unused and are the only permission rules auto mode cannot override." + ) + + +def test_no_deny_rule_uses_the_narrow_dot_anchor() -> None: + """`Read(./secrets/**)` matches one directory; `Read(secrets/**)` matches every depth.""" + narrow = _dot_anchored_denies(_load()) + assert not narrow, ( + f"{len(narrow)} deny rule(s) use the `./` anchor and match at one depth only: {narrow}.\n" + "Drop the prefix. A nested copy of the path -- a vendored tree, a worktree checked out " + "inside the repo, a fixture directory -- is outside a `./`-anchored rule and inside a bare " + "one, and the two forms read identically in review." + ) + + +def test_every_hook_resolves_through_the_project_dir_placeholder() -> None: + unanchored = _unanchored_refs(_load()) + assert not unanchored, ( + f"{len(unanchored)} hook script reference(s) are not anchored to the project root: " + f"{unanchored}.\n" + "A bare path resolves against the session's working directory. When it misses, the hook " + "fails to start, the action PROCEEDS, and the only trace is a non-blocking notice -- so the " + f"guard is absent exactly when someone is working somewhere unusual. Use {_PLACEHOLDER} " + "with `args` (exec form), which is substituted as a plain string with no shell re-parsing." + ) + + +def test_every_hook_script_actually_exists() -> None: + """An anchored path that points at nothing fails open just as quietly as an unanchored one.""" + missing = [ + ref + for _event, handler in _hook_handlers(_load()) + for ref in _repo_script_refs(handler) + if not (_ROOT / ref.replace(_PLACEHOLDER + "/", "")).is_file() + ] + assert not missing, ( + f"hook(s) reference script(s) that are not in the repo: {missing}.\n" + "Renaming or moving a hook script without updating .claude/settings.json disables the hook " + "silently in every clone." + ) + + +@pytest.mark.parametrize( + ("planted", "checker", "label"), + [ + ( + { + "permissions": {"deny": []}, + "hooks": { + "PreToolUse": [ + {"hooks": [{"command": "pwsh", "args": ["-File", "scripts/hooks/x.ps1"]}]} + ] + }, + }, + _unanchored_refs, + "bare relative hook path", + ), + ( + {"permissions": {"deny": ["Read(./.env)"]}, "hooks": {}}, + _dot_anchored_denies, + "dot-anchored deny rule", + ), + ], + ids=["unanchored-hook", "dot-anchored-deny"], +) +def test_the_checks_can_actually_fail(planted: dict[str, Any], checker: Any, label: str) -> None: + """A guard that cannot be shown to fail is not a guard. + + Both checks above are absence assertions over a file that is currently correct, which is the + shape that passes just as well when the check is broken -- the failure mode this repo has + already recorded twice (`tests/test_feature_map_claims.py`, the `.claude/` link-gate exemption). + Each detector is run here against a settings document carrying exactly the defect it hunts. + """ + assert checker(planted), ( + f"the {label} detector returned nothing for a document that contains one. The " + "corresponding test above is passing for the wrong reason and is not protecting anything." + ) diff --git a/tests/test_link_resolution.py b/tests/test_link_resolution.py index 38d16bc4..553ef520 100644 --- a/tests/test_link_resolution.py +++ b/tests/test_link_resolution.py @@ -105,18 +105,18 @@ def test_withheld_prefixes_are_the_gitignored_ones(checker) -> None: "docs/reviews/", "docs/marketing/", "docs/releases/", - ".claude/", } @pytest.mark.parametrize( "prefix", - ["docs/security/", "docs/reviews/", "docs/marketing/", "docs/releases/", ".claude/"], + ["docs/security/", "docs/reviews/", "docs/marketing/", "docs/releases/"], ) def test_withheld_directories_are_not_flagged(tmp_path, checker, prefix: str) -> None: """A gitignored target is a publishing boundary, not a defect; flagging it trains people to - ignore the gate. ``docs/releases/`` joined when ADR 0160 Phase 1 untracked it; ``.claude/`` - joined because 7 docs link to ``.claude/settings.json``, which no clone has. + ignore the gate. ``docs/releases/`` joined when ADR 0160 Phase 1 untracked it. ``.claude/`` + LEFT once ``.claude/settings.json`` became tracked: all 7 links it covered named that one file, + so they now resolve through ``tracked_paths()`` and are counted instead of skipped. This list expresses INTENT. It is not what makes the gate environment-independent -- that is ``tracked_paths()`` never consulting the filesystem, pinned by diff --git a/tests/test_private_paths_stay_ignored.py b/tests/test_private_paths_stay_ignored.py index 2480d614..e8d77147 100644 --- a/tests/test_private_paths_stay_ignored.py +++ b/tests/test_private_paths_stay_ignored.py @@ -35,7 +35,7 @@ # The private-path rules, verbatim from .gitignore's publishing-boundary block, each with a probe # path that must fall under it. Pinned deliberately — see the module docstring. _PRIVATE_PATHS: list[tuple[str, str]] = [ - ("/.claude/", ".claude/probe-327.md"), + ("/.claude/*", ".claude/probe-327.md"), ("/TRANSCRIPTS.md", "TRANSCRIPTS.md"), ("/docs/security/", "docs/security/probe-327.md"), ("/docs/reviews/", "docs/reviews/probe-327.md"), @@ -43,6 +43,20 @@ ("/docs/CI-TOPOLOGY.md", "docs/CI-TOPOLOGY.md"), ] +# The ONE negated path in the block, and the only tracked file any private rule may cover. +# +# `/.claude/` became `/.claude/*` plus `!/.claude/settings.json` so the enforced controls -- the +# deny-list and the `block-blanket-git-stage` PreToolUse guard -- reach a fresh clone and every +# `git worktree add`, which deliver tracked files only. Before that, this repo's own #327 note +# recorded the guard as one that "does not actually travel" and said not to count it as coverage. +# +# This is an exact SET, not a floor. Adding a second negation to the block -- `.claude/rules/`, +# `.claude/skills/`, an agent definition, anything -- fails here until someone writes it down, and +# `.claude/worktrees/` reaching this set would publish full nested checkouts. +_TRACKED_EXCEPTIONS: dict[str, frozenset[str]] = { + "/.claude/*": frozenset({".claude/settings.json"}), +} + def _git(*args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( # nosec B603 B607 - fixed argv, no shell @@ -73,14 +87,22 @@ def test_nothing_under_a_private_path_is_tracked(rule: str, probe: str) -> None: Git does not ignore a file it is already tracking, so a path committed before its rule landed stays tracked forever and `check-ignore` will still cheerfully report it as ignored. Ignoring and not-publishing are different properties; this asserts the second one. + + Asserted as an exact SET against `_TRACKED_EXCEPTIONS`, which is empty for every rule but the + `.claude/` one. A floor ("at least these are absent") would pass while a new negation quietly + published a second file; the set makes each addition a deliberate, reviewed edit. """ - pathspec = rule.lstrip("/") + pathspec = rule.rstrip("*").lstrip("/") res = _git("ls-files", "--", pathspec) - tracked = [ln for ln in res.stdout.splitlines() if ln.strip()] - assert not tracked, ( - f"{len(tracked)} file(s) under the private rule {rule!r} are TRACKED and would publish:\n " - + "\n ".join(tracked[:10]) - + "\nThey are ignored in name only — git does not ignore what it already tracks." + tracked = {ln for ln in res.stdout.splitlines() if ln.strip()} + expected = _TRACKED_EXCEPTIONS.get(rule, frozenset()) + assert tracked == expected, ( + f"the tracked set under the private rule {rule!r} is not what _TRACKED_EXCEPTIONS pins.\n" + f" expected: {sorted(expected) or '(nothing)'}\n" + f" actual: {sorted(tracked) or '(nothing)'}\n" + "A file that appears here publishes. A file that disappears means a control stopped " + "travelling to fresh clones and worktrees. Either way, update this pin in the SAME commit " + "or revert the change -- git does not ignore what it already tracks." ) @@ -96,3 +118,37 @@ def test_the_pinned_list_has_not_silently_shrunk() -> None: "fine — raise this number in the same commit. Removing one means the publishing boundary " "narrowed, which is a decision, not a cleanup." ) + + +def test_the_negation_re_includes_exactly_one_file() -> None: + """The `!` line is load-bearing on a public repo, and it is one character from being a no-op. + + Written as `/.claude/` the directory itself would be excluded, and git cannot re-include a file + whose parent directory is excluded — the negation would parse fine, apply to nothing, and leave + `settings.json` untracked with no error anywhere. The contents form `/.claude/*` is what makes it + work, so the asymmetry is asserted rather than assumed: one path un-ignored, its siblings not. + + The siblings matter beyond hygiene. `rules/`, `skills/` and `agents/` are the directories a + future session is most likely to reach for, and each would reach exactly one checkout while + looking repo-wide — the same delivery failure this whole block exists to close. + """ + negated = _git("check-ignore", "-q", "--no-index", ".claude/settings.json") + assert negated.returncode != 0, ( + "`.claude/settings.json` is IGNORED — the `!/.claude/settings.json` negation is not taking " + "effect. Check that the rule above it is `/.claude/*` and not `/.claude/`: a negation cannot " + "re-include a file whose parent directory is excluded, and it fails silently when it can't." + ) + + for sibling in ( + ".claude/settings.local.json", + ".claude/worktrees/probe-327/CLAUDE.md", + ".claude/rules/probe-327.md", + ".claude/skills/probe-327/SKILL.md", + ".claude/agents/probe-327.md", + ): + res = _git("check-ignore", "-q", "--no-index", sibling) + assert res.returncode == 0, ( + f"{sibling!r} is NOT ignored. The negation is meant to cover `settings.json` alone; a " + "second `!` line publishes session state or machine-local config. If this path is now " + "meant to travel, pin it in _TRACKED_EXCEPTIONS and say why in .gitignore." + )