Skip to content

Commit 7d873ec

Browse files
authored
chore(claude): track .claude/settings.json and fix the rules it was not enforcing (#373)
* 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. * 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.
1 parent 0424bfa commit 7d873ec

7 files changed

Lines changed: 352 additions & 31 deletions

File tree

.claude/settings.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"PowerShell(*ruff.exe *)",
5+
"PowerShell(*mypy.exe *)",
6+
"PowerShell(Select-Object *)",
7+
"PowerShell(git checkout main 2>&1)",
8+
"PowerShell(git pull --ff-only 2>&1)"
9+
],
10+
"deny": [
11+
"Read(.env)",
12+
"Read(.env.*)",
13+
"Read(secrets/**)",
14+
"Read(*.key)",
15+
"Read(*.pem)",
16+
"Read(*.pfx)",
17+
"Read(*.db)",
18+
"Read(*.db-wal)",
19+
"Read(*.db-shm)",
20+
"Read(bootstrap-admin.txt)",
21+
"Edit(.env)",
22+
"Edit(.env.*)",
23+
"Edit(secrets/**)",
24+
"Edit(*.db)",
25+
"Write(.env)",
26+
"Write(secrets/**)",
27+
"PowerShell(Get-Content *.env*)",
28+
"PowerShell(Get-Content *secrets*)",
29+
"PowerShell(Get-Content *.db*)",
30+
"Bash(rm -rf:*)",
31+
"Bash(git push --force:*)",
32+
"Bash(git push -f:*)",
33+
"Bash(git reset --hard:*)",
34+
"PowerShell(Remove-Item -Recurse -Force:*)",
35+
"PowerShell(git push --force:*)",
36+
"PowerShell(git push -f:*)",
37+
"PowerShell(git reset --hard:*)"
38+
]
39+
}
40+
}

.gitignore

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,11 @@ base-tree/
8181
dast-auth-receipt.json
8282
canary-*.json
8383

84-
# Claude Code: settings.json is shared/tracked; settings.local.json is machine-local (never commit)
84+
# Claude Code: settings.json is shared/tracked; settings.local.json is machine-local (never commit).
85+
# That first clause was FALSE from the day `/.claude/` landed until the publishing-boundary block
86+
# below was reshaped to `/.claude/*` plus a negation -- BACKLOG #327's DONE note carried the
87+
# contradiction to the owner rather than editing it in that lane. It is true again now. The rule
88+
# below is redundant with `/.claude/*` and is kept as the statement of intent for this one file.
8589
.claude/settings.local.json
8690

