|
| 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 | + ) |
0 commit comments