From b8da5fd0236ebdaaefcfe96c50eb3077954bff11 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 13 Aug 2026 06:06:33 -0500 Subject: [PATCH 1/2] config: track .claude/settings.json, and fix what it silently was not enforcing `/.claude/` ignored the directory, so `.claude/settings.json` was never tracked and `git worktree add` could not deliver it -- the same failure the neighbouring comment already records for CLAUDE.md. Measured 2026-08-13: of 62 local checkouts carrying CLAUDE.md, only 12 had `.claude/settings.json`. The other 50 ran with no deny-list over `.env` / `secrets/**` / keys / the local `*.db` store, and no `block-blanket-git-stage` PreToolUse guard. Those are client-ENFORCED controls; CLAUDE.md section 5's prose is context, not enforcement, so it does not substitute. BACKLOG #327 recorded the same gap from the other side -- "it is wired through .claude/settings.json, which is itself inside the now-gitignored /.claude/ tree and untracked ... Do not count it as coverage" -- and carried the stale `.gitignore:84` comment to the owner rather than fixing it. Both are resolved here. Ignore by contents (`/.claude/*`) rather than by directory, then re-include the one file. The directory form would have made the negation a silent no-op, because git cannot re-include a file whose parent directory is excluded. `/.claude/worktrees/` is now named explicitly instead of relying on that wildcard plus a per-clone `.git/info/exclude` line that reaches nobody else. Publishing the file made two of its defects load-bearing, so both are fixed here rather than shipped: * Both hook commands were bare `pwsh -NoProfile -File scripts/hooks/...`, which resolves against the session's working directory. A hook that cannot start is NON-BLOCKING -- the action proceeds and the only trace is a notice -- so the staging guard read as enforced in the file and was absent in any session started elsewhere. Now `${CLAUDE_PROJECT_DIR}` in exec form. * All 16 file deny rules used the `./` anchor, which matches one directory. Bare patterns follow gitignore semantics and match at any depth, so `Read(.env)` is strictly broader than `Read(./.env)` and reads identically in review. Prefix dropped. Three `Get-Content` denies added for the PowerShell path, which the documented Read/Edit deny coverage does not reach. The allow list went from 11 hyper-literal command strings to 5 wildcards. Allow rules are the only permission rules gated on the workspace trust dialog, so they applied in none of the untrusted checkouts anyway; the literals also could not survive an argument change, which is how the list grew three near-duplicate pytest invocations. Deny rules are not trust-gated and are evaluated before the auto-mode classifier, so they are the half worth getting right. tests/test_private_paths_stay_ignored.py FAILED on the previous commit and that was correct: it asserts nothing under a private rule is tracked, and tracking settings.json violated that. Pre-commit does not run pytest, so nothing caught it at commit time. Updated deliberately, per that file's own doctrine -- the tracked set under `.claude/` is now pinned as an exact SET, not a floor, so a second negation fails the build instead of publishing. Its new companion test asserts the asymmetry directly: settings.json un-ignored, and `rules/`, `skills/`, `agents/`, `worktrees/` and `settings.local.json` still ignored. tests/test_claude_settings_contract.py is new and covers the payload: the PHI and secret denies are present, no rule regresses to `./`, every hook anchors to the project root, and every referenced script exists. Both absence checks carry a planted-omission self-test, because an absence assertion over a currently-correct file passes just as well when the check itself is broken. `.claude/` also leaves link_check.py's WITHHELD tuple. It was exempt because 7 links pointed at a file no clone had; all 7 name settings.json, which is now tracked, so they resolve honestly and are COUNTED -- the exemption `continue`d before `checked += 1`, so those links were never in the total. Repo-wide link count moves 5359 to 5405. CONTRIBUTING.md discloses what cloning now configures: two PowerShell scripts wired to SessionStart and PreToolUse, that they need pwsh and fail open without it, and that the deny rules anchor at the directory the agent was started in and do not cover writes into sibling worktrees by absolute path. --- .claude/settings.json | 93 ++++++++++++ .gitignore | 30 +++- CONTRIBUTING.md | 24 +++ scripts/docs/link_check.py | 33 ++--- tests/test_claude_settings_contract.py | 178 +++++++++++++++++++++++ tests/test_link_resolution.py | 8 +- tests/test_private_paths_stay_ignored.py | 70 ++++++++- 7 files changed, 405 insertions(+), 31 deletions(-) create mode 100644 .claude/settings.json create mode 100644 tests/test_claude_settings_contract.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..782ff6af --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,93 @@ +{ + "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:*)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(git *)", + "command": "pwsh", + "args": [ + "-NoProfile", + "-File", + "${CLAUDE_PROJECT_DIR}/scripts/hooks/block-blanket-git-stage.ps1" + ], + "timeout": 20, + "statusMessage": "Checking git staging" + } + ] + }, + { + "matcher": "PowerShell", + "hooks": [ + { + "type": "command", + "if": "PowerShell(git *)", + "command": "pwsh", + "args": [ + "-NoProfile", + "-File", + "${CLAUDE_PROJECT_DIR}/scripts/hooks/block-blanket-git-stage.ps1" + ], + "timeout": 20, + "statusMessage": "Checking git staging" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "pwsh", + "args": [ + "-NoProfile", + "-File", + "${CLAUDE_PROJECT_DIR}/scripts/worktree/session-context.ps1" + ] + } + ] + } + ] + } +} 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." + ) From b10eafafcb6e6a70ba9fa419a4eb2bf0b5ae5863 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 13 Aug 2026 18:30:28 -0500 Subject: [PATCH 2/2] config: hold the hooks block, land only the deny-list (owner: "do a") The owner chose option (a) on PR #373: land the 27-rule deny-list, HOLD the three hook registrations. Held is NOT rejected -- no verdict was given on the hooks, and no later change may cite this as one. The two halves have different risk profiles, which is why they separate: DENY-LIST 27 rules mirroring CLAUDE.md section 5 almost literally -- .env, .env.*, secrets/**, *.key, *.pem, *.pfx, *.db, *.db-wal, *.db-shm, bootstrap-admin.txt. The repo's own written policy as enforcement. HOOKS PreToolUse (Bash, PowerShell) -> block-blanket-git-stage.ps1, and SessionStart -> session-context.ps1. Changes what EXECUTES on every matching tool call in every checkout. A deny-list can only refuse; a PreToolUse hook runs code. KNOWN INTERIM CONDITION, measured rather than assumed: with no `hooks` key, test_every_hook_resolves_through_the_project_dir_placeholder and test_every_hook_script_actually_exists iterate an EMPTY set -- 0 handlers -- so they pass VACUOUSLY. Nothing fails; two guards quietly stop guarding until the hooks decision is made, at which point both become live again automatically. This file's own test_the_checks_can_actually_fail exists because absence assertions over a correct file are "the shape that passes just as well when the check is broken" -- but it runs against PLANTED documents, so it passes and does NOT catch this vacuity. The negative control does not cover an empty input set. Verified: 7 passed in tests/test_claude_settings_contract.py; deny 27, allow 5, dot-anchored denies 0, hook handlers 0. --- .claude/settings.json | 53 ------------------------------------------- 1 file changed, 53 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 782ff6af..321c1dbf 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -36,58 +36,5 @@ "PowerShell(git push -f:*)", "PowerShell(git reset --hard:*)" ] - }, - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "if": "Bash(git *)", - "command": "pwsh", - "args": [ - "-NoProfile", - "-File", - "${CLAUDE_PROJECT_DIR}/scripts/hooks/block-blanket-git-stage.ps1" - ], - "timeout": 20, - "statusMessage": "Checking git staging" - } - ] - }, - { - "matcher": "PowerShell", - "hooks": [ - { - "type": "command", - "if": "PowerShell(git *)", - "command": "pwsh", - "args": [ - "-NoProfile", - "-File", - "${CLAUDE_PROJECT_DIR}/scripts/hooks/block-blanket-git-stage.ps1" - ], - "timeout": 20, - "statusMessage": "Checking git staging" - } - ] - } - ], - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "pwsh", - "args": [ - "-NoProfile", - "-File", - "${CLAUDE_PROJECT_DIR}/scripts/worktree/session-context.ps1" - ] - } - ] - } - ] } }