8791
# Local reference notes pointing at machine-specific Claude Code transcript paths — never commit
@@ -139,7 +143,29 @@ scripts/security/scan-tokens.local.txt
139143
# two-line redaction). CLAUDE.md in particular MUST be tracked: it is gitignored-by-default's worst
140144
# case here, because `git worktree add` cannot deliver an untracked file, so every worktree silently
141145
# came up with ZERO project conventions loaded.
142-
/.claude/
146+
#
147+
# `.claude/` is ignored by CONTENTS (`/.claude/*`), NOT as a directory, so `settings.json` can be
148+
# re-included on the next line. Ignoring the directory itself would make that negation a silent
149+
# no-op: git cannot re-include a file whose parent directory is excluded. Everything else under
150+
# `.claude/` stays ignored by the same star rule -- `settings.local.json` (machine-local, also
151+
# named at line 85), `worktrees/` (session state, not configuration), and anything Claude Code
152+
# adds there later, which is ignored by DEFAULT rather than by enumeration.
153+
#
154+
# WHY settings.json IS TRACKED. It carries ENFORCED controls -- the deny-list covering `.env`,
155+
# `secrets/**`, keys and the local `*.db` store, plus the `block-blanket-git-stage` PreToolUse
156+
# guard -- and settings are enforced by the client where CLAUDE.md is only context, so section 5's
157+
# prose is not a substitute for it. Untracked, it hit the exact CLAUDE.md failure described above:
158+
# measured 2026-08-13, of 62 local checkouts carrying CLAUDE.md only 12 had `.claude/settings.json`,
159+
# so 50 ran with no deny-list and no staging guard. `git worktree add` delivers tracked files only.
160+
/.claude/*
161+
!/.claude/settings.json
162+
# Named explicitly rather than left to `/.claude/*` above. Until this line, the only travelling
163+
# protection for the session tree was that wildcard -- `.git/info/exclude` carries it too, but that
164+
# file is per-clone and reaches nobody else. A later edit that narrows the wildcard or adds a second
165+
# negation would expose full nested checkouts carrying `.venv`, `messagefoundry.db` and the caches.
166+
# NOTHING under `.claude/` may be negated except `settings.json`; tests/test_private_paths_stay_ignored.py
167+
# pins the tracked set so widening it fails a required check instead of shipping.
168+
/.claude/worktrees/
143169
/TRANSCRIPTS.md
144170
/docs/security/
145171
/docs/reviews/

CONTRIBUTING.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,30 @@ concrete features go in **Issues**; security vulnerabilities go through a
127127
Building two changes in parallel? Don't share one checkout — give each its own **git worktree**
128128
(`scripts\worktree\new.ps1 -Name <x>`). See [docs/WORKTREES.md](docs/WORKTREES.md).
129129

130+
### If you use Claude Code: this repo ships two hooks
131+
132+
[`.claude/settings.json`](.claude/settings.json) is **tracked**, so cloning this repo configures
133+
Claude Code, and you should read it before you trust it. It is the only tracked file under
134+
`.claude/`; everything else there is session state and stays ignored.
135+
136+
- **It wires two PowerShell scripts to run automatically.**
137+
[`scripts/hooks/block-blanket-git-stage.ps1`](scripts/hooks/block-blanket-git-stage.ps1) runs
138+
before any git command the agent issues, and
139+
[`scripts/worktree/session-context.ps1`](scripts/worktree/session-context.ps1) runs at session
140+
start. Both are in-repo, reviewable, and covered by the same review as any other script here.
141+
- **They need PowerShell 7 (`pwsh`).** A hook that cannot start is **non-blocking** — the action
142+
proceeds and you get a notice, not a refusal. So on a machine without `pwsh` the staging guard is
143+
absent rather than failing loudly. Do not treat it as coverage you can rely on; the leak gate
144+
above is the control that fails closed.
145+
- **The deny rules cover the directory you started the agent in.** They keep `.env`, `secrets/`,
146+
keys and the local `*.db` store away from the agent's file tools at any depth *below that
147+
directory*. A session started in one checkout that writes into a sibling worktree by absolute path
148+
is outside them. The rules are a guard against accident and drift, not against a determined
149+
operator, and they are not a substitute for the leak gate.
150+
151+
None of this is required to contribute. Delete the file locally if you would rather configure your
152+
own; `git update-index --skip-worktree .claude/settings.json` keeps that local.
153+
130154
## PHI / safety
131155

132156
This engine carries PHI in real deployments. **Never** commit real patient data — tests and

scripts/docs/link_check.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,11 @@
2020
repository invariant.
2121
* **Fragments.** ``#some-anchor`` is not validated here; only the path is. Heading slugs churn on
2222
every retitle and would make this noisy.
23-
* **Withheld directories.** ``docs/security/``, ``docs/reviews/``, ``docs/marketing/``,
24-
``docs/releases/`` and ``.claude/`` are gitignored. The master test plan states a missing path
25-
there is a deliberate publishing boundary, not a defect, so flagging them would train readers to
26-
ignore the gate. ``.claude/`` is the instructive one: it is *present* in a long-lived local
27-
checkout and absent from CI's clean clone, so omitting it makes this checker pass locally and fail
28-
on the runner.
23+
* **Withheld directories.** ``docs/security/``, ``docs/reviews/``, ``docs/marketing/`` and
24+
``docs/releases/`` are gitignored. The master test plan states a missing path there is a
25+
deliberate publishing boundary, not a defect, so flagging them would train readers to ignore the
26+
gate. ``.claude/`` was a fifth entry until ``.claude/settings.json`` became tracked; every link
27+
the exemption covered pointed at that one file, so they are now checked like any other.
2928
* **Fenced code.** A path inside ``` is sample output being shown, not a link to follow.
3029
* **Inline code.** A link inside backticks is being *displayed*, not offered -- the same argument as
3130
fenced code, at smaller scale. Four real sites turn on it: a regex whose character class contains
@@ -59,24 +58,22 @@
5958
# docs/releases/ joined when ADR 0160 Phase 1 untracked it (.gitignore carries "/docs/releases/") --
6059
# an archived throughput doc still cites the v0.1 plan that moved out with it.
6160
#
62-
# .claude/ (.gitignore:142) is exempt on the SAME publishing-boundary grounds -- 7 docs link to
63-
# .claude/settings.json, which no clone has.
61+
# .claude/ WAS a fifth entry, exempt because 7 links pointed at .claude/settings.json and no clone
62+
# had it. It is gone because the premise is: settings.json is tracked now, so those 7 resolve
63+
# through tracked_paths() like every other link and are counted rather than skipped.
6464
#
65-
# It is listed here as POLICY, not as protection. It was originally added as protection, because a
66-
# filesystem fallback made those 7 links pass in a long-lived local checkout and fail on CI's clean
67-
# clone -- the first repo-wide measurement was taken in such a checkout and undercounted by exactly
68-
# 7. That hazard is now closed STRUCTURALLY in tracked_paths(): resolution never consults the
69-
# filesystem, so no gitignored-but-present path can pass locally and fail on the runner, listed here
70-
# or not. Removing this entry would make those 7 links fail honestly and identically everywhere.
71-
# Keeping an enumerated exemption as the reason a control holds is the compensating-control-on-a-
72-
# false-premise shape (CLAUDE.md section 11, SDS-3.7); the enumeration expresses intent, the
73-
# resolver provides the guarantee.
65+
# Measured before removing it: all 7 markdown links whose href names a .claude/ path name
66+
# settings.json and nothing else, so nothing else loses its exemption. That mattered, because the
67+
# exemption `continue`s BEFORE `checked += 1` -- a withheld href is not merely resolved, it is never
68+
# counted, which #327 demonstrated by planting a missing path under .claude/ and watching the total
69+
# stay at 5359 and the run stay green. An exemption that hides its own coverage gap is the
70+
# compensating-control-on-a-false-premise shape (CLAUDE.md section 11, SDS-3.7). Keep this tuple to
71+
# genuinely unpublished trees; a path that ships belongs in the gate.
7472
WITHHELD = (
7573
"docs/security/",
7674
"docs/reviews/",
7775
"docs/marketing/",
7876
"docs/releases/",
79-
".claude/",
8077
)
8178

8279
_LINK = re.compile(r"\]\((?P<href>[^)\s]+?)(?P<frag>#[^)\s]*)?\)")
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
# Copyright (C) 2026 MessageFoundry Organization and contributors
3+
"""`.claude/settings.json` is now a TRACKED control, so its shape gets a test.
4+
5+
Tracking the file (see `tests/test_private_paths_stay_ignored.py` for the boundary half) is what
6+
carries the deny-list and the `block-blanket-git-stage` guard to a fresh clone and to every
7+
`git worktree add`. That only buys anything if the payload still works when it arrives, and the two
8+
ways it silently stops working are both invisible to review:
9+
10+
* **A hook that cannot start does not block.** Claude Code's hooks reference is explicit that a
11+
command hook which fails to launch "lands in the same non-blocking bucket" and that for most
12+
events "the action proceeds". A hook path written bare, as `scripts/hooks/x.ps1`, resolves
13+
against the session's current directory, not the repo — so in any session started outside the
14+
repo root it never runs, the guard reads as enforced in the file, and nothing reports it. The
15+
fix is `${CLAUDE_PROJECT_DIR}` in exec form, and this file pins it.
16+
* **A deny rule anchored at `./` covers one directory.** Bare patterns follow gitignore semantics
17+
and match at any depth; `Read(./.env)` matches `<cwd>/.env` and nothing below it. The `./` form
18+
looks equivalent and is strictly narrower, which is the worst combination for a control whose
19+
whole job is to be broad.
20+
21+
Neither is caught by JSON validity, by `pre-commit`, or by reading the diff. Both are caught here.
22+
23+
The deny-list is also the only half of this file that auto mode cannot touch: permission deny rules
24+
are evaluated before the classifier, and unlike `allow` rules they are not gated on the workspace
25+
trust dialog. That is why the pinned subset below is the deny rules and not the allow rules.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import json
31+
from pathlib import Path
32+
from typing import Any
33+
34+
import pytest
35+
36+
_ROOT = Path(__file__).resolve().parents[1]
37+
_SETTINGS = _ROOT / ".claude" / "settings.json"
38+
39+
# The rules whose loss would be silent and would matter. Not the whole deny-list: the point is a
40+
# floor under the PHI, secret and local-store rules that CLAUDE.md section 5 and section 9 promise
41+
# are enforced, so prose and mechanism cannot drift apart without a red test.
42+
_REQUIRED_DENIES = frozenset(
43+
{
44+
"Read(.env)",
45+
"Read(secrets/**)",
46+
"Read(*.db)",
47+
"Edit(.env)",
48+
"Edit(secrets/**)",
49+
"Write(.env)",
50+
"Write(secrets/**)",
51+
}
52+
)
53+
54+
_PLACEHOLDER = "${CLAUDE_PROJECT_DIR}"
55+
56+
57+
def _load() -> dict[str, Any]:
58+
return json.loads(_SETTINGS.read_text(encoding="utf-8"))
59+
60+
61+
def _hook_handlers(settings: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
62+
"""Flatten `hooks.<event>[].hooks[]` into (event, handler) pairs."""
63+
out: list[tuple[str, dict[str, Any]]] = []
64+
for event, groups in settings.get("hooks", {}).items():
65+
for group in groups:
66+
for handler in group.get("hooks", []):
67+
out.append((event, handler))
68+
return out
69+
70+
71+
def _repo_script_refs(handler: dict[str, Any]) -> list[str]:
72+
"""Every token in a handler that names a file under the repo's script trees."""
73+
tokens = [handler.get("command", ""), *handler.get("args", [])]
74+
return [t for t in tokens if isinstance(t, str) and (".ps1" in t or ".py" in t)]
75+
76+
77+
def _unanchored_refs(settings: dict[str, Any]) -> list[str]:
78+
return [
79+
f"{event}: {ref}"
80+
for event, handler in _hook_handlers(settings)
81+
for ref in _repo_script_refs(handler)
82+
if not ref.startswith(_PLACEHOLDER)
83+
]
84+
85+
86+
def _dot_anchored_denies(settings: dict[str, Any]) -> list[str]:
87+
return [r for r in settings["permissions"]["deny"] if "(./" in r]
88+
89+
90+
def test_settings_is_valid_json() -> None:
91+
"""A malformed tracked settings file is a repo-wide outage, not a local one."""
92+
assert _load()["permissions"], "permissions block is missing or empty"
93+
94+
95+
def test_the_phi_and_secret_denies_are_all_present() -> None:
96+
deny = set(_load()["permissions"]["deny"])
97+
missing = _REQUIRED_DENIES - deny
98+
assert not missing, (
99+
f"{len(missing)} required deny rule(s) are gone: {sorted(missing)}.\n"
100+
"These are what CLAUDE.md sections 5 and 9 point at when they say secrets and the local "
101+
"store are off limits. Removing one makes that prose false. Deny rules cost nothing when "
102+
"unused and are the only permission rules auto mode cannot override."
103+
)
104+
105+
106+
def test_no_deny_rule_uses_the_narrow_dot_anchor() -> None:
107+
"""`Read(./secrets/**)` matches one directory; `Read(secrets/**)` matches every depth."""
108+
narrow = _dot_anchored_denies(_load())
109+
assert not narrow, (
110+
f"{len(narrow)} deny rule(s) use the `./` anchor and match at one depth only: {narrow}.\n"
111+
"Drop the prefix. A nested copy of the path -- a vendored tree, a worktree checked out "
112+
"inside the repo, a fixture directory -- is outside a `./`-anchored rule and inside a bare "
113+
"one, and the two forms read identically in review."
114+
)
115+
116+
117+
def test_every_hook_resolves_through_the_project_dir_placeholder() -> None:
118+
unanchored = _unanchored_refs(_load())
119+
assert not unanchored, (
120+
f"{len(unanchored)} hook script reference(s) are not anchored to the project root: "
121+
f"{unanchored}.\n"
122+
"A bare path resolves against the session's working directory. When it misses, the hook "
123+
"fails to start, the action PROCEEDS, and the only trace is a non-blocking notice -- so the "
124+
f"guard is absent exactly when someone is working somewhere unusual. Use {_PLACEHOLDER} "
125+
"with `args` (exec form), which is substituted as a plain string with no shell re-parsing."
126+
)
127+
128+
129+
def test_every_hook_script_actually_exists() -> None:
130+
"""An anchored path that points at nothing fails open just as quietly as an unanchored one."""
131+
missing = [
132+
ref
133+
for _event, handler in _hook_handlers(_load())
134+
for ref in _repo_script_refs(handler)
135+
if not (_ROOT / ref.replace(_PLACEHOLDER + "/", "")).is_file()
136+
]
137+
assert not missing, (
138+
f"hook(s) reference script(s) that are not in the repo: {missing}.\n"
139+
"Renaming or moving a hook script without updating .claude/settings.json disables the hook "
140+
"silently in every clone."
141+
)
142+
143+
144+
@pytest.mark.parametrize(
145+
("planted", "checker", "label"),
146+
[
147+
(
148+
{
149+
"permissions": {"deny": []},
150+
"hooks": {
151+
"PreToolUse": [
152+
{"hooks": [{"command": "pwsh", "args": ["-File", "scripts/hooks/x.ps1"]}]}
153+
]
154+
},
155+
},
156+
_unanchored_refs,
157+
"bare relative hook path",
158+
),
159+
(
160+
{"permissions": {"deny": ["Read(./.env)"]}, "hooks": {}},
161+
_dot_anchored_denies,
162+
"dot-anchored deny rule",
163+
),
164+
],
165+
ids=["unanchored-hook", "dot-anchored-deny"],
166+
)
167+
def test_the_checks_can_actually_fail(planted: dict[str, Any], checker: Any, label: str) -> None:
168+
"""A guard that cannot be shown to fail is not a guard.
169+
170+
Both checks above are absence assertions over a file that is currently correct, which is the
171+
shape that passes just as well when the check is broken -- the failure mode this repo has
172+
already recorded twice (`tests/test_feature_map_claims.py`, the `.claude/` link-gate exemption).
173+
Each detector is run here against a settings document carrying exactly the defect it hunts.
174+
"""
175+
assert checker(planted), (
176+
f"the {label} detector returned nothing for a document that contains one. The "
177+
"corresponding test above is passing for the wrong reason and is not protecting anything."
178+
)

tests/test_link_resolution.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,18 @@ def test_withheld_prefixes_are_the_gitignored_ones(checker) -> None:
105105
"docs/reviews/",
106106
"docs/marketing/",
107107
"docs/releases/",
108-
".claude/",
109108
}
110109

111110

112111
@pytest.mark.parametrize(
113112
"prefix",
114-
["docs/security/", "docs/reviews/", "docs/marketing/", "docs/releases/", ".claude/"],
113+
["docs/security/", "docs/reviews/", "docs/marketing/", "docs/releases/"],
115114
)
116115
def test_withheld_directories_are_not_flagged(tmp_path, checker, prefix: str) -> None:
117116
"""A gitignored target is a publishing boundary, not a defect; flagging it trains people to
118-
ignore the gate. ``docs/releases/`` joined when ADR 0160 Phase 1 untracked it; ``.claude/``
119-
joined because 7 docs link to ``.claude/settings.json``, which no clone has.
117+
ignore the gate. ``docs/releases/`` joined when ADR 0160 Phase 1 untracked it. ``.claude/``
118+
LEFT once ``.claude/settings.json`` became tracked: all 7 links it covered named that one file,
119+
so they now resolve through ``tracked_paths()`` and are counted instead of skipped.
120120
121121
This list expresses INTENT. It is not what makes the gate environment-independent -- that is
122122
``tracked_paths()`` never consulting the filesystem, pinned by

0 commit comments

Comments
 (0)