Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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:*)"
]
}
}
30 changes: 28 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/
Expand Down
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <x>`). 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
Expand Down
33 changes: 15 additions & 18 deletions scripts/docs/link_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<href>[^)\s]+?)(?P<frag>#[^)\s]*)?\)")
Expand Down
178 changes: 178 additions & 0 deletions tests/test_claude_settings_contract.py
Original file line number Diff line number Diff line change
@@ -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 `<cwd>/.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.<event>[].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."
)
8 changes: 4 additions & 4 deletions tests/test_link_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading