From 9c704edf3170306709f23968a5be80887124406f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:12:55 -0400 Subject: [PATCH 01/14] docs(topics): lock the permission-model plan and design resolution Records the contract and implementation plan for two new claude-config skills: audit-permission-state, which computes the effective merged permission state across all five settings scopes with per-rule provenance, and draft-auto-mode-rules, the non-writing authoring lane. The plan is ten phases, sequential. Phase 0 discharges the fresh-docs mandate before any version-gate constant ships. Phase 9 moved ahead of the skill work because it owns two seams that do not exist yet: a fixture-home override so user-global scope is testable without reading the real home, and a side-effect-free extraction of the shared pattern vocabulary, which today lives in a script that self-executes at load. Two probes settled questions the Brief had parked. The debug channel narrates the permission merge per destination and names every allow rule auto mode drops, with its source path and reason; it becomes an opt-in, explicitly priced oracle cross-checked against the computed prediction, not a replacement for it. A non-strict JSON parser is genuinely required for one optional lane: the malformed CLI output carries a raw line feed inside a string value, so no line-oriented filter repairs it, which puts Python in the optional-feature class and leaves the portable core intact. A fresh-context reviewer returned nineteen findings against an earlier draft. All nineteen were re-verified against the repository and all held. Both critical ones were stale premises: PR #2089 had already merged with its OFFICIAL-DOCS row, the frontmatter-name refactor had landed on main, and this slice was untracked on a spent branch and absent from main. Two items are left open rather than guessed: which second existing check the Brief meant by widening two, and what a -p oracle session writes outside its scratch path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/topics/permission-model/PLAN.md | 700 ++++++++++++++++++ .../design/design-resolution.md | 76 ++ 2 files changed, 776 insertions(+) create mode 100644 docs/topics/permission-model/PLAN.md create mode 100644 docs/topics/permission-model/design/design-resolution.md diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md new file mode 100644 index 0000000000..ad901cd270 --- /dev/null +++ b/docs/topics/permission-model/PLAN.md @@ -0,0 +1,700 @@ +# permission-model + +## Brief + +### TLDR + +Add **two** new skills to the `claude-config` plugin. `audit-permission-state` computes the +**effective merged Claude Code permission state** across all five settings scopes with per-rule +provenance, plus a decidability-bounded set of lints over that state and over the `autoMode` block; +`draft-auto-mode-rules` is the authoring lane, drafting an `autoMode` block to stdout for a human to +paste. Both audit or generate and print; neither enforces and neither writes configuration. Existing +`claude-config` permission checks get in-place scope widening at the same time — **one of the two the +Brief promised is still unassigned; see the OPEN block after Phase 9.** No new plugin. + +_Amended 2026-08-09: one skill → two, on the operator's admission of the authoring lane. `MIGRATION-PLAYBOOK.md` +§Naming binds a skill name to its kind, so an `audit-*` skill cannot host a `draft` action._ + +### Goal + +Close the legibility gap in Claude Code's permission plane for downstream consumers of this +marketplace. + +Research established the gap is total: there is no `claude permissions` CLI subcommand (established +by controlled live probe on 2.1.225 — the same probe form returns real usage for seven other hidden +commands and falls back to top-level usage for `permissions`), no documented machine-readable export +of resolved permission state, and across roughly thirty third-party tools nobody audits an `autoMode` +block, resolves cross-scope precedence, or validates a managed policy against the scopes beneath it. +The highest-adoption linter in the space carries one permission rule out of 447 and zero +managed-settings coverage. The official plugin marketplace ships 284 plugins and none of them manage +permission configuration. + +Demand is documented rather than assumed: measured dead allow rules written by the harness's own +"Always allow" path (77 of 154 rules in one Windows project; 6 of 9 on macOS), a session carrying 900+ +allow rules still prompting 700+ times in two days, and two production fleet incidents caused by the +managed-tier no-merge rule. + +Auto mode becomes the default permission mode for new sessions on Pro, Max, and Team plans on +2026-08-14. That does not create the gap, but it widens it: broad allow rules and every `Agent` allow +rule are silently dropped on entering auto mode, and consumers have no way to see that happen. + +### Constraints + +**Product shape** + +- Audit and legibility first. Auto-mode authoring is a second lane, not v1's core. +- **Report-only.** No enforcement in v1 and no writes to consumer configuration. An enforcing hook, if + ever built, is an explicitly separate opt-in component. Research confirms enforcement is genuinely + available to a plugin — hook `deny` and exit-2 both hold, measured — so this is a deliberate posture + choice, not a capability limit. +- The managed/enterprise tier is in scope, **read-only**. A plugin can never author managed policy: + those are admin-write OS paths or a claude.ai Owner role. + +**Packaging** + +- A new sibling skill under `claude-config`, plus in-place scope widening of two existing checks. + **Not** a new plugin. `marketplace.json`'s `renames` map is a flat plugin→plugin map and cannot + express a partial extraction, so extracting would silently strip `audit-permission-grants` from + every consumer on update. Extraction would also turn `audit-pass/SKILL.md`'s route-out section into an unguarded + cross-plugin reference, which `PLUGIN-PHILOSOPHY.md` §Organization names a defect. + +**Reading state** + +- **Compute** the merge, bounded by a per-item decidability criterion with a **stated basis**. In + scope: anything following from documented mechanics over readable inputs, each claim citing the + mechanic it follows from. Out of scope: classifier judgment, runtime demotion state, and anything + resting only on an open upstream discrepancy — each becomes a **named caveat** on the affected + finding, never a silent drop and never an assertion. +- **Shell out only for `autoMode` built-in defaults.** The changelog does not track the content of the + shipped rule lists (zero matches across all 359 releases), so version-gating them from release notes + is impossible — probe and diff, never infer from a version number. +- **Defensive contract for every CLI read, each item measured on 2.1.225, not assumed:** never a + strict JSON parser (`claude auto-mode config` emits raw control characters inside string values; + `jq` and Python's default `json.loads` both reject, exit code still 0); tolerate a **missing** key, + not merely an empty array (`defaults --label` omits `environment` entirely); split a rule label at + the first `[`, not the first `:` (`soft_deny` labels carry a bracketed annotation before the colon); + **never trust exit status** (`critique` returned 0 on all three runs including the one producing no + output at all). + +**Upstream posture** + +- **Fetch at read time.** Bake no upstream catalogs — not the default rule lists, the block/allow + catalogs, the protected-path enumeration, the version-gate table, or the footgun list. +- Every durable claim derived from upstream carries a recheck trigger naming an **observable event**, + per `docs/conventions/upstream-drift/README.md`. A bare date does not qualify in this repository. +- A record carries claim **and** basis **and** trigger. A trigger without its basis is untestable: the + invalid-JSON defect is machine-conditional (attributed to user-supplied entries spliced without + re-escaping), so "`config` parses under a strict parser" would pass on a clean machine and clear a + defect that was never present. The basis must state the probe. + +**Leverage built-ins, never re-implement their judgment** + +- `claude auto-mode critique` is surfaced and wrapped, never replaced. It owns the semantic judgment + (clarity, completeness, conflicts, actionability). We own only the mechanical layer it does not do. + Its unreliability is wrapped, not hidden: truncation and empty-output detection, with a plain + "critique returned nothing; run it yourself" surface. + +**Surface and output** + +- A skill driving deterministic scripts. No CI-gate entry point. +- Findings use the `review` plugin's `severity.md` vocabulary — the marketplace's neutral baseline. + +### Acceptance criteria + +1. Given a repository and a user home with settings at two or more scopes, the skill reports the + effective merged `permissions.allow` / `.ask` / `.deny` set with the source scope named per rule, + and each precedence claim cites the documented mechanic it follows from. +2. The skill detects **both** dead-config traps at their **two distinct gates** — `autoMode.*` ignored + in project and local settings (local also read before v2.1.207), and `defaultMode: "auto"` ignored + in project and local settings (project could set it before v2.1.142) — and reports each separately. + `useAutoModeDuringPlan`, which is not read from shared project settings, is covered as a third. +3. The skill classifies every allow rule that auto mode drops on entry: blanket `Bash(*)` / + `PowerShell(*)`, wildcarded interpreters, package-manager run commands, and **all `Agent` allow + rules**. +4. The skill detects a `$defaults` omission per `autoMode` section and states which built-in rule list + that omission discards. +5. The skill detects `disableAutoMode` typed as a boolean rather than the string `"disable"`, in any + scope (it is not managed-only). +6. The skill detects allow rules that cannot match — doubled-backslash Windows paths, unanchored allow + globs, `Write(path)`-shaped rules that are accepted but never consulted, and `:*` used anywhere but + at pattern end. +7. The skill reads a pre-v2.1.211 `settings.local.json` copy left in the start directory as well as + the repository-root copy, because permission rules from both stay in effect. +8. Every CLI read survives the measured defect set: invalid JSON, a missing key, a bracket-prefixed + label, and exit 0 on empty output. A run that produced no usable output reports that fact and never + claims success. +9. The skill performs no writes to any consumer settings file, in any scope, under any flag present in + v1. +10. Every finding whose basis is an open upstream discrepancy carries that discrepancy as a named + caveat. +11. `claude-config:audit-permission-grants` check P1 sees user-global rules. Today + `reference/criteria.md`'s settings-scan section scans project and local settings only, so a user-global + interpreter-wildcard rule is invisible to it. +12. `skill-quality:check listing-budget` is run against the resulting shape; the new skill's listing + cost is stated rather than assumed. + +### Captured assumptions + +- Every CLI behavior recorded here comes from a single capture on **Claude Code 2.1.225, Windows 11**, + 2026-08-09. The measured defects are shell-dependent (Git Bash truncated vs PowerShell empty), so + per-platform behavior is assumed similar and **not verified**. Re-probe before acting. +- The five research slices carry no `preload_token` — the `/discovery:research` preload failed + silently across the whole fan-out. Only `permissions-core` received full independent adversarial + verification (7/7 priority claims survived, 15/15 drift hashes reproduced, 3 defects found and + corrected). The other four are cross-checked against each other but not separately verified. +- Roughly fifteen GitHub issues in the ecosystem sweep were title-verified only, bodies not read. +- Reddit was unreachable during the sweep; no absence claim is possible from that venue. +- #83766 and #42797 report `permissions.ask` patterns auto-approved under `defaultMode: auto`, which + contradicts current documented behavior. Treated as **open discrepancies, not settled facts**. + +### Out-of-scope + +- Enforcement of any kind in v1, including the PreToolUse `"ask"` lever that is documented to bind the + classifier in auto mode. +- Writing or generating consumer configuration, including a `--fix` mode. +- Authoring managed policy. Auditing it is in scope; authoring it is structurally impossible for a + plugin. +- Re-implementing the semantic judgment `claude auto-mode critique` performs. +- A new plugin, and the extraction of `audit-permission-grants` out of `claude-config`. Re-opens only + on the trigger recorded under Q5 below. +- Skill naming, which still needs deriving against `MIGRATION-PLAYBOOK.md` §Naming. One live + constraint: `PLUGIN-PHILOSOPHY.md` §Naming requires the namespace noun to be true of every skill under + it, and the locked authoring lane is a mutating verb — `claude-config` survives that test, a + narrower noun would not. + +### Deferred questions + +- **Q11 — RESOLVED 2026-08-09, no longer deferred.** A marketplace-installed plugin's skill + `allowed-tools` grant is **NOT** gated by workspace trust: it takes effect at user scope in a + never-trusted workspace, under `-p` where no trust dialog can appear. Measured on 2.1.225 — the + covered command ran, the uncovered one blocked with `This command requires approval`, and a + no-grant baseline proves that shape blocks. Method, the two stated bounds (local-directory + marketplace, user scope only), and four invalid prior attempts are recorded in + `.work/permission-model/EXPERIMENT-marketplace-allowed-tools-trust.md`. + + **Consequence for this repository, carried into the plugin-acceptance security review:** a skill + this marketplace ships can grant itself a prompt-free tool invocation in a consumer's untrusted + workspace, and the install-time plugin trust prompt is the only gate in front of that — there is no + second, per-workspace one. This does not change v1's scope, which ships no self-grant. + +- **Q12 — CLOSED 2026-08-09, shipped.** PR #2089 merged at `2026-08-09T17:31:08Z`; + `docs/OFFICIAL-DOCS.md:100` now carries + `| Configure auto mode (autoMode, claude auto-mode) | https://code.claude.com/docs/en/auto-mode-config | 2026-08-09 |`. + The USER-RESERVED gate is discharged — nothing here waits on it. + +- **Unprobed CLI surfaces — RESOLVED 2026-08-09 by `/planning:plan`'s own probe** + (`.work/permission-model/EXPERIMENT-debug-channel-merge-narration.md`). The debug channel narrates + the merge per destination with full rule text **and** narrates every auto-mode-dropped allow rule + individually with its absolute source path and reason. `claude config` does **not** exist as a + subcommand (controlled probe: `claude config --help` falls back to top-level usage; control + `claude plugin --help` returns real usage), so there was nothing to enumerate. `claude --safe-mode` + as a differential control was not needed — the drop narration is explicit rather than differential. + Consequence: the computed merge stays as the read path; the channel becomes an optional oracle whose + disagreement with the prediction is itself a finding. Bounds are in the experiment file — scopes + absent from the capture were not disproved, obtaining the narration costs a session spawn, and the + `[DEBUG]` strings carry no stability contract. + +- **Runtime prerequisite and its degradation path.** A non-strict JSON parser implies Python or Node — + a new undeclared runtime prerequisite, which `PLUGIN-PHILOSOPHY.md` §Prerequisites and failure behavior and §Cross-platform contract govern + ("never assume Bash, `jq` … is present"; never execute an undeclared tool as an incidental + fallback). The measured defects are shell-dependent, so the declaration and the degradation path are + per-platform. **Arbiter: `/planning:plan`.** + +- **Fresh-context delegation directive.** `PLUGIN-PHILOSOPHY.md` §Fresh-eyes checkpoints requires any + skill step that judges output the same context produced to state a fresh-context delegation or a + greppable exemption; `skill-quality:check` enforces it. Not yet stated for this skill. **Arbiter: + `/planning:plan`.** + +- **Two-lane security-floor posture.** `audit/reference/required-permissions.md` is a shipped lane-1 + default security floor. Bringing an enterprise audience into scope raises the odds a consuming org + holds its own floor, pushing it toward lane 2 (discover-and-externalize) per + `PLUGIN-PHILOSOPHY.md` §Two-lane convention posture. That re-derivation is owed regardless of this work. **Arbiter: + `/planning:plan`.** + +### Recheck triggers + +- **Plugin extraction re-opens when** the auto-mode authoring lane acquires its first persistent-state + or settings-writing component. +- **The local merge retires when** `claude permissions --help` returns real usage instead of falling + back to the top-level `Usage:` line — the same controlled probe that established its absence. + This trigger is **necessary but not sufficient on its own**: a second, narrower route already + exists. `claude --debug-file -p ` narrates the merge per destination with full rule + text, and narrates every auto-mode-dropped allow rule individually with its source file path and + the reason `(bypasses classifier)` — measured on 2.1.225, 216 drop lines in one session + (`.work/permission-model/EXPERIMENT-debug-channel-merge-narration.md`). It does not retire the + computed merge, because it costs a session spawn and parses undocumented `[DEBUG]` strings with no + stability contract. It is an oracle, not a read path. +- **The debug-channel oracle degrades when** a `--debug-file` capture on a machine with known + auto-mode-dropped rules stops emitting `Ignoring dangerous permission from (bypasses + classifier)`. Basis: the strings are undocumented internal output, so only a capture proves them. + +## Plan + +**Skill name: `claude-config:audit-permission-state`.** Derived against `MIGRATION-PLAYBOOK.md` +§Naming: an action / user-invoked skill takes an action verb, and a sibling family orders +base-concept-first — the plugin's existing family is already `audit`, `audit-automation-gaps`, +`audit-instructions`, `audit-pass`, `audit-permission-grants`, `audit-prompting-postures`. `state` +is the discriminator against the `grants` sibling: grants are what you wrote, state is what is +actually in effect. `claude-config` survives the namespace-noun test that a narrower noun would fail. + +**Standards grounding — cited by SECTION NAME, never by line number.** `PLUGIN-PHILOSOPHY.md` +§Cross-platform contract and §Prerequisites and failure behavior govern Phases 1 and 5; +§Two-lane convention posture governs the Phase 6 boundary; §Fresh-eyes checkpoints governs the +Phase 8 declaration; §Naming governs both skill names. `AGENTS.md` governs staging (explicit paths, +never `git add -A`). An earlier draft of this plan cited line ranges and **every one of them was +wrong within a day** — the file moved under them. Line-number citations into living files are +forbidden in this plan; grep the section heading instead. + +### Phase 0: Discharge the fresh-docs mandate [TODO] + +`CLAUDE.md` names this non-negotiable and this work is squarely inside its scope: the plan edits a +plugin manifest and the skills' whole contract surface is documented harness behavior. Every upstream +fact currently in the Brief rests on one machine's 2026-08-09 capture, which the mandate does not +accept as a substitute. The Brief's "fetch at read time" constraint governs the **shipped skill's +runtime**; it does not discharge the **implementer's** obligation. + +- Open `docs/OFFICIAL-DOCS.md`, WebFetch every page it indexes for permissions, permission modes, + settings, server-managed settings, sandboxing, hooks, and auto-mode configuration, and cite each URL. +- Re-confirm from those fetched pages, not from recall or from this file: the version gates + `v2.1.142` / `v2.1.207` / `v2.1.211`; the `autoMode` key set; the precedence order Phase 2 encodes; + the `disableAutoMode` string-vs-boolean shape; and the claim that no `allowManagedAutoModeRulesOnly` + exists. +- Any fact not confirmed from a page fetched during this phase is marked unverified in the phase that + consumes it and carries a caveat per criterion 10. + +**Sanity Check:** the phase's output file names one fetched URL per fact above; assert every version +gate constant appearing anywhere in the two new skills also appears in that file +(`comm -23` of the two sorted constant lists is empty). + +### Phase 1: Walking skeleton — scope discovery across all five scopes [TODO] + +The integration slice. Everything downstream reads what this produces. + +- Create the skill directory following the sibling's exact topology: + `plugins/claude-config/skills/audit-permission-state/{SKILL.md,reference/criteria.md,scripts/,evals/evals.json}`. +- `scripts/permission-state.sh` discovers and reads every settings scope: managed policy (read-only, + per-platform paths), user (`~/.claude/settings.json`), project (`.claude/settings.json`), local + (`.claude/settings.local.json`), and the **pre-v2.1.211 start-directory copy** as a distinct fifth + member — acceptance criterion 7 requires it read alongside the repo-root copy because rules from + both stay in effect. +- Emit one scope record per file: scope, absolute path, present/parsed, and the three rule arrays. +- Declare `jq` as **required for correctness** with a hard stop at the entry point, matching + `permission-rule-check.sh:63-64` (`ERROR: jq required`, exit 2). Declare it in the plugin README. + +- **Fixture seam is Phase 1 work, not an afterthought.** The sibling exposes only + `PERMISSION_HYGIENE_FIXTURE_DIR`, which sets `ROOT` and therefore reaches project and local scopes + only. This script needs **separate** overrides for the user-home, managed-policy, and + start-directory roots, so every scope is testable without ever reading or writing the operator's + real `~/.claude/`. No test may touch the real user home. +- **Managed-policy paths are enumerated explicitly per OS**, sourced in Phase 0, with OS detection + stated at the site. Per §Cross-platform contract, verify on each supported platform **or record an + honest manual-verification gap in the shipped skill** — the Brief already concedes per-platform + behavior is assumed and not verified, so absent real verification the gap gets written down, not + omitted. + +**Sanity Check:** + +- Point the fixture seams at a tree carrying **all five** scopes, then assert each named scope appears + exactly once: `grep -c '^managed\s'` = 1, `^user` = 1, `^project` = 1, `^local` = 1, and + `^startdir-local` = 1. A `≥2` count is not acceptable — it passes on project+local alone and leaves + criterion 7's dedicated scope member, and the managed scope, entirely unverified. +- jq-absent behavior: create a stub directory containing every needed tool **except** `jq`, run with + `PATH=`, assert exit 2 and `ERROR: jq required`. Do **not** use bare `PATH=` — measured, it + yields `bash: command not found` and exit 127, because the interpreter itself becomes unresolvable, + so the check would fail for a reason unrelated to jq. + +### Phase 2: Merge and per-rule provenance [TODO] + +Acceptance criterion 1. + +- Compute the effective merged `allow` / `ask` / `deny` set from the Phase 1 scope records. +- Every merged rule carries `origin` (the winning scope) and `precedence_basis` — the documented + mechanic the placement follows from. Basis is a per-rule field, not prose, because criterion 1 + requires each precedence claim to cite its mechanic. +- Per the Brief's decidability bound: anything resting on classifier judgment, runtime demotion state, + or an open upstream discrepancy becomes a named caveat on the affected finding, never a silent drop. + +**Sanity Check:** run against a fixture with a rule defined at two scopes; assert the output names +exactly one winner and that `grep -c 'precedence_basis'` equals the merged-rule count (no rule +without a basis). + +### Phase 3: Auto-mode drop classification and entry diff [TODO] + +Acceptance criterion 3, plus brainstorm candidate 4. + +- Classify every allow rule auto mode drops on entry: blanket `Bash(*)` / `PowerShell(*)`, wildcarded + interpreters, package-manager run commands, and **all `Agent` allow rules**. +- **Sharing the sibling's pattern vocabulary requires real refactoring — plan for it.** + `permission-rule-check.sh` is self-executing (it scans and `exit 0`s at load), so it cannot be + sourced, and the repo's no-duplication rule forecloses copying the `_interp` block. Extract the + pattern definitions into a shared, side-effect-free file both detectors source. That extraction + edits a **Phase 9-owned** file, so it is sequenced with Phase 9 and destroys the claim that the two + are independent — see the execution shape. +- **Criterion 8 binds this CLI read too.** The oracle's `claude --debug-file … -p …` invocation gets + the same defensive contract as the `autoMode` lane: never trust exit status, tolerate a missing or + empty capture, and never infer an empty drop set from a capture that produced no drop lines. +- Render the entry diff: effective state before and after the drop, per rule, with the drop reason. + +- **Debug-channel oracle — opt-in, explicitly priced.** Behind a flag that states the cost *before* + spawning anything, run `claude --debug-file -p ""` and parse the + harness's own drop narration — `Ignoring dangerous permission from (bypasses + classifier)`, closed by a `Removing N allow rule(s) from source ''` summary. Cross-check + it against this phase's prediction; **report disagreement in either direction as a finding.** The + prediction stays the default read path — the oracle costs a session spawn in the consumer's + environment and parses undocumented `[DEBUG]` strings with no stability contract. Never spawn + without the flag; never spawn silently. Write the capture to a scratch path, never to + `~/.claude/debug/`. Evidence and bounds: + `.work/permission-model/EXPERIMENT-debug-channel-merge-narration.md`. + +**Sanity Check:** + +- Feed a fixture containing one rule of each of the four classes; assert all four appear in the + dropped set and that a narrow exact rule (`Bash(git status)`) does not. +- Oracle off by default: run the phase with no flag and assert zero `claude` child processes and no + file created under the scratch path. +- Oracle on: assert the run prints the cost notice before the spawn, and that the count of + `AGREES`/`DIVERGES` lines **equals the compared-rule count exactly** — not `≥ 1`, which passes on a + single line while the per-rule guarantee is unmet. +- Oracle on with the drop strings absent (simulate with a fixture capture): the run reports the oracle + as unavailable and falls back to the prediction rather than reporting an empty drop set. + +### Phase 4: Permission-plane lints [TODO] + +Acceptance criteria 2, 5, 6. + +- **Criterion 2 — both dead-config traps at their two distinct gates, reported separately:** + `autoMode.*` ignored in project and local settings (local also read before v2.1.207), and + `defaultMode: "auto"` ignored in project and local settings (project could set it before v2.1.142). + `useAutoModeDuringPlan` is covered as a third, because it is not read from shared project settings. +- **Criterion 5** — `disableAutoMode` typed as a boolean rather than the string `"disable"`, in any + scope; it is not managed-only. +- **Criterion 6** — allow rules that cannot match: doubled-backslash Windows paths, unanchored allow + globs, `Write(path)`-shaped rules that are accepted but never consulted, and `:*` used anywhere but + at pattern end. + +**Sanity Check:** a fixture per check; assert each fires exactly once and that criterion 2's two gates +emit two separately-labeled findings rather than one merged finding +(`grep -c '\[C2-autoMode\]'` = 1 and `grep -c '\[C2-defaultMode\]'` = 1). + +### Phase 5: `autoMode`-block lane [TODO] + +Acceptance criteria 4 and 8, plus brainstorm candidates 1, 2, 3. + +- **Criterion 4** — `$defaults` omission per `autoMode` section, stating which built-in rule list the + omission discards. Probe and diff via `claude auto-mode defaults`; never infer from a version number + (the changelog does not track the content of the shipped lists — zero matches across all 359 + releases). +- **Criterion 8 — the measured defensive contract, every item, each already reconfirmed on 2.1.225:** + never a strict JSON parser; tolerate a **missing** key rather than an empty array (`defaults --label` + omits a non-matching key entirely); split a rule label at the first `[`, not the first `:`; never + trust exit status. A run producing no usable output reports that fact and never claims success. +- **Candidate 2** — intra-`autoMode` contradiction lint: allow vs `soft_deny`, `hard_deny` vs allow. +- **Candidate 3** — dead / unactionable rule lint: rules shadowed by an earlier `hard_deny`, and prose + entries with no observable predicate. **Not a duplicate of criterion 6** — criterion 6 is syntactic + non-matching on the `permissions.allow` plane; this is semantic shadowing inside the `autoMode` + block. Different surface, different inputs. The prose half either states a mechanical basis or + routes to `critique`, which owns that judgment. +- **Candidate 1** — surface `claude auto-mode critique` as its own action, wrapped in truncation and + empty-output detection with a plain "critique returned nothing; run it yourself" surface. Wrapped, + never replaced: it owns the semantic judgment. +- **Runtime, decided and measured** (`.work/permission-model/EXPERIMENT-nonstrict-json-runtime.md`): + Python 3 is **required for an optional feature** — this lane only. Absent → warn visibly, skip this + lane, continue with the documented reduced result. Pure POSIX was tested and cannot substitute: the + offending byte is a raw line feed inside a string value, which no line-oriented filter can + distinguish from the pretty-printer's structural newlines. Node is an equally valid host and is + deliberately not adopted — a second optional runtime doubles the declaration surface for one feature. + +**Sanity Check:** + +- Parse a **checked-in fixture** carrying a raw line feed inside a string value: the shipped reader + returns the four `autoMode` sections while `jq -e .` on the same fixture exits non-zero. Do **not** + run this against `claude auto-mode config` on the developer's machine — the Brief records the defect + as machine-conditional, so on a clean config `jq` exits 0 and the check fails while the skill is + behaving correctly. The check must exercise shipped code against a fixture, not the operator's + environment. +- The other three measured defects each get their own fixture assertion, because criterion 8 names + four and only one was covered: a **missing** key (not an empty array) is tolerated; a label carrying + a bracketed annotation splits at the first `[`, not the first `:`; and a run that exits 0 with empty + output is reported as "produced no usable output" rather than as success. +- With Python unreachable, the skill exits 0, prints a visible skip notice naming the lane, and still + emits Phase 2 merge output — assert both the notice string and the merge rows in one run. + +### Phase 6: Managed-policy conformance report [TODO] + +Brainstorm candidate 6 — the highest-value residue of the ecosystem sweep. + +- Read the deployed managed policy (read-only always; a plugin can never author managed policy — + admin-write OS paths or a claude.ai Owner role) and diff it against every scope beneath it. +- Report which managed intents are genuinely enforced versus silently loosenable. The load-bearing + claim: **there is no `allowManagedAutoModeRulesOnly`** — permissions, hooks, MCP, + sandbox-filesystem, and sandbox-network each got an exclusivity lock and auto mode did not, so + managed `autoMode` rules *can* be loosened by a developer and only `permissions.deny` in managed + settings is unoverridable. +- **That claim is NOT independently verified and must not ship as "measured".** It originates in the + `managed-policy` research slice, and the Brief's own `### Captured assumptions` records that only + `permissions-core` received independent adversarial verification — the other four are cross-checked + against each other only. Phase 0 re-confirms it from a fetched page. Until it does, the phase's + headline finding carries an explicit caveat naming its provenance. A whole phase resting on an + unverified slice with no caveat is exactly the failure criterion 10 exists to prevent. +- **Two-lane posture (`PLUGIN-PHILOSOPHY.md` §Two-lane convention posture), decided:** this phase ships **no security floor + of its own**. It reports what the consumer's own policy does and does not achieve — it never + prescribes which rules a policy should contain. That keeps it lane-neutral by construction and out + of the lane-1/lane-2 question entirely. The owed re-derivation of + `audit/reference/required-permissions.md` toward lane 2 is a real debt but belongs to the `audit` + skill that ships it, not to this plan. + +**Sanity Check:** against a fixture managed policy containing one `permissions.deny` rule and one +`autoMode` rule, assert the report marks the deny rule enforced and the `autoMode` rule loosenable. +For lane-neutrality, assert the **positive** property: every rule string appearing in the report also +appears in the fixture policy or in a scope file beneath it (set difference is empty). Do not assert +`grep -c 'RECOMMEND ADD' = 0` — nothing emits that string, so it passes unconditionally and proves +nothing about the guarantee it claims to protect. + +### Phase 7: Authoring lane — `claude-config:draft-auto-mode-rules` [TODO] + +Brainstorm candidate 7. Drafts an `autoMode` block from an interview plus the repo's observed prompt +and denial history, prints it to stdout, human pastes. **No write, no persistent state**, so it does +not trip the Brief's plugin-extraction recheck trigger. + +This is a **second sibling skill**, not an action on `audit-permission-state`: §Naming binds a skill +name to its KIND, and an `audit-*` skill hosting a `draft` action would make its own name untrue. +**Admitted 2026-08-09**, which amends the Brief's TLDR from one new skill to two and adds a second +listing-budget entry that Phase 8 must state rather than assume. + +- **Name the history source.** "The repo's observed prompt and denial history" is not a location. An + unnamed read surface in a skill shipped to consumers is unreviewable. Either bind it to a concrete + readable input (the debug-channel capture from Phase 3, which already carries per-rule denial data + and is already priced and opt-in) or drop the history input and drive the draft from the interview + plus the Phase 2 merge alone. **Recommended: drop it** — it removes a read surface, removes a second + dependency on the priced oracle, and the merge already carries what the draft needs. + +**Sanity Check:** a skill is a markdown surface, not a process, so nothing is piped from it. Assert +instead against the deterministic script the skill drives: run it on a fixture and pipe **its** stdout +through a strict parser (`jq -e .`), asserting exit 0 — strict is correct here because we author this +output, and the non-strict allowance exists only for the CLI's malformed emission. This keeps Phase 7 +free of the Python dependency that Phase 5 scopes to one optional lane. Zero-writes is covered by +Phase 8's sweep, which must include this skill. + +### Phase 8: Cross-cutting close [TODO] + +- **Criterion 9 — no writes, any scope, any flag.** Assert mechanically, not by inspection. +- **Criterion 10** — every finding whose basis is an open upstream discrepancy carries that + discrepancy as a named caveat. The two live ones are #83766 and #42797 (`permissions.ask` patterns + auto-approved under `defaultMode: auto`, contradicting current documented behavior). +- **Fresh-context delegation directive, decided — in the enforced machine-readable form.** + `PLUGIN-PHILOSOPHY.md` §Fresh-eyes checkpoints requires a skill step whose output judges work the + same context produced to delegate to a fresh-context subagent or carry a greppable exemption. These + skills read consumer configuration and run deterministic scripts over it; no step judges output they + authored. Ship the exemption in the form `skill-quality` Check 21 actually enforces — + `` with class from + `deterministic-gate|external-input|deferred` — and **class `external-input`**, since the judged + material is the consumer's configuration, not our own output. **Both** skills carry one; a prose + paragraph containing the words does not satisfy the checker and would error. +- **Criterion 12** — run `skill-quality:check listing-budget`; state the resulting cost rather than + assuming it. With Phase 7 gated in, state the two-entry cost. +- Register as an `audit-pass` lane per that skill's documented lane rule; add `evals/evals.json`; + update `plugins/claude-config/.claude-plugin/plugin.json` (`version`, `description`). +- **Frontmatter `name:` — verify at implementation time, do not assume.** At `main` + (`30be2a0b`) all 191 skills still carry `name:`. A branch dropping it repo-wide + (`refactor/drop-redundant-skill-name-frontmatter`) exists but is **not merged**. Match whatever + `main` holds when the branch is cut; re-check rather than copying this sentence's answer. + +**Sanity Check:** + +- Write-assertion, **oracle explicitly ON for this run**: checksum the whole fixture tree **and a + fixture `HOME`** before and after running every action of **both** skills, and assert zero changes + in either. Running only the default configuration proves nothing about criterion 9, because the + default disables the one code path that spawns a process capable of writing outside the tree. +- `skill-quality:check` returns PASS for **both** `audit-permission-state` and + `draft-auto-mode-rules`, and its Check 21 passes on each — that is the real verification of the + fresh-eyes declaration; a `grep` for the words matches ordinary prose and certifies nothing. +- Version bump: assert `plugins/claude-config/.claude-plugin/plugin.json` `version` differs from its + value at the branch point, and that its `description` names both new skills. + +### Phase 9: `audit-permission-grants` scope widening [TODO] + +Acceptance criterion 11. **Not independent** — see the execution shape; it shares `plugin.json` with +Phase 8 and owns the file Phase 3's pattern extraction touches. + +- The criteria file and the detector scan project and local settings only, so a user-global + interpreter-wildcard rule is invisible to check P1. Add the user-global scope. +- **Build the fixture-home seam first — it does not exist.** The detector's only override is + `PERMISSION_HYGIENE_FIXTURE_DIR`, which sets `ROOT`, and the scans read `$ROOT/.claude/settings.json` + and `$ROOT/.claude/settings.local.json` only; the test harness passes exactly that one variable. + There is no way to point it at a fake user home, so user-global cannot be tested at all until a + second override exists. Adding it **is** the phase's first work item; without it the only way to + exercise the new scope is to read the operator's real `~/.claude/settings.json`, which no test may do. +- **Extract the shared pattern vocabulary here**, side-effect-free, so Phase 3 can source it. The + current file self-executes and `exit 0`s at load, so it cannot be sourced as it stands. + +**Sanity Check:** with the new fixture-home seam pointed at a fake home containing one +interpreter-wildcard rule, assert exactly one P1 finding naming that file; assert the finding does +**not** appear when the seam is unset (proving the fixture, not the real home, produced it); assert +`scripts/permission-rule-check.test.sh` still passes. + +### The Brief's second scope-widening is unassigned — OPEN + +The Brief commits twice to widening **two** existing checks (`### Constraints` → Packaging: "plus +in-place scope widening of two existing checks"; and the TLDR). Only one is named anywhere — P1, in +criterion 11 and this phase. The second is identified nowhere in the Brief, the criteria, the +interview register, or the brainstorm. + +Do not guess it. The strongest candidate from the research is the `claude-config:audit` skill's +settings-file scan, because a pre-v2.1.211 `settings.local.json` left in the start directory is still +read and nothing checks it today — but that is inference, not a recorded decision. Resolve before +Phase 8 closes; it is a Brief commitment that would otherwise ship unbuilt. + +## Blast radius + +**MEDIUM-HIGH.** A new component shipped from a marketplace consumed downstream, plus an in-place +behavior change to an existing detector (Phase 9) that widens what it flags — consumers will see new +findings on unchanged repos. Mitigated by: report-only throughout (criterion 9), no consumer writes +under any flag, and the managed tier read-only by construction. The genuinely irreversible surface is +the published skill name, which is why naming was derived against §Naming rather than chosen. + +## Stress-test summary + +Fresh-context reviewer dispatched 2026-08-09 with the rationale withheld. **19 findings, 2 CRITICAL. +Every finding was independently re-verified against the repository before any edit; all 19 held.** +No finding was rejected. + +The two CRITICALs were factual, not stylistic, and both invalidated premises this plan was resting on: + +1. **Three load-bearing premises were stale.** PR #2089 is **merged**; the `auto-mode-config` row is + already in `docs/OFFICIAL-DOCS.md`; `main` is `a013d204`, not `30be2a0b`; and the frontmatter + `name:` refactor **has merged**, so copying the sibling's frontmatter would re-introduce a field + `main` deliberately removed. The Brief's Q12 gate was being held open on shipped work. +2. **The whole topic slice is untracked on a spent branch** and absent from `main`. Following the + handoff's own instruction — cut a branch from `main` — would produce a worktree with no plan in it. + +The remaining seventeen clustered into four honest weaknesses, all now corrected in place: + +- **Sanity checks that could not fail or could not run.** `grep -c 'RECOMMEND ADD' = 0` asserted a + string nothing emits; `PATH= bash` yields exit 127 (`bash: command not found`), never the exit 2 it + claimed to test; Phase 5's headline check ran against the operator's live config, which the Brief + itself calls machine-conditional, so it would fail on a clean machine while the code was correct; + Phase 7 piped a markdown skill into a JSON parser; a `≥2` scope count passed without ever asserting + the two novel scopes; a `grep` for "fresh-eyes" matched prose while the real checker enforces a + strict directive form. +- **A parallel wave built on false file-disjointness.** Phases 4, 5 and 6 all write the same + `criteria.md` and `SKILL.md`; Phases 8 and 9 collide on `plugin.json`. The plan is now sequential, + and Phase 9 moved early because it owns seams the earlier phases need. +- **Governance skipped or mis-cited.** The `CLAUDE.md` fresh-docs mandate had no step at all — now + Phase 0. Every `PLUGIN-PHILOSOPHY.md` line-range citation pointed at the wrong section within a day; + citations are now by section name, and line numbers are forbidden in this plan. +- **Coverage gaps.** Criterion 8 binds *every* CLI read but the oracle's new CLI read had no + defensive contract and 3 of its 4 measured defects had no check; criterion 9's write assertion ran + only with the risky path disabled; the Brief's commitment to widen **two** existing checks has only + one assigned, now flagged OPEN rather than guessed. + +One item the reviewer explicitly declined to assert: whether a `-p` oracle session writes transcript +files outside the scratch path. It is unverified, not a finding — carried into Open questions. + +## Execution shape + +Nine phases; not fully sequential. + +**Corrected after review: there is no parallel-safe set. This plan is sequential.** + +The earlier draft claimed Phases 4, 5 and 6 were file-disjoint "own criteria rows, own scripts". They +are not: criteria rows are not separate files — the sibling and this plan both put every check in one +`reference/criteria.md` — and Phase 5's `critique` action and Phase 6's report are both new actions in +the single `SKILL.md`. All three write the same two files. Phase 9 was likewise called "zero file +overlap", but Phase 8 bumps `plugins/claude-config/.claude-plugin/plugin.json` and Phase 9 is a +behavior change in the same plugin that must ride the same bump — and Phase 9 now also owns the +pattern extraction Phase 3 consumes. + +| Order | Phase | Gated by | +|---|---|---| +| 1 | 0 | — (fresh-docs mandate; everything downstream cites it) | +| 2 | 9 | 0. Moved early: it owns the fixture-home seam and the extracted pattern vocabulary Phases 1 and 3 need. | +| 3 | 1 | 9 (fixture seams) | +| 4 | 2 | 1 | +| 5 | 3 | 2, 9 (pattern vocabulary) | +| 6 | 4 | 3 (shares the drop vocabulary) | +| 7 | 5 | 2 | +| 8 | 6 | 2 | +| 9 | 7 | 2 | +| 10 | 8 | all — it measures the finished surface and owns the single version bump | + +| Phase | Surface | Basis | +|---|---|---| +| 0 | Main session | Fetch-and-cite judgment; its output is the citation base for everything after. | +| 9, 1, 2, 3 | Main session | Seams, merge, and precedence basis are the contract. | +| 4, 5, 6 | Main session, sequential | They share `criteria.md` and `SKILL.md`; a worker fan-out would collide. | +| 7 | Main session | New public surface; naming and scope judgment. | +| 8 | Main session | Measures and closes. | + +| Phase | Surface | Basis | +|---|---|---| +| 1, 2, 3 | Main session | Judgment-heavy; the merge and precedence basis are the contract. | +| 4, 5, 6 | Sub-agent worker (if used) | File-disjoint, criteria-row shaped, mechanical once the merge exists. | +| 7 | Main session | New public surface; naming and scope judgment. | +| 8 | Main session | Measures and closes; must see everything. | +| 9 | Sub-agent worker or main | Fully independent, small, well-fenced. | + +**Cost note:** no parallel wave survives the file-overlap check, so there is no agent-count tradeoff to +offer. Sequential is the shape, not a fallback from one. + +**Sub-topic promotion watch:** Phases 5 and 6 each carry ≥5 distinct work items and could earn their +own topic slice. Promote if either exceeds ~300 LOC delta or grows sub-phases. + +## Open questions + +All three approval-round questions are resolved (2026-08-09): + +1. **Debug-channel oracle — SHIP IT, opt-in and explicitly priced.** Folded into Phase 3 with its own + four sanity checks. The prediction remains the default read path. +2. **Phase 7 — IN.** Ships as the second sibling skill `claude-config:draft-auto-mode-rules`. +3. **Fresh-context plan reviewer — dispatched** at the user's explicit request. + +Remaining genuinely open, carried into implementation: + +- **Scopes absent from the oracle capture were not disproved.** No `projectSettings` line appeared + because this repository's `.claude/settings.json` carries no `permissions` key; no `policySettings` + line appeared because no managed policy is deployed on the probe machine. Re-probe on a + managed-policy machine before claiming Phase 6 coverage from the oracle. +- **What governs whether a `-p` probe session is in auto mode** was not isolated, and it determines + whether the oracle emits drop lines at all. Phase 3's fallback sanity check covers the failure, but + the mechanic is unknown. +- **The `[DEBUG]` string format** carries no stability contract; the recheck trigger is recorded under + `### Recheck triggers`. +- **What a `-p` oracle session writes outside the scratch path is unverified.** It plausibly writes a + transcript, project state, or telemetry under the user's config directory. Criterion 9 forbids + writes to consumer *settings*, and a transcript is not a settings file, but the boundary was never + measured. Measure it in Phase 3 before the oracle ships, and state the result in the flag's cost + notice — a feature that spawns a session must be honest about everything it leaves behind. +- **Which second existing check the Brief meant.** See the OPEN block after Phase 9. Needs a decision, + not a guess. + +## Handoff to implementation + +### User-approval gates + +- Any proposal to add a `--fix` mode, or any consumer write in any scope, stops and asks — the Brief + puts both out of scope and criterion 9 asserts against them. +- Any change that would make the debug-channel oracle spawn by default, rather than behind its + explicit priced flag, stops and asks. The opt-in *is* the approved shape. +- Promoting Phase 5 or Phase 6 to its own topic slice (the sub-topic watch below) stops and asks. + +### Execution shape (`[EXEC-SHAPE]` tagged) + +The wave table, the routing table, and the sequential fallback above. + +### Mechanical work + +- **This slice is not durable yet — fix before anything else.** `docs/topics/permission-model/` is + **untracked** in a worktree sitting on `docs/index-auto-mode-config`, whose PR **#2089 merged** + (`2026-08-09T17:31:08Z`) carrying only the `OFFICIAL-DOCS.md` row. The branch is spent and nothing + in this slice is on `main` (`git ls-tree -r --name-only main | grep topics/permission-model` → + no matches). Cut `feat/audit-permission-state` from current `origin/main` — untracked files carry + across the switch — and commit the slice before any implementation work begins. +- **Branch:** `feat/audit-permission-state`, from current `origin/main`. The existing worktree is 21 + commits behind. Do **not** reuse `docs/index-auto-mode-config`; it is merged. +- **Frontmatter `name:` is GONE on `main` — do not copy the sibling's.** `main` is at `a013d204`; + `git grep -l '^name:' main -- '*/SKILL.md'` returns only `vendor/` files, so + `refactor/drop-redundant-skill-name-frontmatter` **has merged**. Both new skills ship without a + frontmatter `name`. Re-verify at branch-cut time rather than trusting this sentence. +- Commit at phase boundaries; stage explicit paths only, never `git add -A` (`AGENTS.md`). +- PR body must satisfy `.github/workflows/pr-issue-linkage.yml`: a closing keyword or the literal + `No linked issue`, plus a non-empty `## Related` section. diff --git a/docs/topics/permission-model/design/design-resolution.md b/docs/topics/permission-model/design/design-resolution.md new file mode 100644 index 0000000000..73aeafd1f4 --- /dev/null +++ b/docs/topics/permission-model/design/design-resolution.md @@ -0,0 +1,76 @@ +--- +outcome: early-exit +tier: B +date: 2026-08-09 +--- + +# Design resolution — permission-model + +`/planning:plan`'s design gate, resolved as a **Tier B early exit**. No `/planning:design` session is +owed. This file records the classification, its evidence, and the type sketch the tier requires. + +## Why Tier B and not Tier A + +Tier A asks whether the work introduces new types/contracts, a new module, a package-topology change, +or cross-module integration whose shape is still open. Most of that surface is **already closed** — +by the Brief, or by repository precedent strong enough that inventing an alternative would itself be +the defect. + +| Design axis | Status | Closed by | +|---|---|---| +| Packaging / module boundary | Closed | Brief `### Constraints` → Packaging: a sibling skill under `claude-config`, not a new plugin. Basis is hard: the flat `renames` map and the `audit-pass/SKILL.md:36` cross-plugin reference. | +| Component topology | Closed by precedent | Every skill in this marketplace is `SKILL.md` + `reference/*.md` + `scripts/*.sh` + `evals/evals.json`. Verified against `plugins/claude-config/skills/audit-permission-grants/`, which is the nearest sibling and the direct template. | +| Read strategy | Closed | Brief: compute the merge, bounded by a per-item decidability criterion with a stated basis. | +| Output vocabulary | Closed | Brief: the `review` plugin's `severity.md`. | +| Finding-line format | Closed by precedent | ` [] : `, from `audit-permission-grants/reference/criteria.md`. | +| External contracts consumed | Closed | The measured CLI defensive contract in the Brief; no new external surface is introduced. | +| Cross-module integration | Closed by precedent | Registration as an `audit-pass` lane follows that skill's documented lane rule; no new integration mechanism is invented. | + +What genuinely remains open is **script decomposition** (how many scripts, where the seam between +them falls) and the **criteria-row schema** for the new checks. Both are localized, both are +single-file-ish decisions, and both are resolved inside the plan body rather than needing a design +session. That is the Tier B signature. + +## Type sketch + +No programming-language type system is involved — the artifacts are shell scripts and markdown. The +"types" are the data shapes the scripts pass and the report consumes. + +**Scope record** — one per settings file discovered. + +- `scope`: one of `managed` | `user` | `project` | `local` | `startdir-local` +- `path`: absolute path as read +- `present`: whether the file exists and parsed +- `arrays`: the `permissions.allow` / `.ask` / `.deny` entries found + +`startdir-local` is a distinct member, not a variant of `local`: acceptance criterion 7 requires the +pre-v2.1.211 start-directory copy to be read **alongside** the repository-root copy, because rules +from both stay in effect. + +**Merged rule** — one per rule in the effective set. + +- `rule`: the verbatim pattern +- `bucket`: `allow` | `ask` | `deny` +- `origin`: the `scope` it came from +- `precedence_basis`: the documented mechanic the placement follows from (criterion 1 requires this + per rule, so it is a field, not prose) +- `auto_mode_drop`: whether auto mode discards it on entry, plus which criterion-3 class + +**Finding** — one per detected problem, matching the existing line format. + +- `severity`, `check`, `source`, `detail` +- `caveat`: optional; populated when the basis is an open upstream discrepancy (criterion 10 makes + this mandatory-when-applicable, so it is a field rather than an ad-hoc sentence) + +**Oracle observation** — only if the debug-channel candidate ships (see the plan's open decision). + +- `rule`, `source_path`, `reason` parsed from the harness's own drop narration +- `agrees_with_prediction`: the cross-check result against `auto_mode_drop` + +## What this early exit does not cover + +- **Skill naming.** Still owed against `MIGRATION-PLAYBOOK.md` §Naming, with the live constraint that + `PLUGIN-PHILOSOPHY.md:41-46` requires the namespace noun to be true of every skill under it. Naming + is not a design thread in the Tier A sense; it is resolved in the plan. +- **The runtime prerequisite.** A non-strict JSON parser implies Python or Node, which + `PLUGIN-PHILOSOPHY.md:387-397` and `:436-447` govern. Decided in the plan, not here. From 025d351ae910e9ab001b117ac754d220460bb30b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:22:05 -0400 Subject: [PATCH 02/14] docs(topics): discharge the permission-model fresh-docs mandate Phase 0 re-confirms every upstream fact the two planned skills would ship, against the auto-mode-config, settings, permission-modes, and permissions pages fetched today, rather than the single local capture the Brief was resting on. Eleven facts are confirmed with quoted wording, including the scope precedence, the deny-then-ask-then-allow evaluation order, the four classes of allow rule auto mode drops on entry, and that permission rules from a pre-v2.1.211 start-directory settings.local.json stay in effect. The absence of allowManagedAutoModeRulesOnly is now supported by the governing page rather than by an unverified research slice. Five corrections land against the plan. The largest: managed policy is not two JSON files. It is a plist domain on macOS, a registry hive on Windows, a JSON file, and a managed-settings.d drop-in directory, per OS, so the managed read cannot be jq over a fixed pair of paths. The local settings file also resolves through worktrees to the main checkout, which this topic is itself being planned inside. And autoMode.classifyAllShell suspends every shell allow rule when set, inverting the drop-set answer that no criterion currently covers. Three claims move to not-stated and must not ship as fact: the v2.1.142 gate on project-scope defaultMode, the scope restriction on useAutoModeDuringPlan, and the Write(path) never-consulted mechanic, which the page describes differently and with a startup warning as its observable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/topics/permission-model/PLAN.md | 22 ++++- .../permission-model/phase0-fresh-docs.md | 97 +++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 docs/topics/permission-model/phase0-fresh-docs.md diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index ad901cd270..ec7f54fec5 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -245,7 +245,27 @@ never `git add -A`). An earlier draft of this plan cited line ranges and **every wrong within a day** — the file moved under them. Line-number citations into living files are forbidden in this plan; grep the section heading instead. -### Phase 0: Discharge the fresh-docs mandate [TODO] +### Phase 0: Discharge the fresh-docs mandate [DONE] + +**Completed 2026-08-10 — output: [`phase0-fresh-docs.md`](phase0-fresh-docs.md).** Eleven facts +confirmed against pages fetched this session, five corrections to this plan, two open upstream +discrepancies, and three claims moved to *not stated* that must not ship as fact. The corrections +change downstream phases; the material ones are folded into the phase bodies below: + +- **Phase 1** — managed policy is a plist domain, a Windows **registry hive**, a JSON file, **and** a + `managed-settings.d/` drop-in directory, per OS. Not a jq-over-two-paths read. And + `.claude/settings.local.json` resolves **through worktrees to the main checkout**, with three stated + exceptions. +- **Phase 4** — `autoMode.classifyAllShell` (v2.1.193+) suspends **every** Bash/PowerShell allow rule + when true, which inverts criterion 3's answer. No criterion covers it. +- **Criterion 2** — the `v2.1.142` gate is **not stated** on any governing page, and neither is the + claim that `useAutoModeDuringPlan` is unread from shared project settings. +- **Criterion 6** — the `Write(path)` item needs re-deriving; the page describes a different mechanic + (parameter-form rules on a tool's primary content field are ignored **and emit a startup warning**). +- **Phase 6** — the no-`allowManagedAutoModeRulesOnly` claim is now confirmed by the governing page and + is no longer resting on an unverified research slice. + +### Phase 0 (original brief, retained for the record) `CLAUDE.md` names this non-negotiable and this work is squarely inside its scope: the plan edits a plugin manifest and the skills' whole contract surface is documented harness behavior. Every upstream diff --git a/docs/topics/permission-model/phase0-fresh-docs.md b/docs/topics/permission-model/phase0-fresh-docs.md new file mode 100644 index 0000000000..39f85481ab --- /dev/null +++ b/docs/topics/permission-model/phase0-fresh-docs.md @@ -0,0 +1,97 @@ +# Phase 0 — fresh-docs mandate, discharged + +Every fact the two skills ship is re-confirmed here against pages fetched **2026-08-10**, per +`CLAUDE.md`'s fresh-docs mandate. Facts carried from the 2026-08-09 local capture are not accepted as +verified; each row below says which page it came from, or says the page does not state it. + +Pages fetched this session, all from the `docs/OFFICIAL-DOCS.md` index: + +- +- +- +- + +## Confirmed — safe to ship + +| Fact | Source | Wording | +|---|---|---| +| Scope precedence | settings | Managed (highest) → command line → local → project → user (lowest) | +| Rule evaluation order | permissions | "Rules are evaluated in order: deny, then ask, then allow. The first match in that order determines the outcome, and rule specificity doesn't change the order." | +| `autoMode` scope set | auto-mode-config | Read from `~/.claude/settings.json`, managed settings, and `--settings`/Agent SDK inline JSON. "The classifier doesn't read `autoMode` from project settings in `.claude/settings.json` or `.claude/settings.local.json`." | +| `autoMode` local-settings gate | auto-mode-config | "Before v2.1.207, the classifier also read `.claude/settings.local.json`" | +| Criterion 3's four drop classes | permission-modes | "On entering auto mode, broad allow rules that grant arbitrary code execution are dropped: Blanket `Bash(*)` or `PowerShell(*)`; Wildcarded interpreters like `Bash(python*)`; Package-manager run commands; `Agent` allow rules." Plus "Narrow rules like `Bash(npm test)` carry over. Dropped rules are restored when you leave auto mode." | +| `defaultMode: "auto"` gate | permission-modes | "Claude Code ignores `defaultMode: \"auto\"` in project and local settings." | +| `disableAutoMode` shape | permissions | "set `permissions.disableBypassPermissionsMode` or `permissions.disableAutoMode` to `\"disable\"` in any settings file" — the **string**, and **any** scope, confirming it is not managed-only | +| `:*` position rule | permissions | "The `:*` form is only recognized at the end of a pattern. In a pattern like `Bash(git:* push)`, the colon is treated as a literal character and won't match git commands." | +| Start-directory `settings.local.json` | settings | "Before v2.1.211, the file always lived in the starting directory. Claude Code still reads a `.claude/settings.local.json` that an earlier version left there. When both files set the same key, the repository root's value wins, **except that permission rules from both files stay in effect**." | +| Hook cannot override deny/ask | permissions | "Hook decisions don't bypass permission rules… a matching deny rule blocks the call, and a matching ask rule still prompts even when the hook returned `\"allow\"`" — independently corroborates the local four-leg experiment | +| No `allowManagedAutoModeRulesOnly` | all four pages | Zero occurrences. Affirmatively supported rather than merely absent: "A developer can extend `environment`, `allow`, `soft_deny`, and `hard_deny` with personal entries but can't remove entries that managed settings provide… a developer-added `allow` entry can override an organization `soft_deny` entry: the combination is additive, not a hard policy boundary," alongside "For actions that must never run regardless of user intent or classifier configuration, use `permissions.deny` in managed settings, which… can't be overridden." | + +That last row **upgrades** the claim's status. The plan flagged it as resting on an unverified research +slice; it now rests on the governing page. Phase 6's caveat can be narrowed to the precise wording: +managed `autoMode` entries cannot be **removed**, but a developer `allow` **can** override an +organization `soft_deny`, so managed auto-mode rules are not a hard policy boundary. + +## Corrections — the plan was wrong or incomplete + +1. **Managed policy is not two JSON files, and on Windows it is partly the registry.** The settings + page enumerates: macOS — the `com.anthropic.claudecode` managed-preferences domain (a plist), + `/Library/Application Support/ClaudeCode/managed-settings.json`, and a + `managed-settings.d/` directory; Linux and WSL — `/etc/claude-code/managed-settings.json` and + `/etc/claude-code/managed-settings.d/`; Windows — `HKLM\SOFTWARE\Policies\ClaudeCode`, + `HKCU\SOFTWARE\Policies\ClaudeCode`, `C:\Program Files\ClaudeCode\managed-settings.json`, and a + `managed-settings.d/` directory. **Phase 1 cannot read the managed scope with `jq` over a fixed + pair of paths.** It needs a per-OS reader covering a plist domain, a registry hive, and a + drop-in directory whose file count is unknown ahead of time. This is the single largest scope + change Phase 0 produced. +2. **Legacy Windows managed path is dead.** "The legacy Windows path + `C:\ProgramData\ClaudeCode\managed-settings.json` is no longer supported as of v2.1.75." Reading it + would report policy that is not in force. +3. **`.claude/settings.local.json` resolves through worktrees to the main checkout.** "Claude Code + reads and writes this file at the root of the git repository, resolved through worktrees to the + main checkout, so one file covers sessions started in any subdirectory or worktree." Three stated + exceptions keep it in the start directory: outside a git repository, when the repository root is + the home directory, and in Agent SDK sessions. Phase 1's scope discovery must resolve the worktree + rather than assuming the current root — this very topic is being planned inside a worktree, so the + case is live, not hypothetical. +4. **`autoMode.classifyAllShell` exists and no criterion covers it.** Requires v2.1.193 or later; + when `true` it "suspend[s] every Bash and PowerShell allow rule while auto mode is active." That + changes criterion 3's answer wholesale — with it on, narrow rules do **not** carry over. An audit + reporting the drop set without reading this key can be exactly wrong. Gap to close in Phase 4. +5. **`claude auto-mode reset` needs v2.1.212+** and "removes the `autoMode` section from your user + settings file," asking `Reset auto mode configuration to defaults?` unless `--yes` is passed. The + standing prohibition on running it against the operator's config is reinforced, not relaxed. + +## Open upstream discrepancies — criterion 10 caveats + +- **`defaults --label` on a non-matching key.** The page states "sections with no match print as empty + lists." The 2026-08-09 local capture measured the key **omitted entirely**. Both cannot be true. + The defensive contract already tolerates a missing key, so the code is safe either way, but the + divergence is now documented rather than folkloric and any finding derived from it carries the + caveat. +- **`permissions.ask` under auto mode.** Issues #83766 and #42797 report ask patterns auto-approved. + The page contradicts them: content-scoped ask rules "always force a permission prompt, even in auto + mode… The classifier cannot auto-approve a matching action." The discrepancy stands; the caveat the + Brief already requires stays. + +## Not stated — must not ship as fact + +- **The `v2.1.142` gate** on project-scope `defaultMode` ("project could set it before v2.1.142") + appears on none of the four pages. Criterion 2 asserts it. Either relocate it to a page that states + it, or ship the gate without the version number and caveat it. +- **Which scopes read `useAutoModeDuringPlan`.** The setting is confirmed to exist and to be on by + default; no page states it is not read from shared project settings, which is what criterion 2's + third item claims. +- **`Write(path)`-shaped rules "accepted but never consulted."** The permissions page states something + adjacent but different: rules matching a tool's *primary content field* by parameter — `Bash(command:…)`, + `Write(file_path:…)` — are ignored **and emit a startup warning**. That is a different mechanic with + a different observable. Criterion 6's fourth item needs re-deriving against this wording before it + ships. + +The startup warning in that last item is itself useful: it is a readable signal carrying rule text, +one of the channels the Brief listed as unexplored. + +## Version constants cleared for use + +`v2.1.75`, `v2.1.193`, `v2.1.198`, `v2.1.200`, `v2.1.203`, `v2.1.207`, `v2.1.208`, `v2.1.211`, +`v2.1.212` — each appears verbatim on a page fetched above. **`v2.1.142` is not cleared.** From 7f14c14b238c1ba72f761816a7595a52d2d48aa7 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:03:07 -0400 Subject: [PATCH 03/14] docs(topics): resolve the three parked permission-model decisions Phase 1's managed-policy reader is split into a portable core (the per-OS managed-settings.json and its managed-settings.d/ drop-in directory, always read) and two declared optional platform legs (the Windows registry policy keys and the macOS managed-preferences domain) that warn visibly and skip rather than failing the run. A Windows reader that checks only the Program Files JSON reports "no managed policy deployed" while a registry-deployed policy is in force, so dropping the registry buys a wrong finding rather than a documented gap. An unelevated reg query of HKLM\SOFTWARE\Policies was measured to succeed, so the leg costs no elevation. The Brief's long-unassigned second scope-widening is named: claude-config's audit skill gains the start-directory settings.local.json copy, whose permission rules stay in effect and which nothing audits today. The authoring lane drops its unnamed history input and drafts from the interview plus the computed merge, removing an unreviewable read surface and a second dependency on the priced debug-channel oracle. Phase 9 absorbs the shared managed-scope path enumeration, since approving the managed reader would otherwise make this repository's third copy of it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/topics/permission-model/PLAN.md | 167 +++++++++++++++++++-------- 1 file changed, 121 insertions(+), 46 deletions(-) diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index ec7f54fec5..216e4483c6 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -9,11 +9,12 @@ Add **two** new skills to the `claude-config` plugin. `audit-permission-state` c provenance, plus a decidability-bounded set of lints over that state and over the `autoMode` block; `draft-auto-mode-rules` is the authoring lane, drafting an `autoMode` block to stdout for a human to paste. Both audit or generate and print; neither enforces and neither writes configuration. Existing -`claude-config` permission checks get in-place scope widening at the same time — **one of the two the -Brief promised is still unassigned; see the OPEN block after Phase 9.** No new plugin. +`claude-config` permission checks get in-place scope widening at the same time — **both are now named: +`audit-permission-grants` check P1 gains user-global scope, and `claude-config:audit`'s settings scan +gains the start-directory `settings.local.json` copy.** No new plugin. -_Amended 2026-08-09: one skill → two, on the operator's admission of the authoring lane. `MIGRATION-PLAYBOOK.md` -§Naming binds a skill name to its kind, so an `audit-*` skill cannot host a `draft` action._ +*Amended 2026-08-09: one skill → two, on the operator's admission of the authoring lane. `MIGRATION-PLAYBOOK.md` +§Naming binds a skill name to its kind, so an `audit-*` skill cannot host a `draft` action.* ### Goal @@ -306,18 +307,55 @@ The integration slice. Everything downstream reads what this produces. only. This script needs **separate** overrides for the user-home, managed-policy, and start-directory roots, so every scope is testable without ever reading or writing the operator's real `~/.claude/`. No test may touch the real user home. -- **Managed-policy paths are enumerated explicitly per OS**, sourced in Phase 0, with OS detection - stated at the site. Per §Cross-platform contract, verify on each supported platform **or record an - honest manual-verification gap in the shipped skill** — the Brief already concedes per-platform - behavior is assumed and not verified, so absent real verification the gap gets written down, not - omitted. +- **Managed-policy reader scope — DECIDED 2026-08-10: portable core plus declared optional platform + legs.** Phase 0 correction 1 splits the managed scope into four sub-surfaces with different costs, + so the reader is split the same way rather than being all-or-nothing: + - **Portable core, always read, on every OS:** the per-OS `managed-settings.json` and its sibling + `managed-settings.d/` drop-in directory. + - **Declared optional platform integrations:** the Windows registry keys + `HKLM\SOFTWARE\Policies\ClaudeCode` and `HKCU\SOFTWARE\Policies\ClaudeCode`, and the macOS + `com.anthropic.claudecode` managed-preferences domain. Each is read where it is native and + readable; where its tool is missing or the read fails it **warns visibly and skips that leg + only**, preserving the portable core result. That is §Prerequisites' *required for an optional + feature* class and §Cross-platform contract's *optional platform integrations must degrade + visibly* clause — a declared classification, not an unexplained gap. + - **Basis for splitting here rather than dropping the registry:** Phase 6's headline output is which + managed intents are enforced versus loosenable. A Windows reader that checks only + `%PROGRAMFILES%\ClaudeCode\managed-settings.json` does not under-report a registry-deployed + policy — it reports *no managed policy deployed* while one is in force. That is a wrong finding on + the plugin's primary platform, not a blind spot. + - **Elevation is not required.** Measured 2026-08-10 on Windows 11, unelevated: + `reg query "HKLM\SOFTWARE\Policies"` returns subkeys and exits 0. Recheck trigger: an unelevated + `reg query` of that path starts returning `ERROR: Access is denied` — basis, only a live probe + proves the ACL, and the plugin never elevates. + - **Verification honesty, per §Cross-platform contract.** The Windows registry leg is verified + empirically against a synthetic `HKCU` fixture key (no real policy is deployed on the development + machine, so a synthetic key is the only available positive case). The macOS plist domain and the + Linux paths **cannot** be verified from the development machine and ship with an honest + manual-verification gap recorded in the skill. This applies to the fully-built option too — no + option available here ships every leg verified. + - **`managed-settings.d/` merge semantics are a decidability caveat, not an assertion.** The reader + inventories and reads each drop-in file; any claim about how the drop-ins merge with each other or + with the base file carries a named caveat unless a fetched page states the ordering. + - Legacy `C:\ProgramData\ClaudeCode\managed-settings.json` is **never probed** — unsupported since + v2.1.75, and reading it would report policy not in force (Phase 0 correction 2). **Sanity Check:** - Point the fixture seams at a tree carrying **all five** scopes, then assert each named scope appears - exactly once: `grep -c '^managed\s'` = 1, `^user` = 1, `^project` = 1, `^local` = 1, and - `^startdir-local` = 1. A `≥2` count is not acceptable — it passes on project+local alone and leaves - criterion 7's dedicated scope member, and the managed scope, entirely unverified. + exactly once: `grep -c '^user'` = 1, `^project` = 1, `^local` = 1, and `^startdir-local` = 1. A `≥2` + count is not acceptable — it passes on project+local alone and leaves criterion 7's dedicated scope + member entirely unverified. +- The managed scope is four sub-surfaces, so it gets a **per-surface** assertion instead of one row. + Every leg emits a row on every OS — a non-native or unreadable leg emits an explicit + `not-applicable` / `skipped` row rather than nothing, so the row count is deterministic per OS and a + silently-missing leg is detectable: `grep -c '^managed file'` = 1, `^managed dropin` = 1, + `^managed registry` = 1, `^managed plist` = 1. Asserting only an aggregate `^managed` row would pass + with three of the four legs never attempted. +- Optional-leg degradation, per §Prerequisites: run on Windows with a stub `PATH` directory carrying + every needed tool **except** `reg`; assert exit 0, a visible warning naming the registry leg, a + `^managed registry ... skipped` row, and that the `^managed file` and `^managed dropin` rows are + still emitted — the portable core survives the optional leg's absence. - jq-absent behavior: create a stub directory containing every needed tool **except** `jq`, run with `PATH=`, assert exit 2 and `ERROR: jq required`. Do **not** use bare `PATH=` — measured, it yields `bash: command not found` and exit 127, because the interpreter itself becomes unresolvable, @@ -472,21 +510,20 @@ nothing about the guarantee it claims to protect. ### Phase 7: Authoring lane — `claude-config:draft-auto-mode-rules` [TODO] -Brainstorm candidate 7. Drafts an `autoMode` block from an interview plus the repo's observed prompt -and denial history, prints it to stdout, human pastes. **No write, no persistent state**, so it does -not trip the Brief's plugin-extraction recheck trigger. +Brainstorm candidate 7. Drafts an `autoMode` block from an interview plus the Phase 2 merge, prints it +to stdout, human pastes. **No write, no persistent state**, so it does not trip the Brief's +plugin-extraction recheck trigger. This is a **second sibling skill**, not an action on `audit-permission-state`: §Naming binds a skill name to its KIND, and an `audit-*` skill hosting a `draft` action would make its own name untrue. **Admitted 2026-08-09**, which amends the Brief's TLDR from one new skill to two and adds a second listing-budget entry that Phase 8 must state rather than assume. -- **Name the history source.** "The repo's observed prompt and denial history" is not a location. An - unnamed read surface in a skill shipped to consumers is unreviewable. Either bind it to a concrete - readable input (the debug-channel capture from Phase 3, which already carries per-rule denial data - and is already priced and opt-in) or drop the history input and drive the draft from the interview - plus the Phase 2 merge alone. **Recommended: drop it** — it removes a read surface, removes a second - dependency on the priced oracle, and the merge already carries what the draft needs. +- **History source — DECIDED 2026-08-10: dropped.** "The repo's observed prompt and denial history" + was not a location, and an unnamed read surface in a skill shipped to consumers is unreviewable. The + draft is driven by the interview plus the Phase 2 merge alone. This removes a read surface and a + second dependency on the priced oracle; binding it to the Phase 3 debug capture was the alternative + and was not taken. The skill must not acquire a history input without re-opening this decision. **Sanity Check:** a skill is a markdown surface, not a process, so nothing is piped from it. Assert instead against the deterministic script the skill drives: run it on a fixture and pipe **its** stdout @@ -531,10 +568,11 @@ Phase 8's sweep, which must include this skill. - Version bump: assert `plugins/claude-config/.claude-plugin/plugin.json` `version` differs from its value at the branch point, and that its `description` names both new skills. -### Phase 9: `audit-permission-grants` scope widening [TODO] +### Phase 9: existing-check scope widening and the shared extractions [TODO] -Acceptance criterion 11. **Not independent** — see the execution shape; it shares `plugin.json` with -Phase 8 and owns the file Phase 3's pattern extraction touches. +Acceptance criterion 11, the Brief's **second** widening, and the two extractions later phases consume. +**Not independent** — see the execution shape; it shares `plugin.json` with Phase 8 and owns the file +Phase 3's pattern extraction touches. - The criteria file and the detector scan project and local settings only, so a user-global interpreter-wildcard rule is invisible to check P1. Add the user-global scope. @@ -547,30 +585,66 @@ Phase 8 and owns the file Phase 3's pattern extraction touches. - **Extract the shared pattern vocabulary here**, side-effect-free, so Phase 3 can source it. The current file self-executes and `exit 0`s at load, so it cannot be sourced as it stands. -**Sanity Check:** with the new fixture-home seam pointed at a fake home containing one -interpreter-wildcard rule, assert exactly one P1 finding naming that file; assert the finding does -**not** appear when the seam is unset (proving the fixture, not the real home, produced it); assert -`scripts/permission-rule-check.test.sh` still passes. +- **The Brief's second widening — DECIDED 2026-08-10: `claude-config:audit`'s settings scan gains the + start-directory `settings.local.json` copy.** `check-structure.sh` reads the repository-root copy + only. The settings page states the harness still reads a `.claude/settings.local.json` an earlier + version left in the starting directory, and that permission rules from **both** files stay in + effect — so a rule set nobody audits is live. This is the same criterion-7 surface + `audit-permission-state` covers, applied in place to the existing check. It rides Phase 8's single + `plugin.json` bump like the P1 widening does. + +- **Shared managed-scope enumeration — DECIDED 2026-08-10: extract, do not write a third copy.** + Approving Phase 1's managed reader makes this the **third** in-repo component enumerating managed + paths, after `claude-config:audit/scripts/check-structure.sh` (per-OS JSON file + `managed-settings.d/`, + test seam `SETTINGS_AUDIT_MANAGED_PATH`) and `claude-memory:stateless/scripts/scope-report.sh` + (file only; registry flagged, deliberately not read). §Convention registry binds a cross-plugin + convention to an owner doc **before** a second adopter, and we are already past two. + - Plugin-form isolation forbids a runtime reach-out across plugin roots, so the repo's established + mechanism is a byte-identical copy at the same path-within-plugin, plus a dedicated sync/drift + check, registered in `scripts/cross-plugin-source-registry.txt`. Follow that mechanism rather than + inventing a second one — an unregistered identical cluster is exactly what + `check-cross-plugin-source-drift.sh` exists to flag. + - Scope the shared source to **path enumeration and OS detection only**. Presentation, redaction + posture, and each caller's existing output stay with the caller: `check-structure.sh` deliberately + reports managed policy as counts rather than values, and `scope-report.sh` deliberately reports + presence only. Migrating either one's *output* is not in this plan. + - **Blast radius to state, not discover later:** this edits a second plugin (`claude-memory`), which + owes its own version bump and CHANGELOG entry independent of `claude-config`'s. -### The Brief's second scope-widening is unassigned — OPEN +**Sanity Check:** -The Brief commits twice to widening **two** existing checks (`### Constraints` → Packaging: "plus -in-place scope widening of two existing checks"; and the TLDR). Only one is named anywhere — P1, in -criterion 11 and this phase. The second is identified nowhere in the Brief, the criteria, the -interview register, or the brainstorm. +- With the new fixture-home seam pointed at a fake home containing one interpreter-wildcard rule, + assert exactly one P1 finding naming that file; assert the finding does **not** appear when the seam + is unset (proving the fixture, not the real home, produced it); assert + `scripts/permission-rule-check.test.sh` still passes. +- Second widening: with a fixture tree carrying a start-directory `.claude/settings.local.json` that + the repository root does not carry, assert `check-structure.sh` emits a row naming that file, and + that its existing tests still pass. Assert the row is **absent** on a fixture with no start-directory + copy, so the check cannot pass by always emitting it. +- Extractions: `bash -n` plus a source-and-return test proves the extracted files are side-effect-free + (sourcing them runs nothing and exits nothing); `scripts/check-cross-plugin-source-drift.sh --check` + exits 0 with the new cluster registered; and deliberately perturbing one copy makes it exit non-zero, + proving the drift check actually covers the new cluster rather than silently ignoring it. + +### The Brief's second scope-widening — RESOLVED 2026-08-10 -Do not guess it. The strongest candidate from the research is the `claude-config:audit` skill's -settings-file scan, because a pre-v2.1.211 `settings.local.json` left in the start directory is still -read and nothing checks it today — but that is inference, not a recorded decision. Resolve before -Phase 8 closes; it is a Brief commitment that would otherwise ship unbuilt. +The Brief commits twice to widening **two** existing checks (`### Constraints` → Packaging: "plus +in-place scope widening of two existing checks"; and the TLDR), and only P1 was ever named. The +operator resolved it rather than the plan guessing it: the second is **`claude-config:audit`'s +settings-file scan**, widened to the pre-v2.1.211 start-directory `settings.local.json` copy. It is a +Phase 9 work item with its own sanity check. The alternative — striking "two" from the Brief and +shipping one widening — was offered and not taken. ## Blast radius -**MEDIUM-HIGH.** A new component shipped from a marketplace consumed downstream, plus an in-place -behavior change to an existing detector (Phase 9) that widens what it flags — consumers will see new -findings on unchanged repos. Mitigated by: report-only throughout (criterion 9), no consumer writes -under any flag, and the managed tier read-only by construction. The genuinely irreversible surface is -the published skill name, which is why naming was derived against §Naming rather than chosen. +**MEDIUM-HIGH.** A new component shipped from a marketplace consumed downstream, plus in-place +behavior changes to **two** existing checks (Phase 9) that widen what they flag — consumers will see +new findings on unchanged repos. Phase 9's shared managed-scope extraction also edits a **second +plugin** (`claude-memory`), which owes its own version bump and CHANGELOG entry. Mitigated by: +report-only throughout (criterion 9), no consumer writes under any flag, the managed tier read-only by +construction, and the extraction scoped to path enumeration so no caller's existing output changes. +The genuinely irreversible surface is the published skill name, which is why naming was derived +against §Naming rather than chosen. ## Stress-test summary @@ -612,7 +686,7 @@ files outside the scratch path. It is unverified, not a finding — carried into ## Execution shape -Nine phases; not fully sequential. +Ten phases, 0 through 9. **Corrected after review: there is no parallel-safe set. This plan is sequential.** @@ -627,7 +701,7 @@ pattern extraction Phase 3 consumes. | Order | Phase | Gated by | |---|---|---| | 1 | 0 | — (fresh-docs mandate; everything downstream cites it) | -| 2 | 9 | 0. Moved early: it owns the fixture-home seam and the extracted pattern vocabulary Phases 1 and 3 need. | +| 2 | 9 | 0. Moved early: it owns the fixture-home seam, the extracted pattern vocabulary, and the shared managed-scope enumeration that Phases 1 and 3 consume. | | 3 | 1 | 9 (fixture seams) | | 4 | 2 | 1 | | 5 | 3 | 2, 9 (pattern vocabulary) | @@ -684,8 +758,9 @@ Remaining genuinely open, carried into implementation: writes to consumer *settings*, and a transcript is not a settings file, but the boundary was never measured. Measure it in Phase 3 before the oracle ships, and state the result in the flag's cost notice — a feature that spawns a session must be honest about everything it leaves behind. -- **Which second existing check the Brief meant.** See the OPEN block after Phase 9. Needs a decision, - not a guess. +- **Whether `managed-settings.d/` drop-ins have a stated merge order.** Phase 0's fetched pages + enumerate the directory but no ordering was recorded. Until a page states it, the reader inventories + the drop-ins and any merged-result claim carries a decidability caveat. ## Handoff to implementation From c43ccc9183492d6bdb51879e9bacb2a047eb528a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:04:51 -0400 Subject: [PATCH 04/14] feat(claude-config): widen the permission checks and extract their shared vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check P1 scanned project and local settings only, so an interpreter-wildcard rule in the user-global settings file was invisible to it — and that is the scope Claude Code's own "Always allow" path writes to, so it is where the broad rules auto mode drops actually accumulate. The user scope resolves CLAUDE_CONFIG_DIR before $HOME, matching the resolver claude-memory already cites to the .claude-directory doc; that resolution is also the test seam, so a fixture home replaces the operator's real one and no test reads it. The audit skill's structure check gains the pre-v2.1.211 start-directory settings.local.json, whose permission rules stay in effect alongside the repository-root copy. The row appears only where the two directories genuinely differ, so one file is never counted as two rule sources. Two libraries come out of the widening. The auto-mode drop vocabulary was inline in a detector that self-executes and cannot be sourced, leaving a second consumer no way to reuse it; the per-OS managed-policy locations were hand-kept in three places that had already disagreed about whether the drop-in directory exists. claude-memory carries a byte-identical copy of the managed-scope library, registered as a cross-plugin cluster so the two cannot drift — verified by perturbing a copy and watching the check fail. Both file-only readers now name the managed surfaces they do not read (the Windows policy registry keys, the macOS preferences domain) so an absent managed-settings.json cannot read as "no managed policy deployed". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/topics/permission-model/PLAN.md | 46 +++++++-- .../permission-model/phase0-fresh-docs.md | 23 +++++ .../claude-config/.claude-plugin/plugin.json | 2 +- plugins/claude-config/CHANGELOG.md | 34 +++++++ plugins/claude-config/lib/managed-scope.sh | 91 ++++++++++++++++++ .../claude-config/lib/managed-scope.test.sh | 95 +++++++++++++++++++ .../claude-config/lib/permission-patterns.sh | 73 ++++++++++++++ .../lib/permission-patterns.test.sh | 64 +++++++++++++ .../skills/audit-permission-grants/SKILL.md | 13 ++- .../reference/criteria.md | 11 ++- .../scripts/permission-rule-check.sh | 78 ++++++++------- .../scripts/permission-rule-check.test.sh | 40 +++++++- plugins/claude-config/skills/audit/SKILL.md | 2 + .../skills/audit/scripts/check-structure.sh | 62 ++++++++---- .../audit/scripts/check-structure.test.sh | 44 +++++++++ .../claude-memory/.claude-plugin/plugin.json | 2 +- plugins/claude-memory/CHANGELOG.md | 15 +++ plugins/claude-memory/lib/managed-scope.sh | 91 ++++++++++++++++++ .../skills/stateless/scripts/scope-report.sh | 31 +++--- scripts/cross-plugin-source-registry.txt | 20 +++- 20 files changed, 743 insertions(+), 94 deletions(-) create mode 100644 plugins/claude-config/lib/managed-scope.sh create mode 100644 plugins/claude-config/lib/managed-scope.test.sh create mode 100644 plugins/claude-config/lib/permission-patterns.sh create mode 100644 plugins/claude-config/lib/permission-patterns.test.sh create mode 100644 plugins/claude-memory/lib/managed-scope.sh diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index 216e4483c6..0f64fb80e7 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -334,9 +334,17 @@ The integration slice. Everything downstream reads what this produces. Linux paths **cannot** be verified from the development machine and ship with an honest manual-verification gap recorded in the skill. This applies to the fully-built option too — no option available here ships every leg verified. - - **`managed-settings.d/` merge semantics are a decidability caveat, not an assertion.** The reader - inventories and reads each drop-in file; any claim about how the drop-ins merge with each other or - with the base file carries a named caveat unless a fetched page states the ordering. + - **`managed-settings.d/` merge semantics are decided, not caveated.** The settings page states the + systemd-convention ordering verbatim (base first, then `*.json` sorted alphabetically on top; + scalars overridden, arrays concatenated and de-duplicated, objects deep-merged, dotfiles ignored) — + see the Phase 0 addendum. The reader implements that order rather than reporting an inventory. + - **The Windows registry leg reads one value, and `HKCU` is a fallback, not a peer.** The policy + JSON lives in a `Settings` value (`REG_SZ`/`REG_EXPAND_SZ`) on the key, so the read targets that + value; `HKCU` is documented as lowest policy priority, "only used when no admin-level source + exists", so consulting it while `HKLM` carries policy would report policy that is not in force. + - **Server-managed settings are a managed source with no local path.** Delivered remotely at + sign-in, so no local reader can see them. Every managed finding says "the local managed surfaces", + never "the managed policy" — the completeness claim is not available. - Legacy `C:\ProgramData\ClaudeCode\managed-settings.json` is **never probed** — unsupported since v2.1.75, and reading it would report policy not in force (Phase 0 correction 2). @@ -568,7 +576,28 @@ Phase 8's sweep, which must include this skill. - Version bump: assert `plugins/claude-config/.claude-plugin/plugin.json` `version` differs from its value at the branch point, and that its `description` names both new skills. -### Phase 9: existing-check scope widening and the shared extractions [TODO] +### Phase 9: existing-check scope widening and the shared extractions [DONE] + +**Completed 2026-08-10.** Every sanity check below was run and passed: `permission-rule-check.test.sh` +57/57 (7 of them new), `check-structure.test.sh` 44/44 (10 new), `scope-report.test.sh` 21/21, +`managed-scope.test.sh` 16/16, `permission-patterns.test.sh` 12/12, +`check-cross-plugin-source-drift.sh --check` clean and proven to FAIL on a deliberately perturbed +copy, `shellcheck -x` clean, `check-shell-portability.sh` clean, `check-changelog-parity.sh --check` +and `--check-bump origin/main` clean. + +Two deliberate divergences from the phase as written, both recorded rather than silent: + +- **No bespoke fixture-home environment variable was added.** The phase assumed one had to be + invented because "there is no way to point it at a fake user home". There is: the user scope + resolves `${CLAUDE_CONFIG_DIR:-$HOME/.claude}` — the same resolver `claude-memory` already uses and + cites to the `.claude-directory` doc — so a test points `$HOME` at a fixture home and unsets + `CLAUDE_CONFIG_DIR`. Reusing that seam beats a third way of saying the same thing, and it made the + relocated-config-root case testable for free. +- **The shared managed-scope cluster ships without a dedicated sync script.** + `check-cross-plugin-source-drift.sh --check` is already a required CI job and fails on a drifted + registered cluster, so registration alone enforces the invariant; the registry entry names the + canonical copy so a drift failure says which direction to fix. The registry's header, which claimed + every listed cluster already had a dedicated check, was corrected rather than left untrue. Acceptance criterion 11, the Brief's **second** widening, and the two extractions later phases consume. **Not independent** — see the execution shape; it shares `plugin.json` with Phase 8 and owns the file @@ -758,9 +787,12 @@ Remaining genuinely open, carried into implementation: writes to consumer *settings*, and a transcript is not a settings file, but the boundary was never measured. Measure it in Phase 3 before the oracle ships, and state the result in the flag's cost notice — a feature that spawns a session must be honest about everything it leaves behind. -- **Whether `managed-settings.d/` drop-ins have a stated merge order.** Phase 0's fetched pages - enumerate the directory but no ordering was recorded. Until a page states it, the reader inventories - the drop-ins and any merged-result claim carries a decidability caveat. +- **The worktree resolution gap in the two existing detectors.** `permission-rule-check.sh` and + `check-structure.sh` both anchor on `git rev-parse --show-toplevel`, which yields the **worktree** + root, while the settings page says `.claude/settings.local.json` resolves through worktrees to the + **main checkout**. In a worktree they therefore look for the local file where it is not. Phase 1's + new reader must resolve the main checkout; retrofitting the two existing detectors is a real but + separate behavior change, deliberately not folded into Phase 9's approved scope. ## Handoff to implementation diff --git a/docs/topics/permission-model/phase0-fresh-docs.md b/docs/topics/permission-model/phase0-fresh-docs.md index 39f85481ab..d541f444f3 100644 --- a/docs/topics/permission-model/phase0-fresh-docs.md +++ b/docs/topics/permission-model/phase0-fresh-docs.md @@ -91,6 +91,29 @@ organization `soft_deny`, so managed auto-mode rules are not a hard policy bound The startup warning in that last item is itself useful: it is a readable signal carrying rule text, one of the channels the Brief listed as unexplored. +## Addendum — 2026-08-10, managed-surface detail Phase 9 needed + +Same page (), re-fetched while building Phase 9's shared +managed-scope enumeration. These four facts were not in the original pass and each changes what a +reader must do: + +| Fact | Wording | +|---|---| +| Drop-in merge order **is** documented | "Following the systemd convention, `managed-settings.json` is merged first as the base, then all `*.json` files in the drop-in directory are sorted alphabetically and merged on top. Later files override earlier ones for scalar values, arrays are concatenated and de-duplicated, and objects are deep-merged. Hidden files starting with `.` are ignored." | +| The Windows policy key holds JSON in one **value** | `HKLM\SOFTWARE\Policies\ClaudeCode` "registry key with a `Settings` value (REG_SZ or REG_EXPAND_SZ) containing JSON" — a reader wants that value, not the key's subkeys | +| `HKCU` is **not** a peer of `HKLM` | `HKCU\SOFTWARE\Policies\ClaudeCode` is "lowest policy priority, only used when no admin-level source exists". Merging both would report policy that is not in force | +| A managed source exists that no local reader can see | "Server-managed settings: delivered remotely at sign-in from Anthropic's servers via the claude.ai admin console or from a self-hosted Claude apps gateway" | + +Consequences carried into the plan: + +- Phase 1 can state drop-in merge results as **decided**, not caveated — the ordering is documented. + The `$defaults`-style caveat the Brief's decidability bound calls for does not apply here. +- Phase 1's Windows registry leg reads the `Settings` value and consults `HKCU` **only** when `HKLM` + carries nothing. +- Phase 6's managed-conformance report carries a standing caveat that server-managed settings are a + managed source with no local path, so "the deployed managed policy" always means the local + surfaces. A report that omits this implies a completeness it cannot have. + ## Version constants cleared for use `v2.1.75`, `v2.1.193`, `v2.1.198`, `v2.1.200`, `v2.1.203`, `v2.1.207`, `v2.1.208`, `v2.1.211`, diff --git a/plugins/claude-config/.claude-plugin/plugin.json b/plugins/claude-config/.claude-plugin/plugin.json index 06171b84df..ce56ac2c38 100644 --- a/plugins/claude-config/.claude-plugin/plugin.json +++ b/plugins/claude-config/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-config", - "version": "0.29.0", + "version": "0.30.0", "description": "Seven configuration-health skills (plus setup) for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-instructions (locally-owned instruction surfaces vs current model capability — proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), audit-prompting-postures (the additive lane — posture guidance the prompting guide says a component's purpose needs but the component does not carry), audit-pass (one coordinated, ordered, resumable pass over a named target — three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate — delegating every check to the plugin that owns it), and unhobble (the empirical bare-baseline experiment: reversibly strip a repo's standing instructions, log real stumbles against the current model, re-add only what evidence earns).", "author": { "name": "Melodic Software", diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index 3bbe8d490f..0873797af5 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -3,6 +3,40 @@ All notable changes to the `claude-config` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.30.0] + +### Changed + +- **`audit-permission-grants` check P1 now sees user-global allow rules.** It scanned project and + local settings only, so an interpreter-wildcard rule in + `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json` was invisible to it — and that is the scope + Claude Code's own "Always allow" path writes to, so it is where the broad rules auto mode drops + actually accumulate. Expect new findings on a repository whose own configuration did not change. + The user scope resolves through `CLAUDE_CONFIG_DIR` before `$HOME`, and a finding names the + resolved absolute path rather than `~/.claude/settings.json`, which would name the wrong file + whenever the config root has been relocated. +- **`audit`'s structure check now reports a start-directory `settings.local.json`.** A pre-v2.1.211 + Claude Code wrote the file to the directory the session started in, and the current one still + reads what an earlier version left there: the repository-root copy wins on a shared key, but + permission rules from **both** files stay in effect. The row appears only when the start directory + genuinely differs from the project root and a copy is there, so the same file is never counted as + two rule sources. +- **`audit`'s structure check names the managed surfaces it does not read.** On Windows the + `HKLM`/`HKCU\SOFTWARE\Policies\ClaudeCode` policy keys, on macOS the `com.anthropic.claudecode` + managed-preferences domain. A file-only reader that stays silent about them lets an absent + `managed-settings.json` read as "no managed policy deployed" while a policy is in force. + +### Added + +- **`lib/permission-patterns.sh`** — the auto-mode drop vocabulary (blanket, wildcarded-interpreter, + package-manager-runner, and script-glob rule shapes, plus the top-level tool-token grammar) as a + define-only library. It was inline in the P1 detector, which self-executes and cannot be sourced, + so a second consumer had no way to reuse it without copying. +- **`lib/managed-scope.sh`** — the per-OS managed-policy surface enumeration (base JSON file, + `managed-settings.d/` drop-in directory, Windows policy registry keys, macOS preferences domain). + `claude-memory` carries a byte-identical copy, registered as a cross-plugin shared-source cluster + so the two cannot drift. + ## [0.29.0] ### Removed diff --git a/plugins/claude-config/lib/managed-scope.sh b/plugins/claude-config/lib/managed-scope.sh new file mode 100644 index 0000000000..c0fc91aac0 --- /dev/null +++ b/plugins/claude-config/lib/managed-scope.sh @@ -0,0 +1,91 @@ +# shellcheck shell=bash +# Managed (machine-scope) policy surfaces — per-OS enumeration, library only. +# +# No top-level execution, no env-driven side effects, no exit calls. Callers own +# presentation, redaction posture, test seams, and exit-code mapping: one caller +# reports managed policy as counts, another as presence only, and a third reads +# it to compute an effective merge. Only the LOCATIONS are shared. +# +# WHY THIS EXISTS: managed policy is not one file. Per the official settings doc +# it is, per OS, a JSON file plus a `managed-settings.d/` drop-in directory, plus +# a Windows registry policy key or a macOS managed-preferences domain. Three +# components in this marketplace need that enumeration, and a third hand-written +# copy would drift the moment upstream adds or moves a surface — as it already +# had: the copies disagreed about whether the drop-in directory existed at all. +# +# NOT COVERED, deliberately: server-managed settings, which the doc describes as +# "delivered remotely at sign-in from Anthropic's servers via the claude.ai admin +# console or from a self-hosted Claude apps gateway". They have no local path to +# enumerate, so a local reader cannot see them and must not imply it has. +# +# The legacy Windows location C:\ProgramData\ClaudeCode\managed-settings.json is +# unsupported since v2.1.75 and is deliberately never probed — reporting it would +# report policy that is not in force. +# +# Verified against https://code.claude.com/docs/en/settings on 2026-08-10. +# Recheck trigger: that page's managed-settings location list gains, drops, or +# moves a surface. Basis: the paths are documented, not discoverable — a machine +# with no policy deployed looks identical to a machine whose policy this file +# fails to find. + +# mscope::base_file [override] — absolute path to the managed-settings.json this +# OS reads. A non-empty is returned verbatim, so a caller's own test +# seam stays the caller's: the real locations are absolute system paths that a +# fixture directory cannot reach. +# +# Windows resolves through $PROGRAMFILES so a relocated Program Files directory +# still resolves; the doc spells the default as C:\Program Files\ClaudeCode. +mscope::base_file() { + local override="${1:-}" + if [[ -n "$override" ]]; then + printf '%s\n' "$override" + return 0 + fi + case "$OSTYPE" in + darwin*) printf '%s\n' "/Library/Application Support/ClaudeCode/managed-settings.json" ;; + msys* | cygwin*) printf '%s\n' "${PROGRAMFILES:-C:\\Program Files}\\ClaudeCode\\managed-settings.json" ;; + *) printf '%s\n' "/etc/claude-code/managed-settings.json" ;; + esac +} + +# mscope::dropin_dir [override] — absolute path to the managed-settings.d +# directory that sits beside the base file. Derived from the base file so an +# override relocates both together, which is what a fixture needs. +# +# Merge semantics, for callers that report them: "managed-settings.json is merged +# first as the base, then all *.json files in the drop-in directory are sorted +# alphabetically and merged on top. Later files override earlier ones for scalar +# values, arrays are concatenated and de-duplicated, and objects are deep-merged. +# Hidden files starting with . are ignored." +mscope::dropin_dir() { + local base + base="$(mscope::base_file "${1:-}")" + printf '%s\n' "${base%managed-settings.json}managed-settings.d" +} + +# mscope::registry_keys — Windows policy keys, one per line, highest policy +# priority first; nothing at all on other platforms. Each key carries the policy +# JSON in a `Settings` value (REG_SZ or REG_EXPAND_SZ), so a reader wants that +# value, not the key's subkeys. HKCU is "lowest policy priority, only used when +# no admin-level source exists" — a reader that merges both would report policy +# that is not in force. +mscope::registry_keys() { + case "$OSTYPE" in + msys* | cygwin*) + # portability-ok: the `\S` here is the literal first character of SOFTWARE in + # a single-quoted Windows registry path, not a GNU regex escape. These lines + # only ever run on Windows, and `printf '%s'` does no escape interpretation. + printf '%s\n' 'HKLM\SOFTWARE\Policies\ClaudeCode' + printf '%s\n' 'HKCU\SOFTWARE\Policies\ClaudeCode' + ;; + *) ;; + esac +} + +# mscope::plist_domain — the macOS managed-preferences domain, empty elsewhere. +mscope::plist_domain() { + case "$OSTYPE" in + darwin*) printf '%s\n' "com.anthropic.claudecode" ;; + *) ;; + esac +} diff --git a/plugins/claude-config/lib/managed-scope.test.sh b/plugins/claude-config/lib/managed-scope.test.sh new file mode 100644 index 0000000000..a592d1cb93 --- /dev/null +++ b/plugins/claude-config/lib/managed-scope.test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Regression tests for lib/managed-scope.sh — the per-OS managed-policy surface +# enumeration shared by claude-config's audit skills and claude-memory's scope +# report. +# +# The library contract this protects is side-effect freedom: callers source it, +# so a stray top-level command, output, or `exit` would run inside — and could +# terminate — an unrelated script. +set -uo pipefail + +LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/managed-scope.sh" + +FAILED=0 +CASE_NUM=0 +pass() { + CASE_NUM=$((CASE_NUM + 1)) + printf 'PASS: %s\n' "$1" +} +fail() { + CASE_NUM=$((CASE_NUM + 1)) + FAILED=$((FAILED + 1)) + printf 'FAIL: %s\n detail: %s\n' "$1" "$2" >&2 +} +assert_eq() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected: $2, actual: $3"; fi +} +assert_contains() { + case "$2" in + *"$3"*) pass "$1" ;; + *) fail "$1" "expected to contain: $3" ;; + esac +} + +# --- Side-effect freedom ----------------------------------------------------- +rc=0 +bash -n "$LIB" || rc=$? +assert_eq "parses under bash -n" "0" "$rc" + +# Sourcing must produce no output on either stream and must not exit the shell. +# `echo SURVIVED` after the source is the exit probe: a top-level `exit` in the +# library would swallow it. +out=$(bash -c "source '$LIB' >/dev/null 2>&1; echo SURVIVED" 2>&1) +assert_eq "sourcing does not exit the caller" "SURVIVED" "$out" +noise=$(bash -c "source '$LIB'" 2>&1) +assert_eq "sourcing writes nothing to stdout or stderr" "" "$noise" + +# --- Path resolution --------------------------------------------------------- +assert_eq "macOS base file" \ + "/Library/Application Support/ClaudeCode/managed-settings.json" \ + "$(OSTYPE=darwin23 bash -c "source '$LIB'; mscope::base_file")" +assert_eq "Linux base file" \ + "/etc/claude-code/managed-settings.json" \ + "$(OSTYPE=linux-gnu bash -c "source '$LIB'; mscope::base_file")" +assert_contains "Windows base file resolves through PROGRAMFILES" \ + "$(OSTYPE=msys PROGRAMFILES='X:\PF' bash -c "source '$LIB'; mscope::base_file")" \ + 'X:\PF\ClaudeCode\managed-settings.json' + +# The legacy Windows location is unsupported since v2.1.75; probing it would +# report policy that is not in force. +assert_eq "legacy ProgramData path never emitted" "" \ + "$(OSTYPE=msys bash -c "source '$LIB'; mscope::base_file" | grep -i 'ProgramData' || true)" + +# --- Drop-in directory sits beside the base, override and all ---------------- +assert_eq "Linux drop-in dir" \ + "/etc/claude-code/managed-settings.d" \ + "$(OSTYPE=linux-gnu bash -c "source '$LIB'; mscope::dropin_dir")" +assert_eq "override relocates the base file verbatim" \ + "/tmp/fixture/managed-settings.json" \ + "$(bash -c "source '$LIB'; mscope::base_file /tmp/fixture/managed-settings.json")" +assert_eq "override relocates the drop-in dir with it" \ + "/tmp/fixture/managed-settings.d" \ + "$(bash -c "source '$LIB'; mscope::dropin_dir /tmp/fixture/managed-settings.json")" + +# --- Non-file surfaces, emitted only where they exist ------------------------- +win_keys="$(OSTYPE=msys bash -c "source '$LIB'; mscope::registry_keys")" +# portability-ok: the `\S` below is the literal first character of SOFTWARE in a +# single-quoted Windows registry path, not a GNU regex escape. The assertions are +# shell string comparisons; no regex engine sees these values. +assert_contains "Windows emits the admin-level policy key" "$win_keys" 'HKLM\SOFTWARE\Policies\ClaudeCode' +assert_contains "Windows emits the user-level policy key" "$win_keys" 'HKCU\SOFTWARE\Policies\ClaudeCode' +assert_eq "HKLM is listed first (HKCU is lowest policy priority)" \ + 'HKLM\SOFTWARE\Policies\ClaudeCode' "$(printf '%s\n' "$win_keys" | head -1)" +assert_eq "no registry keys off Windows" "" \ + "$(OSTYPE=linux-gnu bash -c "source '$LIB'; mscope::registry_keys")" +assert_eq "macOS preferences domain" "com.anthropic.claudecode" \ + "$(OSTYPE=darwin23 bash -c "source '$LIB'; mscope::plist_domain")" +assert_eq "no preferences domain off macOS" "" \ + "$(OSTYPE=linux-gnu bash -c "source '$LIB'; mscope::plist_domain")" + +if [[ "$FAILED" -eq 0 ]]; then + printf '\nAll %d checks passed.\n' "$CASE_NUM" + exit 0 +fi +printf '\n%d/%d checks failed.\n' "$FAILED" "$CASE_NUM" >&2 +exit 1 diff --git a/plugins/claude-config/lib/permission-patterns.sh b/plugins/claude-config/lib/permission-patterns.sh new file mode 100644 index 0000000000..2b17b65c78 --- /dev/null +++ b/plugins/claude-config/lib/permission-patterns.sh @@ -0,0 +1,73 @@ +# shellcheck shell=bash +# The bodies are consumed by the sourcing drivers, never in this file itself — +# SC2034 "appears unused" is a false positive for a define-only library. +# shellcheck disable=SC2034 +# Claude Code auto-mode drop vocabulary — shared regex BODIES, define-only. +# +# No functions, no env reads, no I/O, no exit calls. A driver sources this file +# and adds its own wrapping: which surfaces it scans, how it labels a finding, +# severity, and exit-code mapping stay driver-owned. +# +# WHY THIS EXISTS: two components need the same answer to "which allow rules does +# Claude Code drop on entering auto mode" — `audit-permission-grants` check P1, +# which flags them as fragile grants, and `audit-permission-state`, which renders +# the before/after entry diff. A second copy of these alternations would drift the +# moment one side learned a new interpreter or runner. +# +# POSIX ERE only (grep -E) — NO grep -P: macOS BSD grep lacks it entirely, and +# bash =~ delegates to the platform regex library, so non-POSIX extensions would +# not behave identically on Linux CI and Git Bash. +# +# DEFINE single-quoted where the body carries backslashes, EXPAND double-quoted +# ("$CCPERM_…"): a double-quoted definition would collapse the escapes and +# silently change what grep matches. + +# The rule classes upstream documents as dropped on entering auto mode: blanket +# `Bash(*)`/`PowerShell(*)`, wildcarded interpreters, package-manager run +# commands, and all `Agent` allow rules. Narrow rules such as `Bash(npm test)` +# carry over and must never match. +# +# python accepts version suffixes (python3, python3.11, python2.7): pinned +# minor-version binaries are the same interpreter-led grant shape. +CCPERM_INTERP_BODY='python[0-9.]*|node|deno|bun|ruby|perl|php|bash|sh|zsh|pwsh|osascript|Rscript' +CCPERM_RUNNER_BODY='npx|bunx|uvx|pnpm dlx|yarn dlx|pipx run|uv run|npm|pnpm|yarn' +CCPERM_SCRIPT_BODY='py|sh|rb|js|ts|mjs|cjs|pl|php' + +# One ERE, case-sensitive on the tool name. Each alternative requires a wildcard +# so an exact narrow rule (Bash(npm test)) never matches: +# 1. blanket Bash(*) / PowerShell(*) +# 2. an interpreter at the command position followed (eventually) by a * — +# the interpreter may carry a path prefix (Bash(.venv/bin/python *), +# Bash(/usr/bin/python3 *)): a wildcarded interpreter-led grant is the +# same arbitrary-code shape regardless of how the interpreter is addressed. +# What follows the name must be a * or a real separator (space, quote, :) +# so a hyphenated bare PATH command that merely starts with an interpreter +# or runner name (Bash(node-gyp:*), Bash(npm-check-updates:*)) — the very +# shape the convention recommends — is not flagged +# 3. a package-manager run/exec command followed by a * — both the run/exec +# subcommand forms (npx, pnpm dlx, uv run, …) and a bare package manager +# wildcard (Bash(npm:*), Bash(npm *)), which grants arbitrary execution +# via npm exec / lifecycle scripts. A bare package-manager name subsumes +# its own run wildcard (npm matches `npm run *`), so `npm run` etc. are not +# listed separately. A fixed subcommand (Bash(npm test), Bash(npm run +# build)) carries no * and is not matched. +# 4. a leading-glob command that resolves to a script (Bash(*.py:*)) +# +# Each alternative captures the whole Tool(...) spec (trailing [^)]*\) ) so a +# driver reports the full offending rule, not a substring truncated at the *. +CCPERM_P1_ERE="(Bash|PowerShell)\\(\\*\\)" +CCPERM_P1_ERE="${CCPERM_P1_ERE}|(Bash|PowerShell)\\([\"' ]*([^)\"' ]*[/\\\\])?(${CCPERM_INTERP_BODY})([\"' :][^)]*)?\\*[^)]*\\)" +CCPERM_P1_ERE="${CCPERM_P1_ERE}|(Bash|PowerShell)\\([\"' ]*(${CCPERM_RUNNER_BODY})([\"' :][^)]*)?\\*[^)]*\\)" +CCPERM_P1_ERE="${CCPERM_P1_ERE}|(Bash|PowerShell)\\([\"' ]*\\*[^)]*\\.(${CCPERM_SCRIPT_BODY})[^)]*\\)" + +# Splits rule text into top-level `Tool` / `Tool(...)` tokens. The greedy +# `(\(...\))?` consumes a tool's whole parenthesized payload as one token, so a +# tool name inside another rule's payload (e.g. Bash(echo Agent), Bash(grep +# PowerShell *)) never surfaces as its own token. The payload accepts one level +# of nested parentheses (Bash(echo $(date) Agent), Bash(node -e "log()" +# PowerShell)) so an inner `)` does not end the token early; ERE cannot balance +# arbitrary depth, and rule payloads realistically nest at most once. +# +# Drivers use this to tell a whole-tool grant (bare `Bash`, bare or scoped +# `Agent`) from a scoped rule that merely mentions a tool name in its payload. +CCPERM_TOOL_TOKEN_ERE='[A-Za-z_][A-Za-z0-9_]*(\(([^()]|\([^()]*\))*\))?' diff --git a/plugins/claude-config/lib/permission-patterns.test.sh b/plugins/claude-config/lib/permission-patterns.test.sh new file mode 100644 index 0000000000..e1173e098d --- /dev/null +++ b/plugins/claude-config/lib/permission-patterns.test.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Regression tests for lib/permission-patterns.sh — the auto-mode drop +# vocabulary shared by audit-permission-grants' check P1 and +# audit-permission-state's entry diff. +# +# The classification behaviour is exercised in depth by the P1 detector's own +# suite. What is proved HERE is what only a second consumer needs: the file is +# safe to source, and its bodies are usable standalone without the detector. +set -uo pipefail + +LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/permission-patterns.sh" + +FAILED=0 +CASE_NUM=0 +pass() { + CASE_NUM=$((CASE_NUM + 1)) + printf 'PASS: %s\n' "$1" +} +fail() { + CASE_NUM=$((CASE_NUM + 1)) + FAILED=$((FAILED + 1)) + printf 'FAIL: %s\n detail: %s\n' "$1" "$2" >&2 +} +assert_eq() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected: $2, actual: $3"; fi +} + +# --- Side-effect freedom ----------------------------------------------------- +rc=0 +bash -n "$LIB" || rc=$? +assert_eq "parses under bash -n" "0" "$rc" + +out=$(bash -c "source '$LIB' >/dev/null 2>&1; echo SURVIVED" 2>&1) +assert_eq "sourcing does not exit the caller" "SURVIVED" "$out" +noise=$(bash -c "source '$LIB'" 2>&1) +assert_eq "sourcing writes nothing to stdout or stderr" "" "$noise" + +# --- The vocabulary is defined and usable standalone ------------------------- +# shellcheck source=permission-patterns.sh +source "$LIB" +assert_eq "P1 body defined" "0" "$([[ -n "${CCPERM_P1_ERE:-}" ]] && echo 0 || echo 1)" +assert_eq "tool-token body defined" "0" "$([[ -n "${CCPERM_TOOL_TOKEN_ERE:-}" ]] && echo 0 || echo 1)" + +matches() { printf '%s\n' "$1" | grep -qE "$CCPERM_P1_ERE" && echo yes || echo no; } + +# One rule per documented drop class, and one narrow rule that must carry over. +assert_eq "blanket Bash(*) is a drop class" "yes" "$(matches 'Bash(*)')" +assert_eq "wildcarded interpreter is a drop class" "yes" "$(matches 'Bash(python*)')" +assert_eq "package-manager runner is a drop class" "yes" "$(matches 'Bash(npx *)')" +assert_eq "script-glob interpreter is a drop class" "yes" "$(matches 'Bash(*.py:*)')" +assert_eq "narrow rule carries over" "no" "$(matches 'Bash(npm test)')" +assert_eq "bare-name command rule carries over" "no" "$(matches 'Bash(node-gyp:*)')" + +# The token grammar keeps a tool name inside another rule's payload from +# surfacing as its own token — the property whole-tool detection depends on. +tokens=$(printf '%s\n' 'Bash(echo Agent)' | grep -oE "$CCPERM_TOOL_TOKEN_ERE") +assert_eq "payload text is not a separate top-level token" "Bash(echo Agent)" "$tokens" + +if [[ "$FAILED" -eq 0 ]]; then + printf '\nAll %d checks passed.\n' "$CASE_NUM" + exit 0 +fi +printf '\n%d/%d checks failed.\n' "$FAILED" "$CASE_NUM" >&2 +exit 1 diff --git a/plugins/claude-config/skills/audit-permission-grants/SKILL.md b/plugins/claude-config/skills/audit-permission-grants/SKILL.md index 119b32cab9..e34ed26738 100644 --- a/plugins/claude-config/skills/audit-permission-grants/SKILL.md +++ b/plugins/claude-config/skills/audit-permission-grants/SKILL.md @@ -1,5 +1,5 @@ --- -description: "Audit Claude Code permission GRANTS for portability and auto-mode durability — scans skill/command/agent frontmatter allowed-tools and settings.json/settings.local.json permissions.allow for interpreter-wildcard rules dropped in auto mode, hardcoded machine/user paths, and inert plugin self-grants. Use when: 'check permission rules', 'why was my allowed-tools grant ignored', 'audit allow rules', 'is this permission portable', after authoring a code-execution grant, or when a guarded helper is denied despite an allow rule. Report-only." +description: "Audit Claude Code permission GRANTS for portability and auto-mode durability — scans skill/command/agent frontmatter allowed-tools and settings.json/settings.local.json/user-global permissions.allow for interpreter-wildcard rules dropped in auto mode, hardcoded machine/user paths, and inert plugin self-grants. Use when: 'check permission rules', 'why was my allowed-tools grant ignored', 'audit allow rules', 'is this permission portable', after authoring a code-execution grant, or when a guarded helper is denied despite an allow rule. Report-only." argument-hint: "[scope] — scope: frontmatter|settings|plugins|all (default: all)" user-invocable: true disable-model-invocation: false @@ -35,7 +35,7 @@ owns the instruction layer (CLAUDE.md / rules / auto-memory). Parse `$ARGUMENTS` for an optional scope filter: - `frontmatter` — skill/command/agent `allowed-tools` only -- `settings` — `.claude/settings.json` + `.claude/settings.local.json` `permissions.allow` only +- `settings` — project, local, and user-global `permissions.allow` only - `plugins` — plugin `settings.json` self-grant (P3) only - `all` — everything (default) @@ -52,10 +52,15 @@ bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-grants/scripts/permission-ru ``` It scans frontmatter `allowed-tools` and settings `permissions.allow` across the consuming repo and -prints one finding per fragile grant (` [] : `). `--count` prints the -count. It requires `jq`; a missing `jq` exits 2 (report the environment gap rather than a clean bill). +the user-global settings file (`${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json`), and prints one +finding per fragile grant (` [] : `). `--count` prints the count. It +requires `jq`; a missing `jq` exits 2 (report the environment gap rather than a clean bill). `settings.local.json` is parsed for its `permissions.allow` array only — never echoed wholesale. +A user-global finding is reported the same as any other, but its remediation is the operator's: a +skill cannot write that file. Expect this scope to carry the most findings on a long-lived machine — +"Always allow" writes there, and nothing prunes it. + If a scope filter was given, run the full detector and present only the matching checks (P1/P2 map to `frontmatter`/`settings` sources; P3 to `plugins`). diff --git a/plugins/claude-config/skills/audit-permission-grants/reference/criteria.md b/plugins/claude-config/skills/audit-permission-grants/reference/criteria.md index cbc2ed560b..db2ec99d63 100644 --- a/plugins/claude-config/skills/audit-permission-grants/reference/criteria.md +++ b/plugins/claude-config/skills/audit-permission-grants/reference/criteria.md @@ -14,8 +14,13 @@ never depends on fetching the convention. The deterministic spine is `bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-grants/scripts/permission-rule-check.sh"` — it scans skill/command/agent frontmatter `allowed-tools` and the `permissions.allow` arrays of -`.claude/settings.json` and `.claude/settings.local.json`, plus any plugin `settings.json`, and emits -one finding per fragile grant. Frontmatter files under a `vendor/` path segment are skipped: they are +`.claude/settings.json`, `.claude/settings.local.json`, and the user-global settings file, plus any +plugin `settings.json`, and emits one finding per fragile grant. The user-global file resolves as +`${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json` — that scope is where Claude Code's own "Always +allow" path writes, so it accumulates exactly the broad rules auto mode drops, and a project-only +scan could not see any of them. A user-global finding names the resolved absolute path, because +reporting `~/.claude/settings.json` would name the wrong file whenever `CLAUDE_CONFIG_DIR` has moved +the config root. Frontmatter files under a `vendor/` path segment are skipped: they are vendored upstream references, not loadable skills/agents/commands, so their `allowed-tools` never take effect and a finding on them would be a false positive. It is advisory (always exits 0); `--count` prints the finding count. @@ -94,7 +99,7 @@ rule. When a request is about baseline security patterns, deprecated syntax, or ## Permission Hygiene Report — {date} ### Summary -- Grants scanned: frontmatter allowed-tools + settings.json/settings.local.json permissions.allow +- Grants scanned: frontmatter allowed-tools + project, local, and user-global permissions.allow - error: X findings (P2) - warning: X findings (P1, P3) diff --git a/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh b/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh index 2dc36dd006..73cfec7e72 100755 --- a/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh +++ b/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh @@ -24,8 +24,9 @@ # Advisory: prints findings, ALWAYS exits 0 (findings never fail the run). # Requires jq for the settings-file half; exits 2 when jq is absent. # -# Root resolution: $PERMISSION_HYGIENE_FIXTURE_DIR, else the cwd's git +# Project-root resolution: $PERMISSION_HYGIENE_FIXTURE_DIR, else the cwd's git # toplevel, else $CLAUDE_PROJECT_DIR, else $PWD. Never the plugin's own dir. +# User-scope resolution: $CLAUDE_CONFIG_DIR, else $HOME/.claude. # # Usage: # permission-rule-check.sh # human-readable findings, one per line @@ -45,7 +46,8 @@ Usage: permission-rule-check.sh [--count|--help] --help this message Scans skill/command/agent frontmatter `allowed-tools` and the `permissions.allow` -arrays of .claude/settings.json and .claude/settings.local.json for P1 (auto-mode +arrays of .claude/settings.json, .claude/settings.local.json, and the user-global +settings file (${CLAUDE_CONFIG_DIR:-~/.claude}/settings.json) for P1 (auto-mode -dropped interpreter/blanket rules), P2 (hardcoded machine paths), and plugin settings.json for P3 (unsupported self-granted `permissions`). Advisory — exit 0. Requires jq (exit 2 when absent). @@ -77,36 +79,15 @@ fi # --- Detection patterns ------------------------------------------------------- # -# P1 — one ERE, case-sensitive on the tool name. Each alternative requires a -# wildcard so an exact narrow rule (Bash(npm test)) never matches: -# 1. blanket Bash(*) / PowerShell(*) -# 2. an interpreter at the command position followed (eventually) by a * — -# the interpreter may carry a path prefix (Bash(.venv/bin/python *), -# Bash(/usr/bin/python3 *)): a wildcarded interpreter-led grant is the -# same arbitrary-code shape regardless of how the interpreter is addressed. -# What follows the name must be a * or a real separator (space, quote, :) -# so a hyphenated bare PATH command that merely starts with an interpreter -# or runner name (Bash(node-gyp:*), Bash(npm-check-updates:*)) — the very -# shape the convention recommends — is not flagged -# 3. a package-manager run/exec command followed by a * — both the run/exec -# subcommand forms (npx, pnpm dlx, uv run, …) and a bare package manager -# wildcard (Bash(npm:*), Bash(npm *)), which grants arbitrary execution -# via npm exec / lifecycle scripts. A bare package-manager name subsumes -# its own run wildcard (npm matches `npm run *`), so `npm run` etc. are not -# listed separately. A fixed subcommand (Bash(npm test), Bash(npm run -# build)) carries no * and is not matched. -# 4. a leading-glob command that resolves to a script (Bash(*.py:*)) -# python accepts version suffixes (python3, python3.11, python2.7): pinned -# minor-version binaries are the same interpreter-led grant shape. -_interp='python[0-9.]*|node|deno|bun|ruby|perl|php|bash|sh|zsh|pwsh|osascript|Rscript' -_runner='npx|bunx|uvx|pnpm dlx|yarn dlx|pipx run|uv run|npm|pnpm|yarn' -_script='py|sh|rb|js|ts|mjs|cjs|pl|php' -# Each alternative captures the whole Tool(...) spec (trailing [^)]*\) ) so a -# finding reports the full offending rule, not a substring truncated at the *. -P1_ERE="(Bash|PowerShell)\\(\\*\\)" -P1_ERE="${P1_ERE}|(Bash|PowerShell)\\([\"' ]*([^)\"' ]*[/\\\\])?(${_interp})([\"' :][^)]*)?\\*[^)]*\\)" -P1_ERE="${P1_ERE}|(Bash|PowerShell)\\([\"' ]*(${_runner})([\"' :][^)]*)?\\*[^)]*\\)" -P1_ERE="${P1_ERE}|(Bash|PowerShell)\\([\"' ]*\\*[^)]*\\.(${_script})[^)]*\\)" +# P1's auto-mode drop vocabulary is shared with audit-permission-state's entry +# diff, so it lives in a define-only library rather than here. Resolve the plugin +# root the way every other component in this marketplace does: Claude Code sets +# CLAUDE_PLUGIN_ROOT in plugin form, and the BASH_SOURCE fallback keeps a direct +# invocation (the test harness, a developer running the script) working. +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}" +# shellcheck source=../../../lib/permission-patterns.sh +source "$PLUGIN_ROOT/lib/permission-patterns.sh" +P1_ERE="$CCPERM_P1_ERE" # P2 — machine home-path shapes, ASSEMBLED FROM FRAGMENTS so no contiguous # home-path literal appears in this file's source bytes and trips the repo's @@ -142,14 +123,9 @@ scan_rule() { top_level_tokens() { # top_level_tokens — split rule text into top-level `Tool` / - # `Tool(...)` tokens, one per line. The greedy `(\(...\))?` consumes a tool's - # whole parenthesized payload as one token, so a tool name inside another - # rule's payload (e.g. Bash(echo Agent), Bash(grep PowerShell *)) never - # surfaces as its own token. The payload accepts one level of nested - # parentheses (Bash(echo $(date) Agent), Bash(node -e "log()" PowerShell)) - # so an inner `)` does not end the token early; ERE cannot balance - # arbitrary depth, and rule payloads realistically nest at most once. - printf '%s\n' "$1" | grep -oE '[A-Za-z_][A-Za-z0-9_]*(\(([^()]|\([^()]*\))*\))?' 2>/dev/null + # `Tool(...)` tokens, one per line. The token grammar is shared vocabulary; + # this wrapper is the driver's own I/O around it. + printf '%s\n' "$1" | grep -oE "$CCPERM_TOOL_TOKEN_ERE" 2>/dev/null } scan_bare_tool() { @@ -244,6 +220,28 @@ scan_settings_allow() { scan_settings_allow "$ROOT/.claude/settings.json" ".claude/settings.json permissions.allow" scan_settings_allow "$ROOT/.claude/settings.local.json" ".claude/settings.local.json permissions.allow" +# User-global scope. A project-only scan cannot see it, yet it is where Claude +# Code's own "Always allow" path writes, so it is the scope most likely to +# accumulate the broad rules auto mode drops. CLAUDE_CONFIG_DIR relocates the +# whole ~/.claude tree when set (official .claude-directory doc), so it wins over +# $HOME; with neither resolvable there is no user scope to scan. The label is the +# resolved absolute path — reporting "~/.claude/settings.json" would name the +# wrong file whenever CLAUDE_CONFIG_DIR has moved the tree. +# +# This resolution IS the fixture seam: a test points $HOME at a fixture home and +# unsets CLAUDE_CONFIG_DIR, the same seam claude-memory's resolver already uses. +# No test may read the operator's real ~/.claude. +if [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then + USER_CONFIG_ROOT="$CLAUDE_CONFIG_DIR" +elif [[ -n "${HOME:-}" ]]; then + USER_CONFIG_ROOT="$HOME/.claude" +else + USER_CONFIG_ROOT="" +fi +if [[ -n "$USER_CONFIG_ROOT" ]]; then + scan_settings_allow "$USER_CONFIG_ROOT/settings.json" "$USER_CONFIG_ROOT/settings.json permissions.allow" +fi + # --- Plugin self-grant scan (P3) --------------------------------------------- # A settings.json sitting at a plugin root (sibling .claude-plugin/plugin.json) # may only carry `agent` / `subagentStatusLine`; a `permissions` block is inert. diff --git a/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh b/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh index eb878ecbe2..d00e822424 100755 --- a/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh +++ b/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh @@ -48,7 +48,15 @@ if ! command -v jq >/dev/null 2>&1; then exit 0 fi -run() { PERMISSION_HYGIENE_FIXTURE_DIR="$1" bash "$SCRIPT" "${2:-}"; } +# Every run gets an isolated, EMPTY user home with CLAUDE_CONFIG_DIR unset. The +# user-global scan resolves ${CLAUDE_CONFIG_DIR:-$HOME/.claude}, so an inherited +# environment would read the operator's real ~/.claude — which no test may do — +# and its rules would pollute every case's finding count. +ISOLATED_HOME="$TEST_TMPDIR/empty-home" +mkdir -p "$ISOLATED_HOME" +run() { env -u CLAUDE_CONFIG_DIR HOME="$ISOLATED_HOME" PERMISSION_HYGIENE_FIXTURE_DIR="$1" bash "$SCRIPT" "${2:-}"; } +run_with_home() { env -u CLAUDE_CONFIG_DIR HOME="$2" PERMISSION_HYGIENE_FIXTURE_DIR="$1" bash "$SCRIPT" "${3:-}"; } +run_with_config_dir() { env CLAUDE_CONFIG_DIR="$2" HOME="$3" PERMISSION_HYGIENE_FIXTURE_DIR="$1" bash "$SCRIPT" "${4:-}"; } # Runtime-assembled machine paths (no contiguous path literal in source). SL='/' @@ -242,6 +250,36 @@ OUT=$(run "$D8B") assert_contains "flags P1 grant in settings.local.json" "$OUT" "Bash(python*)" assert_contains "finding names the local settings file" "$OUT" "settings.local.json" +# --- Case 8c: user-global settings scanned (scope widening) ------------------ +# A user-global interpreter-wildcard rule was invisible to a project-only scan, +# yet user scope is where Claude Code's own "Always allow" path writes. +D8C="$TEST_TMPDIR/user-global-project" +mkdir -p "$D8C/.claude" +jq -n '{permissions:{allow:["Bash(git status)"]}}' >"$D8C/.claude/settings.json" +FAKE_HOME="$TEST_TMPDIR/fake-home" +mkdir -p "$FAKE_HOME/.claude" +jq -n '{permissions:{allow:["Bash(python*)"]}}' >"$FAKE_HOME/.claude/settings.json" +OUT=$(run_with_home "$D8C" "$FAKE_HOME") +assert_contains "flags P1 grant in user-global settings" "$OUT" "Bash(python*)" +assert_contains "user-global finding names the resolved file" "$OUT" "$FAKE_HOME/.claude/settings.json" +assert_eq "user-global grant produces exactly one finding" "1" "$(run_with_home "$D8C" "$FAKE_HOME" --count)" + +# The same project against an EMPTY home must report nothing — proving the +# fixture home, not an inherited real one, produced the finding above. +assert_eq "empty user home contributes no findings" "0" "$(run "$D8C" --count)" + +# --- Case 8d: CLAUDE_CONFIG_DIR relocates the user scope --------------------- +# Per the official .claude-directory doc it moves the whole ~/.claude tree, so a +# reader keyed on $HOME alone would audit a file that is not in effect. +RELOCATED="$TEST_TMPDIR/relocated-config" +mkdir -p "$RELOCATED" +jq -n '{permissions:{allow:["Bash(npx *)"]}}' >"$RELOCATED/settings.json" +OUT=$(run_with_config_dir "$D8C" "$RELOCATED" "$FAKE_HOME") +assert_contains "reads the relocated config root" "$OUT" "Bash(npx *)" +assert_not_contains "ignores \$HOME once CLAUDE_CONFIG_DIR is set" "$OUT" "Bash(python*)" +assert_eq "relocated config root produces exactly one finding" "1" \ + "$(run_with_config_dir "$D8C" "$RELOCATED" "$FAKE_HOME" --count)" + # --- Case 9: missing jq exits 2 --------------------------------------------- real_bash=$(command -v bash) empty_path_dir="$TEST_TMPDIR/empty-path" diff --git a/plugins/claude-config/skills/audit/SKILL.md b/plugins/claude-config/skills/audit/SKILL.md index 20c1bff850..58f544608a 100644 --- a/plugins/claude-config/skills/audit/SKILL.md +++ b/plugins/claude-config/skills/audit/SKILL.md @@ -54,7 +54,9 @@ Parse `$ARGUMENTS` for: | `.claude/settings.local.json` | `jq` via Bash only | Commonly deny-listed for the Read tool because it holds tokens. Parse structure/key counts only. **Never echo secret values** | | `.mcp.json` | Read tool or `jq` | Project-level MCP server definitions | | `~/.claude/settings.json` | Read tool | User-level defaults (optional — check if exists) | +| start-directory `.claude/settings.local.json` | `check-structure.sh` (structure only) | Only when the session's start directory is not the repository root AND a copy is there. A pre-v2.1.211 Claude Code wrote the file to the start directory and the current one still reads what it left; the repository-root copy wins on a shared key, but **permission rules from both files stay in effect** | | `managed-settings.json` + `managed-settings.d/` | `check-structure.sh` (structure only) | Machine-scope managed policy, the highest-precedence layer. OS-specific path resolved by the script (macOS `/Library/Application Support/ClaudeCode/`, Linux/WSL `/etc/claude-code/`, Windows `%ProgramFiles%\ClaudeCode\`). Findings on it are report-only routing — managed policy is the administrator's, never edited by `--fix` | +| managed policy outside the filesystem | not read | The Windows `HKLM`/`HKCU\SOFTWARE\Policies\ClaudeCode` policy keys and the macOS `com.anthropic.claudecode` managed-preferences domain. `check-structure.sh` names them so an absent `managed-settings.json` is never read as "no managed policy deployed", but it does not read them | ### Reading settings.local.json safely diff --git a/plugins/claude-config/skills/audit/scripts/check-structure.sh b/plugins/claude-config/skills/audit/scripts/check-structure.sh index c73844309b..91554d5c8e 100755 --- a/plugins/claude-config/skills/audit/scripts/check-structure.sh +++ b/plugins/claude-config/skills/audit/scripts/check-structure.sh @@ -48,29 +48,32 @@ SETTINGS="$PROJECT_ROOT/.claude/settings.json" LOCAL="$PROJECT_ROOT/.claude/settings.local.json" MCP="$PROJECT_ROOT/.mcp.json" -# Managed (machine-scope) policy settings — highest-precedence layer. Paths per -# the official settings doc (verified 2026-08-08); the legacy Windows -# C:\ProgramData location is unsupported since v2.1.75 and deliberately not -# probed. Windows resolution goes through $PROGRAMFILES so a relocated -# Program Files directory still resolves. SETTINGS_AUDIT_MANAGED_PATH is the -# test seam — the real locations are absolute system paths a fixture dir -# cannot reach. -if [[ -n "${SETTINGS_AUDIT_MANAGED_PATH:-}" ]]; then - MANAGED="$SETTINGS_AUDIT_MANAGED_PATH" +# A pre-v2.1.211 Claude Code wrote settings.local.json into the directory the +# session started in, and the current one "still reads a .claude/settings.local +# .json that an earlier version left there… permission rules from both files stay +# in effect". So a leftover copy is live configuration that a repository-root-only +# scan cannot see. Default the start directory to the project root (and to the +# fixture root under test) so the row appears only where the two genuinely differ. +if [[ -n "${SETTINGS_AUDIT_STARTDIR_FIXTURE_DIR:-}" ]]; then + START_DIR="$SETTINGS_AUDIT_STARTDIR_FIXTURE_DIR" +elif [[ -n "${SETTINGS_AUDIT_STRUCTURE_FIXTURE_DIR:-}" ]]; then + START_DIR="$PROJECT_ROOT" else - case "$OSTYPE" in - darwin*) - MANAGED="/Library/Application Support/ClaudeCode/managed-settings.json" - ;; - msys* | cygwin*) - MANAGED="${PROGRAMFILES:-C:\\Program Files}\\ClaudeCode\\managed-settings.json" - ;; - *) - MANAGED="/etc/claude-code/managed-settings.json" - ;; - esac + START_DIR="$PWD" fi -MANAGED_DROPIN="${MANAGED%managed-settings.json}managed-settings.d" +STARTDIR_LOCAL="$START_DIR/.claude/settings.local.json" + +# Managed (machine-scope) policy settings — highest-precedence layer. The per-OS +# locations are shared vocabulary (lib/managed-scope.sh), not this script's to +# restate. Claude Code sets CLAUDE_PLUGIN_ROOT in plugin form; the BASH_SOURCE +# fallback keeps a direct invocation working. SETTINGS_AUDIT_MANAGED_PATH stays +# this script's own test seam — the real locations are absolute system paths a +# fixture dir cannot reach. +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}" +# shellcheck source=../../../lib/managed-scope.sh +source "$PLUGIN_ROOT/lib/managed-scope.sh" +MANAGED="$(mscope::base_file "${SETTINGS_AUDIT_MANAGED_PATH:-}")" +MANAGED_DROPIN="$(mscope::dropin_dir "${SETTINGS_AUDIT_MANAGED_PATH:-}")" emit_file_facts() { local label="$1" path="$2" kind="$3" @@ -162,6 +165,15 @@ emit_file_facts ".claude/settings.json" "$SETTINGS" settings || invalid=1 printf '\n' emit_file_facts ".claude/settings.local.json" "$LOCAL" local || invalid=1 printf '\n' +# Only when the start directory is genuinely a different directory AND holds a +# leftover copy: reporting an absent row for every session started at the repo +# root would be noise, and reporting the same file twice would be a lie about +# how many permission-rule sources are live. +if [[ "$STARTDIR_LOCAL" != "$LOCAL" && -f "$STARTDIR_LOCAL" ]]; then + emit_file_facts "$STARTDIR_LOCAL (start-directory copy, pre-v2.1.211)" "$STARTDIR_LOCAL" local || invalid=1 + printf 'Note: a start-directory settings.local.json left by a pre-v2.1.211 Claude Code. The repository-root copy wins on a shared key, but permission rules from BOTH files stay in effect.\n' + printf '\n' +fi emit_file_facts ".mcp.json" "$MCP" mcp || invalid=1 printf '\n' emit_file_facts "managed-settings.json (machine scope)" "$MANAGED" managed || invalid=1 @@ -174,5 +186,13 @@ if [[ -d "$MANAGED_DROPIN" ]]; then else printf 'Managed drop-in dir: absent\n' fi +# Managed policy also lives outside the filesystem on some platforms. This check +# reads files only, so it names what it did not read rather than letting an +# absent JSON file read as "no managed policy deployed". +while IFS= read -r key; do + [[ -n "$key" ]] && printf 'Managed registry key (not read by this check): %s\n' "$key" +done < <(mscope::registry_keys) +plist_domain="$(mscope::plist_domain)" +[[ -n "$plist_domain" ]] && printf 'Managed preferences domain (not read by this check): %s\n' "$plist_domain" [[ "$invalid" -eq 0 ]] diff --git a/plugins/claude-config/skills/audit/scripts/check-structure.test.sh b/plugins/claude-config/skills/audit/scripts/check-structure.test.sh index 0b020c944f..2f42fad7ce 100755 --- a/plugins/claude-config/skills/audit/scripts/check-structure.test.sh +++ b/plugins/claude-config/skills/audit/scripts/check-structure.test.sh @@ -172,6 +172,50 @@ assert_exit "case 8: exit 0" 0 "$rc" assert_contains "case 8: managed absence reported" "$out" "managed-settings.json (machine scope)" assert_contains "case 8: drop-in absence reported" "$out" "Managed drop-in dir: absent" +# --- Case 9: start-directory settings.local.json left by a pre-v2.1.211 CC ------ +# Its permission rules stay in effect alongside the repository-root copy, so a +# repository-root-only scan misses live configuration. +fixture_dir="$TEST_TMPDIR/startdir-present" +startdir="$TEST_TMPDIR/startdir-present-cwd" +mkdir -p "$fixture_dir/.claude" "$startdir/.claude" +printf '%s\n' '{}' >"$fixture_dir/.claude/settings.json" +printf '%s\n' '{"permissions":{"allow":["Bash(git status)","Bash(ls)"]}}' >"$startdir/.claude/settings.local.json" +rc=0 +out=$( + SETTINGS_AUDIT_STRUCTURE_FIXTURE_DIR="$fixture_dir" \ + SETTINGS_AUDIT_STARTDIR_FIXTURE_DIR="$startdir" \ + bash "$SCRIPT" 2>/dev/null +) || rc=$? +assert_exit "case 9: exit 0" 0 "$rc" +assert_contains "case 9: start-directory copy reported" "$out" "start-directory copy, pre-v2.1.211" +assert_contains "case 9: names the leftover file" "$out" "$startdir/.claude/settings.local.json" +assert_contains "case 9: its allow rules counted" "$out" "Allow count: 2" +assert_contains "case 9: both-files note emitted" "$out" "permission rules from BOTH files stay in effect" + +# --- Case 10: no start-directory copy — no row, so the check cannot pass by +# always emitting one ----------------------------------------------------------- +fixture_dir="$TEST_TMPDIR/startdir-absent" +startdir="$TEST_TMPDIR/startdir-absent-cwd" +mkdir -p "$fixture_dir/.claude" "$startdir" +printf '%s\n' '{}' >"$fixture_dir/.claude/settings.json" +rc=0 +out=$( + SETTINGS_AUDIT_STRUCTURE_FIXTURE_DIR="$fixture_dir" \ + SETTINGS_AUDIT_STARTDIR_FIXTURE_DIR="$startdir" \ + bash "$SCRIPT" 2>/dev/null +) || rc=$? +assert_exit "case 10: exit 0" 0 "$rc" +assert_not_contains "case 10: no start-directory row" "$out" "start-directory copy" + +# --- Case 11: start directory IS the project root — never reported twice -------- +rc=0 +out=$( + SETTINGS_AUDIT_STRUCTURE_FIXTURE_DIR="$fixture_dir" \ + bash "$SCRIPT" 2>/dev/null +) || rc=$? +assert_exit "case 11: exit 0" 0 "$rc" +assert_not_contains "case 11: same directory is not a second source" "$out" "start-directory copy" + # --- Case 3: missing jq exits 2 ------------------------------------------------- # Run the script under an EMPTY PATH so its `command -v jq` resolves nothing. The # script exits at the jq gate before invoking any external tool, so an empty PATH diff --git a/plugins/claude-memory/.claude-plugin/plugin.json b/plugins/claude-memory/.claude-plugin/plugin.json index 47faed0ba2..47185cb6e5 100644 --- a/plugins/claude-memory/.claude-plugin/plugin.json +++ b/plugins/claude-memory/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-memory", - "version": "0.7.0", + "version": "0.7.1", "description": "Keeps a repo's Claude Code memory layer healthy and under your control, against criteria derived from official Claude Code documentation. The audit skill checks the instruction/memory layer (CLAUDE.md, CLAUDE.local.md, .claude/rules/, auto-memory) with a deterministic script-backed spine plus judgment-tier checks. The stateless skill inspects, disables, and (confirm-gated) purges Claude-written auto memory across all settings scopes.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-memory/CHANGELOG.md b/plugins/claude-memory/CHANGELOG.md index 84c9035101..cb2b69f359 100644 --- a/plugins/claude-memory/CHANGELOG.md +++ b/plugins/claude-memory/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to the `claude-memory` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.1] + +### Changed + +- **`scope-report.sh` now reports every managed-policy surface, not one JSON file.** Its + hand-kept per-OS location list had fallen behind the settings doc: it never named the + `managed-settings.d/` drop-in directory, and it folded the Windows registry policy keys into a + parenthetical inside the file path. The locations now come from `lib/managed-scope.sh`, a + shared library that `claude-config` carries a byte-identical copy of, so a location change + lands once instead of per plugin. The report gains a `managed.d` row and one `not read` row per + non-file surface (the `HKLM`/`HKCU` policy keys on Windows, the managed-preferences domain on + macOS) — a presence report must not let an absent JSON file read as "no managed policy + deployed". The Windows base path also now resolves through `%PROGRAMFILES%` rather than assuming + the default location. + ## [0.7.0] ### Removed diff --git a/plugins/claude-memory/lib/managed-scope.sh b/plugins/claude-memory/lib/managed-scope.sh new file mode 100644 index 0000000000..c0fc91aac0 --- /dev/null +++ b/plugins/claude-memory/lib/managed-scope.sh @@ -0,0 +1,91 @@ +# shellcheck shell=bash +# Managed (machine-scope) policy surfaces — per-OS enumeration, library only. +# +# No top-level execution, no env-driven side effects, no exit calls. Callers own +# presentation, redaction posture, test seams, and exit-code mapping: one caller +# reports managed policy as counts, another as presence only, and a third reads +# it to compute an effective merge. Only the LOCATIONS are shared. +# +# WHY THIS EXISTS: managed policy is not one file. Per the official settings doc +# it is, per OS, a JSON file plus a `managed-settings.d/` drop-in directory, plus +# a Windows registry policy key or a macOS managed-preferences domain. Three +# components in this marketplace need that enumeration, and a third hand-written +# copy would drift the moment upstream adds or moves a surface — as it already +# had: the copies disagreed about whether the drop-in directory existed at all. +# +# NOT COVERED, deliberately: server-managed settings, which the doc describes as +# "delivered remotely at sign-in from Anthropic's servers via the claude.ai admin +# console or from a self-hosted Claude apps gateway". They have no local path to +# enumerate, so a local reader cannot see them and must not imply it has. +# +# The legacy Windows location C:\ProgramData\ClaudeCode\managed-settings.json is +# unsupported since v2.1.75 and is deliberately never probed — reporting it would +# report policy that is not in force. +# +# Verified against https://code.claude.com/docs/en/settings on 2026-08-10. +# Recheck trigger: that page's managed-settings location list gains, drops, or +# moves a surface. Basis: the paths are documented, not discoverable — a machine +# with no policy deployed looks identical to a machine whose policy this file +# fails to find. + +# mscope::base_file [override] — absolute path to the managed-settings.json this +# OS reads. A non-empty is returned verbatim, so a caller's own test +# seam stays the caller's: the real locations are absolute system paths that a +# fixture directory cannot reach. +# +# Windows resolves through $PROGRAMFILES so a relocated Program Files directory +# still resolves; the doc spells the default as C:\Program Files\ClaudeCode. +mscope::base_file() { + local override="${1:-}" + if [[ -n "$override" ]]; then + printf '%s\n' "$override" + return 0 + fi + case "$OSTYPE" in + darwin*) printf '%s\n' "/Library/Application Support/ClaudeCode/managed-settings.json" ;; + msys* | cygwin*) printf '%s\n' "${PROGRAMFILES:-C:\\Program Files}\\ClaudeCode\\managed-settings.json" ;; + *) printf '%s\n' "/etc/claude-code/managed-settings.json" ;; + esac +} + +# mscope::dropin_dir [override] — absolute path to the managed-settings.d +# directory that sits beside the base file. Derived from the base file so an +# override relocates both together, which is what a fixture needs. +# +# Merge semantics, for callers that report them: "managed-settings.json is merged +# first as the base, then all *.json files in the drop-in directory are sorted +# alphabetically and merged on top. Later files override earlier ones for scalar +# values, arrays are concatenated and de-duplicated, and objects are deep-merged. +# Hidden files starting with . are ignored." +mscope::dropin_dir() { + local base + base="$(mscope::base_file "${1:-}")" + printf '%s\n' "${base%managed-settings.json}managed-settings.d" +} + +# mscope::registry_keys — Windows policy keys, one per line, highest policy +# priority first; nothing at all on other platforms. Each key carries the policy +# JSON in a `Settings` value (REG_SZ or REG_EXPAND_SZ), so a reader wants that +# value, not the key's subkeys. HKCU is "lowest policy priority, only used when +# no admin-level source exists" — a reader that merges both would report policy +# that is not in force. +mscope::registry_keys() { + case "$OSTYPE" in + msys* | cygwin*) + # portability-ok: the `\S` here is the literal first character of SOFTWARE in + # a single-quoted Windows registry path, not a GNU regex escape. These lines + # only ever run on Windows, and `printf '%s'` does no escape interpretation. + printf '%s\n' 'HKLM\SOFTWARE\Policies\ClaudeCode' + printf '%s\n' 'HKCU\SOFTWARE\Policies\ClaudeCode' + ;; + *) ;; + esac +} + +# mscope::plist_domain — the macOS managed-preferences domain, empty elsewhere. +mscope::plist_domain() { + case "$OSTYPE" in + darwin*) printf '%s\n' "com.anthropic.claudecode" ;; + *) ;; + esac +} diff --git a/plugins/claude-memory/skills/stateless/scripts/scope-report.sh b/plugins/claude-memory/skills/stateless/scripts/scope-report.sh index 31566c708a..d246c625bf 100755 --- a/plugins/claude-memory/skills/stateless/scripts/scope-report.sh +++ b/plugins/claude-memory/skills/stateless/scripts/scope-report.sh @@ -48,15 +48,19 @@ resolver="$script_dir/../../audit/scripts/resolve-memory-dir.sh" config_root="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" exists() { [[ -f "$1" ]] && echo "PRESENT" || echo "absent"; } - -# Managed/policy settings location is OS-specific. Report the path for this OS so the -# workflow knows where to look; on Windows it may instead be in the registry. -case "$(uname -s 2>/dev/null || echo unknown)" in -Darwin) managed="/Library/Application Support/ClaudeCode/managed-settings.json" ;; -Linux) managed="/etc/claude-code/managed-settings.json" ;; -MINGW* | MSYS* | CYGWIN*) managed="C:/Program Files/ClaudeCode/managed-settings.json (or Windows registry: HKLM/HKCU\\SOFTWARE\\Policies\\ClaudeCode)" ;; -*) managed="(unknown OS — see settings doc for managed-settings.json location)" ;; -esac +dir_exists() { [[ -d "$1" ]] && echo "PRESENT" || echo "absent"; } + +# Managed/policy settings locations are OS-specific and are shared vocabulary +# (lib/managed-scope.sh) rather than this script's to restate — a hand-kept copy +# here had already fallen behind the drop-in directory the settings doc adds. +# This report stays presence-only: it names the non-file surfaces (registry, +# preferences domain) without reading them, so an absent JSON file is never +# mistaken for "no managed policy deployed". +plugin_root="${CLAUDE_PLUGIN_ROOT:-$(cd "$script_dir/../../.." && pwd)}" +# shellcheck source=../../../lib/managed-scope.sh +source "$plugin_root/lib/managed-scope.sh" +managed="$(mscope::base_file)" +managed_dropin="$(mscope::dropin_dir)" user_settings="$config_root/settings.json" @@ -67,8 +71,13 @@ project_settings="$base/.claude/settings.json" local_settings="$base/.claude/settings.local.json" echo "=== Settings scopes (precedence: managed > local > project > user) ===" -managed_file="${managed%% (*}" -printf '%-10s %-8s %s\n' "managed" "$(exists "$managed_file")" "$managed" +printf '%-10s %-8s %s\n' "managed" "$(exists "$managed")" "$managed" +printf '%-10s %-8s %s\n' "managed.d" "$(dir_exists "$managed_dropin")" "$managed_dropin" +while IFS= read -r policy_key; do + [[ -n "$policy_key" ]] && printf '%-10s %-8s %s\n' "managed" "not read" "$policy_key" +done < <(mscope::registry_keys) +plist_domain="$(mscope::plist_domain)" +[[ -n "$plist_domain" ]] && printf '%-10s %-8s %s\n' "managed" "not read" "$plist_domain (managed preferences domain)" printf '%-10s %-8s %s\n' "user" "$(exists "$user_settings")" "$user_settings" printf '%-10s %-8s %s\n' "project" "$(exists "$project_settings")" "$project_settings" printf '%-10s %-8s %s\n' "local" "$(exists "$local_settings")" "$local_settings" diff --git a/scripts/cross-plugin-source-registry.txt b/scripts/cross-plugin-source-registry.txt index e3170f70f5..f0f62da421 100644 --- a/scripts/cross-plugin-source-registry.txt +++ b/scripts/cross-plugin-source-registry.txt @@ -1,9 +1,13 @@ # Cross-plugin shared-source-file clusters that are expected to stay -# byte-identical across every plugin that carries them, and already have a -# dedicated drift check elsewhere. Read by check-cross-plugin-source-drift.sh -# --check: an identical cluster not listed here fails as "unregistered" (a -# new shared-copy pattern needing a decision); a listed cluster whose copies -# no longer match fails as "drifted". +# byte-identical across every plugin that carries them. Read by +# check-cross-plugin-source-drift.sh --check: an identical cluster not listed +# here fails as "unregistered" (a new shared-copy pattern needing a decision); +# a listed cluster whose copies no longer match fails as "drifted". +# +# Most clusters carry a dedicated sync/drift check of their own, named below. +# A cluster with no dedicated check is enforced by this script's own --check +# (already a required CI job) and names its canonical copy instead, so a drift +# failure says which direction to fix. # # One path-within-plugin per line, relative to each plugin's own root. @@ -15,3 +19,9 @@ reference/artifact-protocol.md # Dedicated check: scripts/sync-standards-contract.sh --check (CI: standards-contract-sync) reference/standards-contract.md + +# No dedicated check — enforced by this script's own --check. +# Canonical copy: plugins/claude-config/lib/managed-scope.sh (claude-config is +# where the managed scope is read and audited; claude-memory only reports the +# locations). Fix a drift by copying that file over the others. +lib/managed-scope.sh From 09f88c1f1188fb44fd10ea8944e75ed3c6601037 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:28:39 -0400 Subject: [PATCH 05/14] fix(claude-config): announce an unresolvable user scope instead of skipping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With neither CLAUDE_CONFIG_DIR nor HOME set there is no user-global settings file to resolve, and the detector scanned nothing without saying so — which would let "No fragile permission grants found." rest on a scope that was never opened. It now writes a named notice to stderr, with a regression test covering it. The plugin README carries the widened scope and the fact that a user-global finding's remediation is the operator's, and the plan records the two review findings this pass closed plus the P2 / standards-managed machine-path overlap it deliberately left alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/topics/permission-model/PLAN.md | 22 +++++++++++++++++-- plugins/claude-config/README.md | 7 ++++-- .../scripts/permission-rule-check.sh | 4 ++++ .../scripts/permission-rule-check.test.sh | 7 ++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index 0f64fb80e7..4d68c7b83b 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -300,7 +300,9 @@ The integration slice. Everything downstream reads what this produces. both stay in effect. - Emit one scope record per file: scope, absolute path, present/parsed, and the three rule arrays. - Declare `jq` as **required for correctness** with a hard stop at the entry point, matching - `permission-rule-check.sh:63-64` (`ERROR: jq required`, exit 2). Declare it in the plugin README. + `permission-rule-check.sh`'s own jq gate (`ERROR: jq required`, exit 2). Declare it in the plugin + README. (This plan forbids line-number citations into living files; the earlier `:63-64` here was + already stale.) - **Fixture seam is Phase 1 work, not an afterthought.** The sibling exposes only `PERMISSION_HYGIENE_FIXTURE_DIR`, which sets `ROOT` and therefore reaches project and local scopes @@ -583,7 +585,15 @@ Phase 8's sweep, which must include this skill. `managed-scope.test.sh` 16/16, `permission-patterns.test.sh` 12/12, `check-cross-plugin-source-drift.sh --check` clean and proven to FAIL on a deliberately perturbed copy, `shellcheck -x` clean, `check-shell-portability.sh` clean, `check-changelog-parity.sh --check` -and `--check-bump origin/main` clean. +and `--check-bump origin/main` clean, and `check-skill.sh audit-permission-grants` PASS against the +pre-change ref — the only gate covering the frontmatter edit, which the trigger-preservation check +confirms kept all four base-ref phrases at 552/1536 characters. + +No machine consumer gates on the detector's finding count: a repo-wide sweep for +`permission-rule-check` and `audit-permission-grants` outside the skill's own directory returns +documentation and lane routing only, no `--count` threshold in a hook, a CI job, or an `audit-pass` +verdict. The widening therefore adds findings a human reads; it cannot turn a previously-green +automated check red. Two deliberate divergences from the phase as written, both recorded rather than silent: @@ -787,6 +797,14 @@ Remaining genuinely open, carried into implementation: writes to consumer *settings*, and a transcript is not a settings file, but the boundary was never measured. Measure it in Phase 3 before the oracle ships, and state the result in the flag's cost notice — a feature that spawns a session must be honest about everything it leaves behind. +- **P2's machine-path vocabulary overlaps a standards-managed upstream body.** + `guardrails/lib/path-detection/machine-path-patterns.sh` is the org-shared materialization of the + per-OS machine-path regexes, kept upstream "so a pattern change lands once and reaches every scan + driver in lockstep". `permission-rule-check.sh` assembles its own `P2_ERE` independently. The two + serve different inputs (permission-rule text with its own `$`/`{`/`~` exemptions vs file content + with driver-side boundary prefixes), so this is not an obvious merge — and the upstream body is + standards-owned, so any convergence lands there, not here. Deliberately left alone by Phase 9; + recorded so it is not lost. - **The worktree resolution gap in the two existing detectors.** `permission-rule-check.sh` and `check-structure.sh` both anchor on `git rev-parse --show-toplevel`, which yields the **worktree** root, while the settings page says `.claude/settings.local.json` resolves through worktrees to the diff --git a/plugins/claude-config/README.md b/plugins/claude-config/README.md index dc46971cd5..1d84620ca4 100644 --- a/plugins/claude-config/README.md +++ b/plugins/claude-config/README.md @@ -59,8 +59,11 @@ Audits permission GRANTS (not file correctness — that is `audit`) for the fail make a grant silently do nothing: interpreter-wildcard / blanket rules that Claude Code drops on entering auto mode, hardcoded absolute machine/user paths (Bash rules match literally, no expansion), and inert plugin self-grants. A deterministic detector scans skill/command/agent frontmatter -`allowed-tools` and `settings.json` / `settings.local.json` `permissions.allow`, and recommends the -bare-command-on-PATH pattern. The principle and citations live in the marketplace +`allowed-tools` and the project, local, and user-global `permissions.allow` arrays, and recommends the +bare-command-on-PATH pattern. The user-global file +(`${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json`) is where Claude Code's own "Always allow" path +writes, so on a long-lived machine expect it to carry most of the findings — and their remediation is +the operator's, since no skill can write that file. The principle and citations live in the marketplace [permission-rule-hygiene convention](../../docs/conventions/permission-rule-hygiene/README.md). Report-only. diff --git a/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh b/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh index 73cfec7e72..e2ea5bbbcd 100755 --- a/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh +++ b/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh @@ -240,6 +240,10 @@ else fi if [[ -n "$USER_CONFIG_ROOT" ]]; then scan_settings_allow "$USER_CONFIG_ROOT/settings.json" "$USER_CONFIG_ROOT/settings.json permissions.allow" +else + # An unresolvable user scope is a skipped check, not a clean one. Silence here + # would let a report claiming "no fragile grants" rest on a scope never read. + echo "NOTE: user-global scope not scanned — neither CLAUDE_CONFIG_DIR nor HOME is set, so ~/.claude could not be resolved." >&2 fi # --- Plugin self-grant scan (P3) --------------------------------------------- diff --git a/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh b/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh index d00e822424..174c62a299 100755 --- a/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh +++ b/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh @@ -280,6 +280,13 @@ assert_not_contains "ignores \$HOME once CLAUDE_CONFIG_DIR is set" "$OUT" "Bash( assert_eq "relocated config root produces exactly one finding" "1" \ "$(run_with_config_dir "$D8C" "$RELOCATED" "$FAKE_HOME" --count)" +# --- Case 8e: an unresolvable user scope is announced, never silently skipped -- +# With neither CLAUDE_CONFIG_DIR nor HOME set there is no user scope to read. A +# silent skip would let "No fragile permission grants found." rest on a scope +# that was never opened. +err_out=$(env -u CLAUDE_CONFIG_DIR -u HOME PERMISSION_HYGIENE_FIXTURE_DIR="$D8C" bash "$SCRIPT" 2>&1 >/dev/null) +assert_contains "unresolvable user scope is announced" "$err_out" "user-global scope not scanned" + # --- Case 9: missing jq exits 2 --------------------------------------------- real_bash=$(command -v bash) empty_path_dir="$TEST_TMPDIR/empty-path" From 721fee180257f3f5079b523f994ae6d94bd28413 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:41:55 -0400 Subject: [PATCH 06/14] feat(claude-config): report which permission scopes exist and what each holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code exposes no way to see the permission rules actually in effect — no `claude permissions` subcommand, no machine-readable export — so the honest answer to "where is this rule coming from" has been to read five files in five places and hope you knew all five. This reader finds them: managed policy, user-global, project, local, and any pre-v2.1.211 copy left in the session's start directory, each scope's allow/ask/deny rules attributed to the surface they came from. The status vocabulary is the substance. `absent` means looked and found nothing; `skipped` means could not look. Every scope and every managed surface emits a record on every OS, including the ones that do not apply here, so a surface that was never attempted can never be read as one that is genuinely empty. Server-managed settings arrive remotely and have no local path at all, which the reader discloses rather than letting silence imply completeness. Managed policy is four surfaces per OS, not one file. The JSON file and its drop-in directory are the portable core, read everywhere and merged in the documented order; the Windows policy registry keys and the macOS managed-preferences domain are optional platform integrations that announce a skip and leave the core result intact. Exercising it under a stub PATH caught two defects that each produced a confident wrong answer. MSYS rewrites any argument containing backslashes as a POSIX path, so every registry query died with "Invalid syntax" and scored as no policy deployed. And with plugin-root resolution broken, the shared library failed to source while the run still exited 0, reporting every managed surface absent; that path now exits 2, in this reader and in the audit skill's structure check, which had the same fall-through. The registry surface is verified against the real registry via a scratch key outside the policy tree, created and deleted within the check, so no policy was ever deployed to the machine. The macOS domain and the Linux paths are an honest manual-verification gap, stated in the skill. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/CATALOG.md | 2 +- docs/SKILL-CHEAT-SHEET.md | 1 + docs/topics/permission-model/PLAN.md | 46 ++- .../claude-config/.claude-plugin/plugin.json | 2 +- plugins/claude-config/CHANGELOG.md | 9 + plugins/claude-config/README.md | 21 ++ .../scripts/permission-rule-check.sh | 9 +- .../skills/audit-permission-state/SKILL.md | 145 ++++++++ .../audit-permission-state/evals/evals.json | 52 +++ .../scripts/permission-state.sh | 322 ++++++++++++++++++ .../scripts/permission-state.test.sh | 211 ++++++++++++ .../skills/audit/scripts/check-structure.sh | 13 +- 12 files changed, 826 insertions(+), 7 deletions(-) create mode 100644 plugins/claude-config/skills/audit-permission-state/SKILL.md create mode 100644 plugins/claude-config/skills/audit-permission-state/evals/evals.json create mode 100644 plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh create mode 100644 plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh diff --git a/docs/CATALOG.md b/docs/CATALOG.md index 29019e946e..fc4dac8dc4 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -73,7 +73,7 @@ plugin manifests and kept in sync by CI — never hand-edit it; the category voc - [`desktop-notification`](../plugins/desktop-notification) — Alert you when Claude Code needs input — an audible terminal bell, an OSC 9 terminal notification, and an OS-native toast (macOS/Linux) on permission and idle prompts. - [`playbooks`](../plugins/playbooks) — Doctrine and knowledge playbooks as on-demand skills, plus a maintainer-facing update skill. boris — Boris Cherny's Claude Code workflow tips (howborisusesclaudecode.com); skill-authoring — Anthropic's internal skill-authoring playbook; fable-5 — Claude Fable 5's operating doctrine (self-authored, no upstream). The boris and skill-authoring packs vendor a verbatim upstream baseline; /playbooks:update drift-checks and syncs those baselines centrally (maintainers). -- [`claude-config`](../plugins/claude-config) — Seven configuration-health skills (plus setup) for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-instructions (locally-owned instruction surfaces vs current model capability — proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), audit-prompting-postures (the additive lane — posture guidance the prompting guide says a component's purpose needs but the component does not carry), audit-pass (one coordinated, ordered, resumable pass over a named target — three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate — delegating every check to the plugin that owns it), and unhobble (the empirical bare-baseline experiment: reversibly strip a repo's standing instructions, log real stumbles against the current model, re-add only what evidence earns). +- [`claude-config`](../plugins/claude-config) — Eight configuration-health skills (plus setup) for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-permission-state (which settings scopes exist and what rules each one holds — managed policy, user-global, project, local, and the pre-v2.1.211 start-directory copy), audit-instructions (locally-owned instruction surfaces vs current model capability — proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), audit-prompting-postures (the additive lane — posture guidance the prompting guide says a component's purpose needs but the component does not carry), audit-pass (one coordinated, ordered, resumable pass over a named target — three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate — delegating every check to the plugin that owns it), and unhobble (the empirical bare-baseline experiment: reversibly strip a repo's standing instructions, log real stumbles against the current model, re-add only what evidence earns). - [`claude-memory`](../plugins/claude-memory) — Keeps a repo's Claude Code memory layer healthy and under your control, against criteria derived from official Claude Code documentation. The audit skill checks the instruction/memory layer (CLAUDE.md, CLAUDE.local.md, .claude/rules/, auto-memory) with a deterministic script-backed spine plus judgment-tier checks. The stateless skill inspects, disables, and (confirm-gated) purges Claude-written auto memory across all settings scopes. - [`claude-ops`](../plugins/claude-ops) — Claude Code operations toolkit. Seven skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action — an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads. - [`rate-limit-guard`](../plugins/rate-limit-guard) — Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume. diff --git a/docs/SKILL-CHEAT-SHEET.md b/docs/SKILL-CHEAT-SHEET.md index eb53c92637..a3875e3276 100644 --- a/docs/SKILL-CHEAT-SHEET.md +++ b/docs/SKILL-CHEAT-SHEET.md @@ -139,6 +139,7 @@ owned by [docs/CATALOG-TAXONOMY.md](CATALOG-TAXONOMY.md). | [`/claude-config:audit-instructions`](../plugins/claude-config/skills/audit-instructions/SKILL.md) | `claude-config` | Find instructions current models no longer need across CLAUDE.md, rules, and skill bodies | | [`/claude-config:audit-pass`](../plugins/claude-config/skills/audit-pass/SKILL.md) | `claude-config` | Run one coordinated, resumable audit pass over a repo with a single human gate | | [`/claude-config:audit-permission-grants`](../plugins/claude-config/skills/audit-permission-grants/SKILL.md) | `claude-config` | Audit permission grants for portability and auto-mode durability | +| [`/claude-config:audit-permission-state`](../plugins/claude-config/skills/audit-permission-state/SKILL.md) | `claude-config` | Report which permission scopes exist and what rules each one holds | | [`/claude-config:audit-prompting-postures`](../plugins/claude-config/skills/audit-prompting-postures/SKILL.md) | `claude-config` | Find posture guidance the prompting guide says a component needs but does not carry | | [`/claude-config:unhobble`](../plugins/claude-config/skills/unhobble/SKILL.md) | `claude-config` | Strip instructions to a bare baseline, log real stumbles, re-add only what evidence earns | | [`/claude-memory:audit`](../plugins/claude-memory/skills/audit/SKILL.md) | `claude-memory` | Audit CLAUDE.md, rules, and auto-memory against the official-docs checklist | diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index 4d68c7b83b..9b80063a83 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -287,7 +287,51 @@ runtime**; it does not discharge the **implementer's** obligation. gate constant appearing anywhere in the two new skills also appears in that file (`comm -23` of the two sorted constant lists is empty). -### Phase 1: Walking skeleton — scope discovery across all five scopes [TODO] +### Phase 1: Walking skeleton — scope discovery across all five scopes [DONE] + +**Completed 2026-08-11.** `permission-state.test.sh` 38/38, covering every sanity check below: +all four file scopes exactly once, all four managed surfaces exactly once, drop-ins in the documented +alphabetical order with dotfiles ignored, `absent` distinguished from `invalid-json` and from +`skipped`, the start-directory copy never double-counted, jq-absent exit 2, and the optional-leg +degradation (stub `PATH` without `reg`: exit 0, announced skip, portable core still read). +`check-skill.sh audit-permission-state` PASS, `check-evals-quality.sh` PASS, +`validate-plugins.sh` clean, cheat sheet and catalog regenerated, listing budget 5892/8000 across +eight skills. `shellcheck -x` clean; both Phase 9 suites still pass after the fail-loud retrofit below. + +**The registry leg is verified against the real registry, not a fixture.** A scratch key +(`HKCU\SOFTWARE\ClaudeCodePluginTest`, deliberately **not** under `Policies`, so no policy was ever +deployed to the machine) was created, read, parsed, and deleted; `HKCU\SOFTWARE\Policies\ClaudeCode` +was confirmed still absent afterwards. The shipped test uses the key-list seam instead, because a test +that writes to a consumer's registry is not something this marketplace should ship. + +**Two wrong-answer defects the stub-`PATH` case caught before they shipped:** + +- **MSYS argument conversion silently broke every registry read.** Git Bash rewrites an argument + containing backslashes as though it were a POSIX path, so `reg query 'HKLM\SOFTWARE\...'` reaches + `reg.exe` mangled and exits non-zero with `ERROR: Invalid syntax` — which the reader scored as "no + managed policy deployed" on a machine that has one. Exactly the failure mode the Option A decision + was chosen to avoid, arriving by a different route. Fixed by scoping `MSYS2_ARG_CONV_EXCL` to those + calls; measured both ways. +- **A missing shared library reported a clean machine.** With `dirname` off `PATH`, plugin-root + resolution collapsed, the `source` failed, and every managed surface reported `absent` while the run + still exited 0. Now resolved with builtins only (`${BASH_SOURCE[0]%/*}`) and a hard `exit 2` when the + library is unreadable. The same fail-loud guard was retrofitted to `check-structure.sh`, which had + the identical fall-through from Phase 9. + +**Deviations from the phase as written, recorded rather than silent:** + +- **No `reference/criteria.md` yet.** Phase 1 ships an inventory, not a check, so a criteria file + today could only restate the record contract that `SKILL.md` already carries at run time — the + duplication this repository's no-duplication rule exists to prevent. It lands with the first + mechanical check (Phase 4), or with Phase 2's precedence basis, whichever comes first. +- **`evals/evals.json` shipped now rather than in Phase 8**, since the skill directory is created + here and a new skill without evals is a gap for however many phases it stays open. +- **`plugin.json`'s description and the generated catalog and cheat sheet were updated now**, not + deferred: both are generated artifacts with a required CI parity check, and the manifest's skill + count was factually wrong the moment the directory existed. Phase 8 still owns extending both for + `draft-auto-mode-rules`; the version bump remains the single one already taken. +- **The macOS `plist` surface reports presence, not contents.** The reader names the domain and says + so explicitly. Inventorying it needs a machine that can be verified, which this plan does not have. The integration slice. Everything downstream reads what this produces. diff --git a/plugins/claude-config/.claude-plugin/plugin.json b/plugins/claude-config/.claude-plugin/plugin.json index ce56ac2c38..8f2954d375 100644 --- a/plugins/claude-config/.claude-plugin/plugin.json +++ b/plugins/claude-config/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-config", "version": "0.30.0", - "description": "Seven configuration-health skills (plus setup) for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-instructions (locally-owned instruction surfaces vs current model capability — proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), audit-prompting-postures (the additive lane — posture guidance the prompting guide says a component's purpose needs but the component does not carry), audit-pass (one coordinated, ordered, resumable pass over a named target — three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate — delegating every check to the plugin that owns it), and unhobble (the empirical bare-baseline experiment: reversibly strip a repo's standing instructions, log real stumbles against the current model, re-add only what evidence earns).", + "description": "Eight configuration-health skills (plus setup) for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-permission-state (which settings scopes exist and what rules each one holds — managed policy, user-global, project, local, and the pre-v2.1.211 start-directory copy), audit-instructions (locally-owned instruction surfaces vs current model capability — proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), audit-prompting-postures (the additive lane — posture guidance the prompting guide says a component's purpose needs but the component does not carry), audit-pass (one coordinated, ordered, resumable pass over a named target — three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate — delegating every check to the plugin that owns it), and unhobble (the empirical bare-baseline experiment: reversibly strip a repo's standing instructions, log real stumbles against the current model, re-add only what evidence earns).", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index 0873797af5..99c946dcac 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -28,6 +28,15 @@ All notable changes to the `claude-config` plugin are documented here. Format fo ### Added +- **`audit-permission-state`** — a new skill reporting which permission rules are actually in effect + and where each comes from. Claude Code ships no `claude permissions` subcommand and no + machine-readable export, so the honest answer had been "read five files in five places and hope you + know all five". The reader discovers managed policy, user-global, project, local, and any + pre-v2.1.211 start-directory copy, and inventories each scope's `allow`/`ask`/`deny` rules with its + source named. Every scope and every managed surface emits a record on every OS, so a surface that + was never attempted can never be mistaken for one that is genuinely empty: `absent` means looked and + found nothing, `skipped` means could not look. Server-managed settings are disclosed as having no + local path rather than assumed absent. Report-only, and managed policy is read-only by construction. - **`lib/permission-patterns.sh`** — the auto-mode drop vocabulary (blanket, wildcarded-interpreter, package-manager-runner, and script-glob rule shapes, plus the top-level tool-token grammar) as a define-only library. It was inline in the P1 detector, which self-executes and cannot be sourced, diff --git a/plugins/claude-config/README.md b/plugins/claude-config/README.md index 1d84620ca4..236d93262c 100644 --- a/plugins/claude-config/README.md +++ b/plugins/claude-config/README.md @@ -9,6 +9,7 @@ different question about the same surface: | `/claude-config:audit` | Are the configuration FILES (`settings.json`, `settings.local.json`, `.mcp.json`, hooks, plugins, permissions) correct against upstream truth? | | `/claude-config:audit-automation-gaps` | Is the configured automation SET the right set — are there genuine gaps, judged against the enforcement hierarchy? | | `/claude-config:audit-permission-grants` | Are the permission GRANTS (`allowed-tools`, `permissions.allow`) portable and durable — do they survive auto mode, work across machines, and live where they can take effect? | +| `/claude-config:audit-permission-state` | Which permission rules are actually IN EFFECT, and where does each one come from — across managed policy, user-global, project, local, and the pre-v2.1.211 start-directory copy? | | `/claude-config:audit-instructions` | Are the INSTRUCTIONS you wrote (CLAUDE.md, rules, skill bodies, agents, hooks, output styles) still earning their context cost against current model capability, or is prior-model scar tissue holding the model back? | | `/claude-config:audit-pass` | Can all of that run as ONE ordered, resumable pass over a named target — every scope inventoried before any check, one reconciled findings artifact, one human gate — instead of several separate runs whose results nobody reconciles? | | `/claude-config:unhobble` | What does the CURRENT MODEL actually still need — measured, not reasoned: reversibly strip the project's standing instructions to a bare baseline, log real stumbles, and re-add only what the evidence earns back? | @@ -73,6 +74,26 @@ Report-only. /claude-config:audit-permission-grants settings # permissions.allow only ``` +### audit-permission-state + +Reports the permission state actually in effect. Claude Code exposes no `claude permissions` +subcommand and no machine-readable export, so "where is this rule coming from" has meant reading five +files in five places and hoping you knew all five. A deterministic reader discovers every settings +scope — managed policy, user-global, project, local, and any pre-v2.1.211 copy left in the session's +start directory — and inventories each one's `allow` / `ask` / `deny` rules with its source named. + +The status vocabulary is the point: `absent` means looked and found nothing, `skipped` means could not +look. The managed scope is four surfaces per OS, not one file; the Windows policy registry keys and +the macOS managed-preferences domain are optional platform integrations that degrade visibly while the +portable core (the JSON file and its `managed-settings.d/` drop-ins) still reads. Server-managed +settings have no local path and are disclosed as invisible rather than assumed empty. Report-only — +it writes nothing in any scope, and managed policy is read-only by construction. + +```shell +/claude-config:audit-permission-state # scopes + rule inventory +/claude-config:audit-permission-state --scopes # which scopes exist and which were readable +``` + ### audit-instructions Audits instruction *content* against current model capability — a different question from the diff --git a/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh b/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh index e2ea5bbbcd..2a2078f8c7 100755 --- a/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh +++ b/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh @@ -84,9 +84,14 @@ fi # root the way every other component in this marketplace does: Claude Code sets # CLAUDE_PLUGIN_ROOT in plugin form, and the BASH_SOURCE fallback keeps a direct # invocation (the test harness, a developer running the script) working. -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${BASH_SOURCE[0]%/*}/../../.." && pwd)}" +PATTERNS_LIB="$PLUGIN_ROOT/lib/permission-patterns.sh" +if [[ ! -r "$PATTERNS_LIB" ]]; then + echo "ERROR: cannot read $PATTERNS_LIB — the plugin's shared permission-pattern library is missing" >&2 + exit 2 +fi # shellcheck source=../../../lib/permission-patterns.sh -source "$PLUGIN_ROOT/lib/permission-patterns.sh" +source "$PATTERNS_LIB" P1_ERE="$CCPERM_P1_ERE" # P2 — machine home-path shapes, ASSEMBLED FROM FRAGMENTS so no contiguous diff --git a/plugins/claude-config/skills/audit-permission-state/SKILL.md b/plugins/claude-config/skills/audit-permission-state/SKILL.md new file mode 100644 index 0000000000..0648dca0c9 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/SKILL.md @@ -0,0 +1,145 @@ +--- +description: "Report the Claude Code permission state actually in effect — discovers every settings scope (managed policy, user-global, project, local, and the pre-v2.1.211 start-directory copy) and inventories each one's allow/ask/deny rules with its source named. Use when: 'what permissions are actually in effect', 'which settings file is my rule coming from', 'why is my allow rule ignored', 'show me my effective permissions', 'is my managed policy being read', 'what scopes did you check', or before changing a permission rule you cannot locate. Report-only — never writes any settings file." +argument-hint: "[--scopes] — surface records only, no rule inventory" +user-invocable: true +disable-model-invocation: false +metadata: + workflow-stage: anytime + summary: Report which permission scopes exist and what rules each one holds +--- + +## Purpose + +Claude Code gives you no way to see the permission rules actually in effect. There is no +`claude permissions` subcommand and no documented machine-readable export, so the honest answer to +"where is this rule coming from" has been "read five files in five places and hope you know all +five". This skill computes that locally. + +It answers a question the siblings do not. `audit-permission-grants` asks whether the grants you +**wrote** are durable and portable; `audit` asks whether your config files are **correct**. This +skill asks what is **in effect** — which scopes exist on this machine, which of them this reader +could actually open, and what each one holds. + +## Scope boundary (route out) + +- Grant portability and auto-mode durability (P1/P2/P3) → `claude-config:audit-permission-grants`. +- Settings-file correctness, baseline deny/ask presence, plugin drift → `claude-config:audit`. +- The instruction layer (CLAUDE.md, rules, auto-memory) → the `claude-memory` plugin. + +## Report-only, permanently + +This skill writes nothing, in any scope, under any flag. Managed policy is read-only by +construction — those are admin-write OS locations or a claude.ai Owner role, so a plugin could not +author them even if it wanted to. + +## Arguments + +Parse `$ARGUMENTS`: + +- `--scopes` — surface records only, no rule inventory. Use when the question is "which scopes exist + and which could you read", not "what is in them". +- (no argument) — surfaces plus one record per allow/ask/deny rule. + +## Phase 1: Discover and inventory + +Run the deterministic spine: + +```shell +bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-state/scripts/permission-state.sh" +``` + +It emits one record per line: + +```text + one per settings surface +rule one per allow/ask/deny entry +NOTE: anything the operator must know +``` + +| Field | Values | +| --- | --- | +| `scope` | `managed`, `user`, `project`, `local`, `startdir-local` | +| `surface` | `file`, `dropin-dir`, `dropin-file:`, `registry`, `plist` (managed); `settings` elsewhere | +| `status` | `present`, `absent`, `unreadable`, `invalid-json`, `skipped`, `not-applicable` | +| `kind` | `allow`, `ask`, `deny` | + +## Reading the output honestly + + + +Interpret and report the records below; the judgment is over the consumer's configuration, never over +anything this skill produced. The status vocabulary carries the whole point of the skill, so do not +collapse it in the report: + +- **`absent` means looked and found nothing.** **`skipped` means could not look.** Never present a + `skipped` surface as "no policy" — say the surface was not read and why. The script emits a `NOTE:` + naming the reason every time. +- **Every scope and every managed surface emits a record on every OS**, including the ones that do + not apply here (`not-applicable`). A surface missing from the output is a defect in this reader, + not evidence about the machine. +- **`managed` means the LOCAL managed surfaces.** Server-managed settings arrive remotely at sign-in + and have no local path, so no local reader can see them. The script says so on every run; carry it + into the report rather than implying completeness. +- **`invalid-json` is not `absent`.** A malformed settings file contributes no rules to the + inventory, but its rules may still be a live problem for the operator — report it as a finding, not + as an empty scope. + +## Scopes, and why there are five + +| Scope | Why it is its own member | +| --- | --- | +| `managed` | Highest precedence. Four surfaces per OS, not one file — see below | +| `user` | `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json`. Where Claude Code's own "Always allow" path writes, so it accumulates the most rules | +| `project` | `.claude/settings.json` at the repository root | +| `local` | `.claude/settings.local.json`, resolved **through worktrees to the main checkout** — anchoring on the worktree root looks where the file is not | +| `startdir-local` | A pre-v2.1.211 copy left in the session's start directory. Not a fallback: when both exist the repository root wins on a shared key, **but permission rules from both stay in effect**, so both are live | + +## The managed scope is four surfaces + +Two are the portable core, read on every OS: the per-OS `managed-settings.json` and its +`managed-settings.d/` drop-in directory (read in the documented order — base first, then `*.json` +sorted alphabetically on top, dotfiles ignored). + +Two are declared optional platform integrations: the Windows policy registry keys and the macOS +managed-preferences domain. Each is read where it is native and readable; where its tool is missing +the surface reports `skipped` with a notice and **every other result is unaffected**. That is the +contract — an optional platform integration degrades visibly and preserves the portable core. + +`HKCU` is not a peer of `HKLM`: it is documented as lowest policy priority, used only when no +admin-level source exists, so the first key that answers wins and the rest are not consulted. + +## Prerequisites + +- **`jq` — required for correctness.** Absent, the script stops at the entry point with + `ERROR: jq required` and exit 2. Report the environment gap; do not report a clean bill. +- **`reg` (Windows) and `defaults` (macOS) — required for an optional feature.** Absent, that one + managed surface is `skipped` with a visible notice and everything else still runs. + +## Verification status + +The Windows registry surface was verified end to end against a real registry key. The macOS +preferences domain and the Linux managed paths are **not** verified on real hardware — they are an +honest manual-verification gap, not a claim. Treat a macOS `plist` record as reporting the surface, +not its contents: the reader names the domain and does not yet inventory its rules. + +## Gotchas + +Observed failures, each of which produced a confidently wrong answer before it was found: + +- **A registry read that silently reports "no policy."** On Git Bash, MSYS rewrites any argument + containing backslashes as though it were a POSIX path, so a registry key reaches `reg.exe` mangled + and the query dies with `ERROR: Invalid syntax`. A caller that only checks the exit status reads + that as "no managed policy deployed" on a machine that has one. The reader disables the rewrite for + those calls; if you invoke `reg` yourself while debugging, do the same or you will reproduce the + wrong answer by hand. +- **A missing shared library used to look like a clean machine.** If the plugin's + `lib/managed-scope.sh` could not be sourced, every managed surface reported `absent`. It is now a + hard `exit 2` — a reader that cannot load its own location list must not answer the question. +- **The local file is not under the worktree you are standing in.** `settings.local.json` resolves + through worktrees to the main checkout, so a reader anchored on `git rev-parse --show-toplevel` + looks where the file is not and reports `absent`. Three documented exceptions keep it in the start + directory — outside a git repository, when the repository root is the home directory, and in Agent + SDK sessions. The reader detects the first two and states that it cannot detect the third. +- **Two live copies of `settings.local.json` are normal, not a bug.** When a pre-v2.1.211 copy sits in + the start directory, the repository-root copy wins on a shared key but permission rules from both + stay in effect. Reporting only one of them under-reports what is live. diff --git a/plugins/claude-config/skills/audit-permission-state/evals/evals.json b/plugins/claude-config/skills/audit-permission-state/evals/evals.json new file mode 100644 index 0000000000..89366802b5 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/evals/evals.json @@ -0,0 +1,52 @@ +{ + "skill_name": "audit-permission-state", + "evals": [ + { + "id": 1, + "name": "inventory-happy-path", + "prompt": "What Claude Code permission rules are actually in effect here, and which file is each one coming from?", + "expected_output": "Runs the deterministic permission-state reader, then reports every settings scope it discovered — managed policy, user-global, project, local, and any pre-v2.1.211 start-directory copy — with each scope's status and its allow/ask/deny rules attributed to the surface they came from.", + "files": [], + "expectations": [ + "Output runs permission-state.sh rather than reading settings files ad hoc", + "Output names all five scopes, including the start-directory copy and the managed scope", + "Output attributes each rule to the scope and surface it was read from" + ] + }, + { + "id": 2, + "name": "skipped-is-not-absent", + "prompt": "Do I have any managed policy deployed on this machine?", + "expected_output": "Distinguishes a managed surface that was read and found empty from one that could not be read at all, reports any skipped surface as unread with its reason, and states that server-managed settings have no local path and are invisible to a local reader.", + "expectations": [ + "Output does not report a skipped surface as 'no managed policy'", + "Output names which managed surfaces were read and which were not", + "Output discloses that server-managed settings cannot be seen locally" + ], + "files": [] + }, + { + "id": 3, + "name": "scope-boundary-routes-out", + "prompt": "Show me what permissions are in effect, and also tell me whether my allow rules will survive auto mode and whether my settings files are correct.", + "expected_output": "Reports the effective scope inventory it owns, and routes auto-mode grant durability to claude-config:audit-permission-grants and settings-file correctness to claude-config:audit rather than answering them here.", + "expectations": [ + "Output reports the scope inventory itself", + "Output routes auto-mode grant durability to audit-permission-grants", + "Output routes settings-file correctness to the audit skill" + ], + "files": [] + }, + { + "id": 4, + "name": "missing-jq-is-an-environment-gap", + "prompt": "Run the permission state audit.", + "expected_output": "When jq is absent the reader exits 2 with 'ERROR: jq required'; the skill reports the environment gap and explicitly does not present a clean result.", + "expectations": [ + "Output reports the missing prerequisite rather than an empty or clean inventory", + "Output does not claim any scope was checked" + ], + "files": [] + } + ] +} diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh new file mode 100644 index 0000000000..6e9ec8edbc --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# permission-state.sh — scope discovery and raw rule inventory for the +# audit-permission-state skill. +# +# Claude Code exposes no way to see the permission rules actually in effect: +# there is no `claude permissions` subcommand and no machine-readable export. +# This script is step one of computing that state locally — it finds every +# settings scope, says what it could and could not read, and emits each scope's +# raw allow/ask/deny rules. It decides NOTHING about precedence; that is the +# merge step's job, and a reader that cannot say which scopes it saw cannot be +# trusted to merge them. +# +# Output (one record per line, stable field order): +# one per settings surface +# rule one per allow/ask/deny entry +# NOTE: anything the operator must know +# +# scope managed | user | project | local | startdir-local +# surface file | dropin-dir | dropin-file: | registry | plist (managed); +# settings (everything else) +# status present | absent | unreadable | invalid-json | skipped | not-applicable +# kind allow | ask | deny +# +# EVERY scope and managed surface emits exactly one record on every OS, even +# when it does not apply here. A surface that is silently absent from the output +# is indistinguishable from one that was never attempted, and this script's whole +# value is being able to say which is which. +# +# Managed policy is READ-ONLY and always will be: those are admin-write OS +# locations or a claude.ai Owner role. This script writes nothing, anywhere. +# +# Prerequisites: +# jq required for correctness — exits 2 at the entry point when absent. +# reg optional platform integration (Windows managed policy). Absent or +# failing: warn visibly, mark that surface `skipped`, keep every other +# result. +# defaults the same, for the macOS managed-preferences domain. +# +# Test seams (the real locations are absolute system paths and a real user home, +# which no test may touch): +# PERMISSION_STATE_FIXTURE_DIR project root +# PERMISSION_STATE_STARTDIR session start directory +# PERMISSION_STATE_MANAGED_PATH managed-settings.json (drop-in dir follows it) +# PERMISSION_STATE_REGISTRY_KEYS newline-separated registry keys to query +# PERMISSION_STATE_PLIST_DOMAIN managed-preferences domain to read +# CLAUDE_CONFIG_DIR / HOME user scope, the same resolver Claude Code documents +# +# Usage: +# permission-state.sh full inventory +# permission-state.sh --scopes surface records only, no rule records +# permission-state.sh --help + +set -uo pipefail + +usage() { + cat <<'EOF' +permission-state.sh — discover every Claude Code settings scope and inventory its rules. + +Usage: permission-state.sh [--scopes|--help] + + (no arg) surface records + one record per allow/ask/deny rule + --scopes surface records only + --help this message + +Records: " " and "rule ". +Every scope and managed surface emits exactly one record on every OS, so a surface +that was never attempted is never mistaken for one that is genuinely absent. + +Reads only. Requires jq (exit 2 when absent); the Windows registry and macOS +preferences-domain surfaces are optional and degrade to `skipped` with a notice. +EOF +} + +case "${1:-}" in +-h | --help) + usage + exit 0 + ;; +*) ;; +esac + +if ! command -v jq >/dev/null 2>&1; then + echo "ERROR: jq required" >&2 + exit 2 +fi + +mode="full" +[[ "${1:-}" == "--scopes" ]] && mode="scopes" + +# `${BASH_SOURCE[0]%/*}` rather than `dirname`: `cd` and `pwd` are builtins, so +# plugin-root resolution needs nothing on PATH. A missing external tool here +# would leave PLUGIN_ROOT empty, the source would fail, and every managed surface +# would report `absent` — a reader claiming no policy is deployed because it +# could not load its own library is the worst failure this script can have. +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${BASH_SOURCE[0]%/*}/../../.." && pwd)}" +MANAGED_SCOPE_LIB="$PLUGIN_ROOT/lib/managed-scope.sh" +if [[ ! -r "$MANAGED_SCOPE_LIB" ]]; then + echo "ERROR: cannot read $MANAGED_SCOPE_LIB — the plugin's shared managed-scope library is missing, so managed policy cannot be located" >&2 + exit 2 +fi +# shellcheck source=../../../lib/managed-scope.sh +source "$MANAGED_SCOPE_LIB" + +emit() { printf '%s %s %s %s\n' "$1" "$2" "$3" "${4:--}"; } +note() { printf 'NOTE: %s\n' "$1"; } + +# MSYS/Cygwin rewrite any argument containing backslashes as though it were a +# POSIX path, so a registry key reaches reg.exe mangled and the call dies with +# "ERROR: Invalid syntax" — which a naive caller reads as "no policy deployed" +# on a machine that has one. Measured on Git Bash 2026-08-11: the same query +# succeeds with the rewrite disabled and fails with it on. Scope the opt-out to +# these calls rather than exporting it, so nothing else in the process changes. +reg_cmd() { MSYS2_ARG_CONV_EXCL='*' reg "$@"; } + +# --- Root resolution ---------------------------------------------------------- + +# The session's start directory. Claude Code wrote settings.local.json here +# before v2.1.211 and still reads what an earlier version left behind. +START_DIR="${PERMISSION_STATE_STARTDIR:-$PWD}" + +if [[ -n "${PERMISSION_STATE_FIXTURE_DIR:-}" ]]; then + PROJECT_ROOT="$PERMISSION_STATE_FIXTURE_DIR" +else + PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null | tr -d '\r')" + [[ -n "$PROJECT_ROOT" ]] || PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}" +fi + +# settings.local.json lives "at the root of the git repository, resolved through +# worktrees to the main checkout, so one file covers sessions started in any +# subdirectory or worktree" — anchoring on --show-toplevel would look for it in +# the WORKTREE, where it is not. --git-common-dir points at the main checkout's +# .git for every linked worktree and at our own inside the main checkout, so its +# parent is the main checkout root either way. +# +# Three documented exceptions keep the file in the start directory: outside a git +# repository, when the repository root is the home directory, and in Agent SDK +# sessions. The first two are detectable here; the third is not, so it is stated +# rather than silently mis-resolved. +LOCAL_ROOT="$PROJECT_ROOT" +local_basis="repository root" +if [[ -z "${PERMISSION_STATE_FIXTURE_DIR:-}" ]]; then + common_dir="$(git rev-parse --git-common-dir 2>/dev/null | tr -d '\r')" + if [[ -n "$common_dir" ]]; then + main_root="$(cd "$common_dir/.." 2>/dev/null && pwd)" + if [[ -n "$main_root" && "$main_root" != "$PROJECT_ROOT" ]]; then + LOCAL_ROOT="$main_root" + local_basis="main checkout, resolved through this worktree" + fi + else + LOCAL_ROOT="$START_DIR" + local_basis="start directory (not inside a git repository)" + fi +fi +if [[ -n "${HOME:-}" && "$LOCAL_ROOT" == "$HOME" ]]; then + LOCAL_ROOT="$START_DIR" + local_basis="start directory (repository root is the home directory)" +fi + +if [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then + USER_CONFIG_ROOT="$CLAUDE_CONFIG_DIR" +elif [[ -n "${HOME:-}" ]]; then + USER_CONFIG_ROOT="$HOME/.claude" +else + USER_CONFIG_ROOT="" +fi + +MANAGED_FILE="$(mscope::base_file "${PERMISSION_STATE_MANAGED_PATH:-}")" +MANAGED_DROPIN="$(mscope::dropin_dir "${PERMISSION_STATE_MANAGED_PATH:-}")" + +# --- Reading ------------------------------------------------------------------ + +# classify_json_file — status for a settings file, without ever holding +# its contents in a variable (a settings file may carry credentials, and a shell +# variable would put them into `set -x` output). +classify_json_file() { + local path="$1" + [[ -f "$path" ]] || { + printf 'absent\n' + return 0 + } + : <"$path" 2>/dev/null || { + printf 'unreadable\n' + return 0 + } + tr -d '\r' <"$path" | jq empty 2>/dev/null || { + printf 'invalid-json\n' + return 0 + } + printf 'present\n' +} + +emit_file_rules() { + # emit_file_rules + [[ "$mode" == "full" ]] || return 0 + local scope="$1" surface="$2" path="$3" kind + for kind in allow ask deny; do + while IFS= read -r rule; do + [[ -n "$rule" ]] && printf 'rule %s %s %s %s\n' "$scope" "$surface" "$kind" "$rule" + # jq emits CRLF on Windows; a trailing \r would corrupt every rule string. + done < <(tr -d '\r' <"$path" | jq -r --arg k "$kind" '.permissions[$k] // [] | .[]' 2>/dev/null | tr -d '\r') + done +} + +emit_json_scope() { + # emit_json_scope + local scope="$1" surface="$2" path="$3" status + status="$(classify_json_file "$path")" + emit "$scope" "$surface" "$status" "$path" + [[ "$status" == "present" ]] && emit_file_rules "$scope" "$surface" "$path" + return 0 +} + +# --- Managed scope: portable core -------------------------------------------- + +emit_json_scope managed file "$MANAGED_FILE" + +if [[ -d "$MANAGED_DROPIN" ]]; then + emit managed dropin-dir present "$MANAGED_DROPIN" + # "managed-settings.json is merged first as the base, then all *.json files in + # the drop-in directory are sorted alphabetically and merged on top… Hidden + # files starting with . are ignored." Read them in that documented order so a + # downstream merge does not have to guess it; `sort` is the same collation the + # doc's "alphabetically" names, and the caller sees the order it read them in. + while IFS= read -r dropin; do + [[ -f "$dropin" ]] || continue + case "${dropin##*/}" in .*) continue ;; *) ;; esac + emit_json_scope managed "dropin-file:${dropin##*/}" "$dropin" + done < <(find "$MANAGED_DROPIN" -maxdepth 1 -type f -name '*.json' 2>/dev/null | LC_ALL=C sort) +else + emit managed dropin-dir absent "$MANAGED_DROPIN" +fi + +# --- Managed scope: declared optional platform integrations ------------------- +# +# Present where native and readable; a visibly announced `skipped` otherwise. The +# portable core above is never affected — that is the contract an optional +# platform integration owes. + +registry_keys="${PERMISSION_STATE_REGISTRY_KEYS:-$(mscope::registry_keys)}" +if [[ -z "$registry_keys" ]]; then + emit managed registry not-applicable "-" +elif ! command -v reg >/dev/null 2>&1; then + emit managed registry skipped "-" + note "Windows managed policy not read: 'reg' is not on PATH. Every other scope below is unaffected; the managed result is incomplete, not empty." +else + # The policy JSON lives in a single `Settings` value on the key. HKCU is + # documented as "lowest policy priority, only used when no admin-level source + # exists", so the first key that answers wins and the rest are not consulted — + # merging them would report policy that is not in force. + registry_status="absent" + registry_path="-" + while IFS= read -r key; do + [[ -n "$key" ]] || continue + if reg_cmd query "$key" /v Settings >/dev/null 2>&1; then + registry_status="present" + registry_path="$key" + break + fi + done <<<"$registry_keys" + emit managed registry "$registry_status" "$registry_path" + if [[ "$registry_status" == "present" && "$mode" == "full" ]]; then + # `reg query` prints " " with the data as the rest of the + # line; cut at the type token rather than by field count, because the JSON + # payload contains spaces. + reg_json="$(reg_cmd query "$registry_path" /v Settings 2>/dev/null | tr -d '\r' | + sed -n 's/.*REG_\(EXPAND_\)\{0,1\}SZ[[:space:]]*//p' | head -1)" + if [[ -z "$reg_json" ]] || ! printf '%s' "$reg_json" | jq empty 2>/dev/null; then + note "Windows managed policy key $registry_path carries a Settings value that did not parse as JSON — reporting it as unread rather than as empty." + else + for kind in allow ask deny; do + while IFS= read -r rule; do + [[ -n "$rule" ]] && printf 'rule managed registry %s %s\n' "$kind" "$rule" + done < <(printf '%s' "$reg_json" | jq -r --arg k "$kind" '.permissions[$k] // [] | .[]' 2>/dev/null | tr -d '\r') + done + fi + fi +fi + +plist_domain="${PERMISSION_STATE_PLIST_DOMAIN:-$(mscope::plist_domain)}" +if [[ -z "$plist_domain" ]]; then + emit managed plist not-applicable "-" +elif ! command -v defaults >/dev/null 2>&1; then + emit managed plist skipped "-" + note "macOS managed preferences not read: 'defaults' is not on PATH. Every other scope below is unaffected; the managed result is incomplete, not empty." +else + if defaults read "$plist_domain" >/dev/null 2>&1; then + emit managed plist present "$plist_domain" + note "The managed-preferences domain $plist_domain is present. Its rules are NOT inventoried yet — this reader reports the surface, not its contents." + else + emit managed plist absent "$plist_domain" + fi +fi + +# Server-managed settings arrive remotely at sign-in and have no local path, so +# no local reader can see them. Saying so is the difference between an honest +# managed report and one that implies completeness it cannot have. +note "Server-managed settings (delivered at sign-in via the claude.ai admin console or a self-hosted gateway) have no local path and are not visible to any local reader. 'managed' above means the local managed surfaces only." + +# --- The four file scopes ----------------------------------------------------- + +if [[ -n "$USER_CONFIG_ROOT" ]]; then + emit_json_scope user settings "$USER_CONFIG_ROOT/settings.json" +else + emit user settings skipped "-" + note "User scope not read: neither CLAUDE_CONFIG_DIR nor HOME is set, so ~/.claude could not be resolved." +fi + +emit_json_scope project settings "$PROJECT_ROOT/.claude/settings.json" +emit_json_scope local settings "$LOCAL_ROOT/.claude/settings.local.json" +note "local scope anchored on the $local_basis. In an Agent SDK session the file stays in the start directory instead, which this reader cannot detect." + +# The pre-v2.1.211 copy is a DISTINCT scope member, not a fallback: when both +# exist "the repository root's value wins, except that permission rules from both +# files stay in effect", so both rule sets are live and both must be inventoried. +STARTDIR_LOCAL="$START_DIR/.claude/settings.local.json" +if [[ "$STARTDIR_LOCAL" == "$LOCAL_ROOT/.claude/settings.local.json" ]]; then + emit startdir-local settings not-applicable "$STARTDIR_LOCAL" +else + emit_json_scope startdir-local settings "$STARTDIR_LOCAL" +fi + +exit 0 diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh new file mode 100644 index 0000000000..1c3d19c989 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# Regression tests for permission-state.sh (self-contained — ships with the plugin). +# +# Every run is fully fixtured: project root, start directory, managed policy, and +# the user home all point into a temp tree, with CLAUDE_CONFIG_DIR unset. No test +# reads or writes the operator's real ~/.claude or the machine's real managed +# policy — which is also why the Windows registry surface is exercised through +# its key-list seam rather than against a deployed policy. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/permission-state.sh" + +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +FAILED=0 +CASE_NUM=0 +pass() { + CASE_NUM=$((CASE_NUM + 1)) + printf 'PASS: %s\n' "$1" +} +fail() { + CASE_NUM=$((CASE_NUM + 1)) + FAILED=$((FAILED + 1)) + printf 'FAIL: %s\n detail: %s\n' "$1" "$2" >&2 +} +assert_eq() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected: $2, actual: $3"; fi +} +assert_exit() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected exit $2, got $3"; fi +} +assert_contains() { + case "$2" in + *"$3"*) pass "$1" ;; + *) fail "$1" "expected to contain: $3" ;; + esac +} +assert_not_contains() { + case "$2" in + *"$3"*) fail "$1" "unexpected substring: $3" ;; + *) pass "$1" ;; + esac +} +count_matching() { printf '%s\n' "$1" | grep -cE "$2"; } + +if ! command -v jq >/dev/null 2>&1; then + echo "SKIP: jq not installed" >&2 + exit 0 +fi + +# --- Fixture tree: all five scopes populated --------------------------------- +FX="$TEST_TMPDIR/fx" +mkdir -p "$FX/proj/.claude" "$FX/home/.claude" "$FX/policy/managed-settings.d" "$FX/startdir/.claude" +jq -n '{permissions:{allow:["Bash(git status)"],deny:["WebFetch"]}}' >"$FX/proj/.claude/settings.json" +jq -n '{permissions:{allow:["Bash(npm test)"]}}' >"$FX/proj/.claude/settings.local.json" +jq -n '{permissions:{allow:["Bash(python*)"]}}' >"$FX/home/.claude/settings.json" +jq -n '{permissions:{deny:["Read(./.env)"]}}' >"$FX/policy/managed-settings.json" +jq -n '{permissions:{ask:["Bash(rm *)"]}}' >"$FX/policy/managed-settings.d/20-second.json" +jq -n '{permissions:{ask:["Bash(dd *)"]}}' >"$FX/policy/managed-settings.d/10-first.json" +jq -n '{permissions:{allow:["Bash(ls)"]}}' >"$FX/policy/managed-settings.d/.hidden.json" +jq -n '{permissions:{allow:["Bash(ls)"]}}' >"$FX/startdir/.claude/settings.local.json" + +run() { + env -u CLAUDE_CONFIG_DIR \ + HOME="$FX/home" \ + PERMISSION_STATE_FIXTURE_DIR="$FX/proj" \ + PERMISSION_STATE_STARTDIR="$FX/startdir" \ + PERMISSION_STATE_MANAGED_PATH="$FX/policy/managed-settings.json" \ + PERMISSION_STATE_REGISTRY_KEYS="${STUB_REGISTRY_KEYS:-}" \ + PERMISSION_STATE_PLIST_DOMAIN="${STUB_PLIST_DOMAIN:-}" \ + bash "$SCRIPT" "$@" +} + +# --- Case 1: --help ---------------------------------------------------------- +rc=0 +OUT=$(bash "$SCRIPT" --help) || rc=$? +assert_exit "--help exits 0" 0 "$rc" +assert_contains "--help prints usage" "$OUT" "Usage:" + +# --- Case 2: every file scope appears EXACTLY once --------------------------- +# Exactly one, not "at least one": a count of >=1 passes on project+local alone +# and leaves the two scopes this reader exists to add entirely unverified. +rc=0 +OUT=$(run) || rc=$? +assert_exit "full run exits 0" 0 "$rc" +assert_eq "user scope appears exactly once" "1" "$(count_matching "$OUT" '^user ')" +assert_eq "project scope appears exactly once" "1" "$(count_matching "$OUT" '^project ')" +assert_eq "local scope appears exactly once" "1" "$(count_matching "$OUT" '^local ')" +assert_eq "start-directory scope appears exactly once" "1" "$(count_matching "$OUT" '^startdir-local ')" + +# --- Case 3: every managed SURFACE appears exactly once, on every OS ---------- +# The managed scope is four surfaces with different costs. Asserting a single +# aggregate "managed" row would pass with three of the four never attempted. +assert_eq "managed base file surface" "1" "$(count_matching "$OUT" '^managed file ')" +assert_eq "managed drop-in directory surface" "1" "$(count_matching "$OUT" '^managed dropin-dir ')" +assert_eq "managed registry surface" "1" "$(count_matching "$OUT" '^managed registry ')" +assert_eq "managed preferences-domain surface" "1" "$(count_matching "$OUT" '^managed plist ')" + +# --- Case 4: rules are inventoried per scope and kind ------------------------ +assert_contains "managed deny rule" "$OUT" "rule managed file deny Read(./.env)" +assert_contains "user allow rule" "$OUT" "rule user settings allow Bash(python*)" +assert_contains "project allow rule" "$OUT" "rule project settings allow Bash(git status)" +assert_contains "project deny rule" "$OUT" "rule project settings deny WebFetch" +assert_contains "local allow rule" "$OUT" "rule local settings allow Bash(npm test)" +assert_contains "start-directory allow rule" "$OUT" "rule startdir-local settings allow Bash(ls)" + +# --- Case 5: drop-ins in documented order, dotfiles ignored ------------------ +# "all *.json files in the drop-in directory are sorted alphabetically and merged +# on top… Hidden files starting with . are ignored." Order is load-bearing: later +# files override earlier ones, so a reader that emits them out of order hands the +# merge step the wrong answer. +dropins=$(printf '%s\n' "$OUT" | grep -oE '^managed dropin-file:[^ ]+' | sed 's/^managed dropin-file://') +assert_eq "drop-ins emitted in alphabetical order" "$(printf '10-first.json\n20-second.json')" "$dropins" +assert_not_contains "hidden drop-in ignored" "$OUT" ".hidden.json" + +# --- Case 6: --scopes suppresses rule records only --------------------------- +OUT_SCOPES=$(run --scopes) +assert_eq "no rule records under --scopes" "0" "$(count_matching "$OUT_SCOPES" '^rule ')" +assert_eq "surface records survive --scopes" "1" "$(count_matching "$OUT_SCOPES" '^managed file ')" + +# --- Case 7: server-managed settings are disclosed, never implied absent ----- +assert_contains "server-managed settings disclosed" "$OUT" "Server-managed settings" + +# --- Case 8: status vocabulary distinguishes absent from malformed ----------- +BAD="$TEST_TMPDIR/bad" +mkdir -p "$BAD/proj/.claude" "$BAD/home/.claude" "$BAD/startdir" +printf '{invalid\n' >"$BAD/proj/.claude/settings.json" +OUT_BAD=$(env -u CLAUDE_CONFIG_DIR HOME="$BAD/home" \ + PERMISSION_STATE_FIXTURE_DIR="$BAD/proj" \ + PERMISSION_STATE_STARTDIR="$BAD/startdir" \ + PERMISSION_STATE_MANAGED_PATH="$BAD/policy/managed-settings.json" \ + PERMISSION_STATE_REGISTRY_KEYS="" PERMISSION_STATE_PLIST_DOMAIN="" \ + bash "$SCRIPT") +assert_contains "malformed settings reported as invalid-json" "$OUT_BAD" "project settings invalid-json" +assert_contains "missing settings reported as absent" "$OUT_BAD" "user settings absent" +assert_contains "missing managed file reported as absent" "$OUT_BAD" "managed file absent" +assert_eq "a malformed file contributes no rules" "0" "$(count_matching "$OUT_BAD" '^rule project ')" + +# --- Case 9: the start-directory copy is never double-counted ---------------- +# When the session starts at the repository root the two paths are the same file. +# Reporting it twice would claim two live rule sources where there is one. +OUT_SAME=$(env -u CLAUDE_CONFIG_DIR HOME="$FX/home" \ + PERMISSION_STATE_FIXTURE_DIR="$FX/proj" \ + PERMISSION_STATE_STARTDIR="$FX/proj" \ + PERMISSION_STATE_MANAGED_PATH="$FX/policy/managed-settings.json" \ + PERMISSION_STATE_REGISTRY_KEYS="" PERMISSION_STATE_PLIST_DOMAIN="" \ + bash "$SCRIPT") +assert_contains "same directory reported not-applicable" "$OUT_SAME" "startdir-local settings not-applicable" +assert_eq "its rules are not counted twice" "1" "$(count_matching "$OUT_SAME" '^rule local settings allow Bash\(npm test\)')" + +# --- Case 10: an unresolvable user scope is announced ------------------------ +OUT_NOHOME=$(env -u CLAUDE_CONFIG_DIR -u HOME \ + PERMISSION_STATE_FIXTURE_DIR="$FX/proj" \ + PERMISSION_STATE_STARTDIR="$FX/startdir" \ + PERMISSION_STATE_MANAGED_PATH="$FX/policy/managed-settings.json" \ + PERMISSION_STATE_REGISTRY_KEYS="" PERMISSION_STATE_PLIST_DOMAIN="" \ + bash "$SCRIPT") +assert_contains "unresolvable user scope is skipped, not absent" "$OUT_NOHOME" "user settings skipped" +assert_contains "and says why" "$OUT_NOHOME" "neither CLAUDE_CONFIG_DIR nor HOME" + +# --- Case 11: optional platform legs degrade visibly, core survives ---------- +# A stub PATH holding every tool the script needs EXCEPT `reg`. Not a bare +# `PATH=`: that makes the interpreter itself unresolvable (exit 127, "command not +# found"), which would "pass" for a reason unrelated to the tool under test. +# +# Each entry is a wrapper that execs the real binary at its absolute path, +# deliberately NOT a copy: an MSYS binary copied out of /usr/bin loses the +# msys-2.0.dll sitting beside it and fails to start, which would make this case +# "pass" by breaking every tool instead of the one under test. +STUB="$TEST_TMPDIR/stub-path" +mkdir -p "$STUB" +real_bash="$(command -v bash)" +for tool in jq git tr find sort sed head grep cat mktemp rm; do + src="$(command -v "$tool" 2>/dev/null)" || continue + [[ -n "$src" ]] || continue + printf '#!%s\nexec "%s" "$@"\n' "$real_bash" "$src" >"$STUB/$tool" + chmod +x "$STUB/$tool" +done +rc=0 +OUT_NOREG=$(env -u CLAUDE_CONFIG_DIR PATH="$STUB" HOME="$FX/home" \ + PERMISSION_STATE_FIXTURE_DIR="$FX/proj" \ + PERMISSION_STATE_STARTDIR="$FX/startdir" \ + PERMISSION_STATE_MANAGED_PATH="$FX/policy/managed-settings.json" \ + PERMISSION_STATE_REGISTRY_KEYS='HKLM\SOFTWARE\Policies\ClaudeCode' \ + PERMISSION_STATE_PLIST_DOMAIN="" \ + "$real_bash" "$SCRIPT" 2>&1) || rc=$? +assert_exit "missing optional tool does not fail the run" 0 "$rc" +assert_contains "registry surface marked skipped" "$OUT_NOREG" "managed registry skipped" +assert_contains "and the skip is announced" "$OUT_NOREG" "'reg' is not on PATH" +assert_contains "portable core still read: base file" "$OUT_NOREG" "managed file present" +assert_contains "portable core still read: drop-in dir" "$OUT_NOREG" "managed dropin-dir present" +assert_contains "other scopes unaffected" "$OUT_NOREG" "rule user settings allow Bash(python*)" + +# --- Case 12: jq is required for correctness --------------------------------- +# The jq gate runs before any external tool, so an EMPTY stub dir is enough here; +# bash is invoked by absolute path so the empty PATH cannot hide the interpreter. +empty_path_dir="$TEST_TMPDIR/empty-path" +mkdir -p "$empty_path_dir" +rc=0 +err_out=$(PATH="$empty_path_dir" "$real_bash" "$SCRIPT" 2>&1) || rc=$? +assert_exit "exit 2 when jq missing" 2 "$rc" +assert_contains "jq required message" "$err_out" "jq required" + +if [[ "$FAILED" -eq 0 ]]; then + printf '\nAll %d checks passed.\n' "$CASE_NUM" + exit 0 +fi +printf '\n%d/%d checks failed.\n' "$FAILED" "$CASE_NUM" >&2 +exit 1 diff --git a/plugins/claude-config/skills/audit/scripts/check-structure.sh b/plugins/claude-config/skills/audit/scripts/check-structure.sh index 91554d5c8e..d6028497d9 100755 --- a/plugins/claude-config/skills/audit/scripts/check-structure.sh +++ b/plugins/claude-config/skills/audit/scripts/check-structure.sh @@ -69,9 +69,18 @@ STARTDIR_LOCAL="$START_DIR/.claude/settings.local.json" # fallback keeps a direct invocation working. SETTINGS_AUDIT_MANAGED_PATH stays # this script's own test seam — the real locations are absolute system paths a # fixture dir cannot reach. -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${BASH_SOURCE[0]%/*}/../../.." && pwd)}" +MANAGED_SCOPE_LIB="$PLUGIN_ROOT/lib/managed-scope.sh" +# Fail loudly rather than fall through: an unsourced library leaves the managed +# path empty and every managed row would read `Present: no`, reporting an +# administrator's policy as absent because this script could not find its own +# location list. +if [[ ! -r "$MANAGED_SCOPE_LIB" ]]; then + echo "ERROR: cannot read $MANAGED_SCOPE_LIB — the plugin's shared managed-scope library is missing" >&2 + exit 2 +fi # shellcheck source=../../../lib/managed-scope.sh -source "$PLUGIN_ROOT/lib/managed-scope.sh" +source "$MANAGED_SCOPE_LIB" MANAGED="$(mscope::base_file "${SETTINGS_AUDIT_MANAGED_PATH:-}")" MANAGED_DROPIN="$(mscope::dropin_dir "${SETTINGS_AUDIT_MANAGED_PATH:-}")" From d269c41435410faea3e61c08bf7b31ba0094430e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:46:50 -0400 Subject: [PATCH 07/14] fix(claude-config): end the managed-registry search at the first key that exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stray admin-level key could make user-level policy look like the managed policy. The search stopped at the first key whose Settings value parsed, so an HKLM key with a missing or malformed value fell through to HKCU — which the settings doc calls lowest policy priority, used only when no admin-level source exists. Reporting it while an admin-level key is in force is the precise failure that rule exists to prevent. `reg query /v Settings` cannot distinguish a missing key from a present key with no such value: both return exit 1 and the same message. A bare `reg query ` does distinguish them, so key existence is now the probe that ends the search, and an existing key that yields nothing readable is reported unreadable with a note rather than as permission to consult the next key. The regression test uses HKCU\SOFTWARE, which exists on every Windows install and carries no Settings value, so it covers the case read-only and writes nothing to the registry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/topics/permission-model/PLAN.md | 23 +++++++++++- .../permission-model/phase0-fresh-docs.md | 9 +++-- .../scripts/permission-state.sh | 22 +++++++++--- .../scripts/permission-state.test.sh | 36 ++++++++++++++++++- 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index 9b80063a83..d1fc6a2288 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -304,7 +304,8 @@ deployed to the machine) was created, read, parsed, and deleted; `HKCU\SOFTWARE\ was confirmed still absent afterwards. The shipped test uses the key-list seam instead, because a test that writes to a consumer's registry is not something this marketplace should ship. -**Two wrong-answer defects the stub-`PATH` case caught before they shipped:** +**Three wrong-answer defects caught before they shipped**, two by the stub-`PATH` case and one by +reviewing the code against this plan's own `HKCU`-is-a-fallback claim: - **MSYS argument conversion silently broke every registry read.** Git Bash rewrites an argument containing backslashes as though it were a POSIX path, so `reg query 'HKLM\SOFTWARE\...'` reaches @@ -312,6 +313,13 @@ that writes to a consumer's registry is not something this marketplace should sh managed policy deployed" on a machine that has one. Exactly the failure mode the Option A decision was chosen to avoid, arriving by a different route. Fixed by scoping `MSYS2_ARG_CONV_EXCL` to those calls; measured both ways. +- **A stray admin-level registry key could have made user-level policy look like the managed + policy.** The search stopped at the first key whose `Settings` value parsed, so an `HKLM` key with a + missing or malformed value fell through to `HKCU` — reporting lowest-priority policy as the one in + force, the exact failure the `HKCU`-is-a-fallback rule exists to prevent. `reg query /v + Settings` cannot distinguish a missing key from a missing value; a bare `reg query ` can, and + is now the existence probe. Covered by a read-only test using `HKCU\SOFTWARE`, which exists + everywhere and carries no `Settings` value. - **A missing shared library reported a clean machine.** With `dirname` off `PATH`, plugin-root resolution collapsed, the `source` failed, and every managed surface reported `absent` while the run still exited 0. Now resolved with builtins only (`${BASH_SOURCE[0]%/*}`) and a hard `exit 2` when the @@ -388,6 +396,9 @@ The integration slice. Everything downstream reads what this produces. JSON lives in a `Settings` value (`REG_SZ`/`REG_EXPAND_SZ`) on the key, so the read targets that value; `HKCU` is documented as lowest policy priority, "only used when no admin-level source exists", so consulting it while `HKLM` carries policy would report policy that is not in force. + **The search therefore ends at the first key that EXISTS, not at the first key whose value + parses** — see the Phase 0 addendum for why the `/v` form cannot make that distinction. An + existing key with no readable value is reported unread, never as permission to consult the next. - **Server-managed settings are a managed source with no local path.** Delivered remotely at sign-in, so no local reader can see them. Every managed finding says "the local managed surfaces", never "the managed policy" — the completeness claim is not available. @@ -425,6 +436,16 @@ Acceptance criterion 1. requires each precedence claim to cite its mechanic. - Per the Brief's decidability bound: anything resting on classifier judgment, runtime demotion state, or an open upstream discrepancy becomes a named caveat on the affected finding, never a silent drop. +- **Treat `not-applicable` exactly like `absent` when merging, and keep them distinct in the report.** + Phase 1 emits a record for every scope on every OS; `startdir-local` is `not-applicable` when the + session started at the repository root, and the optional managed legs are `not-applicable` off their + native OS. A merge that skips only `absent` trips over the others. `skipped` and `unreadable` are a + third case again — they contribute no rules but must not be merged as "this scope is empty", because + the scope was never read. +- **`reference/criteria.md` does not exist yet.** Phase 1 deliberately did not create it rather than + fill it with a restatement of the record contract `SKILL.md` already carries. This phase's + `precedence_basis` is the first content that genuinely belongs in it — create it here, or leave it + to Phase 4, but do not recreate the duplication. **Sanity Check:** run against a fixture with a rule defined at two scopes; assert the output names exactly one winner and that `grep -c 'precedence_basis'` equals the merged-rule count (no rule diff --git a/docs/topics/permission-model/phase0-fresh-docs.md b/docs/topics/permission-model/phase0-fresh-docs.md index d541f444f3..81ea14b192 100644 --- a/docs/topics/permission-model/phase0-fresh-docs.md +++ b/docs/topics/permission-model/phase0-fresh-docs.md @@ -108,8 +108,13 @@ Consequences carried into the plan: - Phase 1 can state drop-in merge results as **decided**, not caveated — the ordering is documented. The `$defaults`-style caveat the Brief's decidability bound calls for does not apply here. -- Phase 1's Windows registry leg reads the `Settings` value and consults `HKCU` **only** when `HKLM` - carries nothing. +- Phase 1's Windows registry leg reads the `Settings` value and consults `HKCU` **only when no + admin-level key exists** — key existence, not value readability, ends the search. Measured + 2026-08-11: `reg query /v Settings` returns the same exit code and the same message for a + missing key and for a present key with no such value, so keying the search on the `/v` form would + let an `HKLM` key with an unreadable value fall through and report user-level policy as the managed + policy. A bare `reg query ` does distinguish the two (exit 0 when the key exists), so that is + the existence probe; an existing key that yields nothing readable is reported unread. - Phase 6's managed-conformance report carries a standing caveat that server-managed settings are a managed source with no local path, so "the deployed managed policy" always means the local surfaces. A report that omits this implies a completeness it cannot have. diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh index 6e9ec8edbc..a6c30066b5 100644 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh @@ -245,17 +245,31 @@ elif ! command -v reg >/dev/null 2>&1; then else # The policy JSON lives in a single `Settings` value on the key. HKCU is # documented as "lowest policy priority, only used when no admin-level source - # exists", so the first key that answers wins and the rest are not consulted — - # merging them would report policy that is not in force. + # exists", so the search stops at the first key that EXISTS and the rest are + # not consulted — merging them would report policy that is not in force. + # + # Existence is probed with a bare `reg query `, not `/v Settings`: with + # `/v` the two failures that must not be conflated — key absent, and key + # present but carrying no Settings value — return the same exit code and the + # same message ("The system was unable to find the specified registry key or + # value"), measured on Windows 11 2026-08-11. Keying the search on the /v form + # would let an admin-level key with an unreadable value fall through to HKCU + # and report user-level policy as the managed policy while the admin-level key + # is what is in force. A key that exists but yields nothing readable is + # reported as `unreadable`, never as a licence to consult the next key. registry_status="absent" registry_path="-" while IFS= read -r key; do [[ -n "$key" ]] || continue + reg_cmd query "$key" >/dev/null 2>&1 || continue + registry_path="$key" if reg_cmd query "$key" /v Settings >/dev/null 2>&1; then registry_status="present" - registry_path="$key" - break + else + registry_status="unreadable" + note "Managed policy key $registry_path exists but carries no readable Settings value. Lower-priority policy keys are NOT consulted in its place — an admin-level key that exists is the source in force, so the managed registry result is unread rather than empty." fi + break done <<<"$registry_keys" emit managed registry "$registry_status" "$registry_path" if [[ "$registry_status" == "present" && "$mode" == "full" ]]; then diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh index 1c3d19c989..436d90f4f2 100644 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh @@ -193,7 +193,41 @@ assert_contains "portable core still read: base file" "$OUT_NOREG" "managed file assert_contains "portable core still read: drop-in dir" "$OUT_NOREG" "managed dropin-dir present" assert_contains "other scopes unaffected" "$OUT_NOREG" "rule user settings allow Bash(python*)" -# --- Case 12: jq is required for correctness --------------------------------- +# --- Case 12: registry key selection stops at the first key that EXISTS ------- +# HKCU is documented as lowest policy priority, used only when no admin-level +# source exists, so an existing higher-priority key must end the search even when +# its Settings value cannot be read — otherwise a stray admin-level key lets +# user-level policy be reported as the managed policy. +# +# Read-only, and writes nothing to the registry: the fixture keys are one that +# cannot exist and HKCU\SOFTWARE, which exists on every Windows install and +# carries no Settings value — exactly the key-present/value-absent case. +if command -v reg >/dev/null 2>&1; then + OUT_REG=$(env -u CLAUDE_CONFIG_DIR HOME="$FX/home" \ + PERMISSION_STATE_FIXTURE_DIR="$FX/proj" \ + PERMISSION_STATE_STARTDIR="$FX/startdir" \ + PERMISSION_STATE_MANAGED_PATH="$FX/policy/managed-settings.json" \ + PERMISSION_STATE_REGISTRY_KEYS="$(printf 'HKCU\\SOFTWARE\\ClaudeCodeNoSuchKeyExists\nHKCU\\SOFTWARE')" \ + PERMISSION_STATE_PLIST_DOMAIN="" \ + bash "$SCRIPT") + assert_contains "an absent key is skipped, the existing one is selected" "$OUT_REG" "managed registry unreadable HKCU\\SOFTWARE" + assert_contains "a key with no readable value is not a licence to fall through" "$OUT_REG" "Lower-priority policy keys are NOT consulted" + assert_eq "no rules are claimed from an unreadable key" "0" "$(count_matching "$OUT_REG" '^rule managed registry ')" + + OUT_REG_NONE=$(env -u CLAUDE_CONFIG_DIR HOME="$FX/home" \ + PERMISSION_STATE_FIXTURE_DIR="$FX/proj" \ + PERMISSION_STATE_STARTDIR="$FX/startdir" \ + PERMISSION_STATE_MANAGED_PATH="$FX/policy/managed-settings.json" \ + PERMISSION_STATE_REGISTRY_KEYS="$(printf 'HKCU\\SOFTWARE\\ClaudeCodeNoSuchKeyExists\nHKCU\\SOFTWARE\\ClaudeCodeAlsoAbsent')" \ + PERMISSION_STATE_PLIST_DOMAIN="" \ + bash "$SCRIPT") + assert_contains "no policy keys at all reports absent" "$OUT_REG_NONE" "managed registry absent" +else + pass "registry key selection (skipped — reg not on PATH)" + pass "registry absence reporting (skipped — reg not on PATH)" +fi + +# --- Case 13: jq is required for correctness --------------------------------- # The jq gate runs before any external tool, so an EMPTY stub dir is enough here; # bash is invoked by absolute path so the empty PATH cannot hide the interpreter. empty_path_dir="$TEST_TMPDIR/empty-path" From 434d24f4f54b0a5ab719ca6910268cb1adfa5e2d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:48:16 -0400 Subject: [PATCH 08/14] feat(claude-config): compute the permission set in force, with each rule's mechanic named audit-permission-state could say what every scope holds but not what any of it means together. This adds the merge: one effective allow/ask/deny set, each rule naming every scope that contributes it and the documented mechanic that put it there. The two governing doc sections were re-fetched before any code was written, and they overturned the planned model. Permission rules "merge across scopes rather than override", so a rule written at two scopes has no winner and electing one would have been a precedence claim no page supports. What a rule can lose is its kind: deny is evaluated before ask and ask before allow, from any scope, in both directions -- a user-level deny blocks a project-level allow just as a project-level deny blocks a user-level allow. That is the only election made here, and a scope-ranked implementation would get the low-scope-deny case exactly backwards. The beaten entry ships as an `inert` record naming what beat it, which is the answer to "why is my allow rule ignored". Every run states the two bounds on the claim. The command-line scope (--settings, --allowedTools, --disallowedTools) outranks the files and has no file to read. Rules are compared by exact text, so a narrow allow blocked only by a broader deny pattern is still reported effective -- the docs state the broad deny wins, so the error direction is known: it over-reports allow and never over-reports blocking. An empty merge cannot mean an empty machine. A pipeline that swallowed the reader's exit 2 would report "nothing in effect" on a machine full of rules, so zero scope records on input is exit 2 before any merge line is emitted. The skill also stops overclaiming against `/permissions`, which the permissions page documents as listing every rule and the file it came from. The Purpose now points readers there for "where is this rule written" and claims only the difference: outcome resolution, telling a scope that was empty from one that could not be read, and an output another tool can consume. reference/criteria.md finally has content that is not a restatement -- the precedence_basis vocabulary, each token mapped to the sentence it follows from. 40 new checks, the reader's 42 still pass, shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/topics/permission-model/PLAN.md | 45 ++- .../permission-model/phase0-fresh-docs.md | 36 +++ plugins/claude-config/CHANGELOG.md | 18 +- .../skills/audit-permission-state/SKILL.md | 47 +++- .../reference/criteria.md | 80 ++++++ .../scripts/permission-merge.sh | 163 +++++++++++ .../scripts/permission-merge.test.sh | 266 ++++++++++++++++++ 7 files changed, 647 insertions(+), 8 deletions(-) create mode 100644 plugins/claude-config/skills/audit-permission-state/reference/criteria.md create mode 100644 plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh create mode 100644 plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index d1fc6a2288..a066bf0309 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -426,7 +426,44 @@ The integration slice. Everything downstream reads what this produces. yields `bash: command not found` and exit 127, because the interpreter itself becomes unresolvable, so the check would fail for a reason unrelated to jq. -### Phase 2: Merge and per-rule provenance [TODO] +### Phase 2: Merge and per-rule provenance [DONE] + +**Completed 2026-08-11.** `permission-merge.test.sh` 40/40, `shellcheck -x` clean, +`permission-state.test.sh` still 42/42. `scripts/permission-merge.sh` consumes Phase 1's records and +emits the effective set; `reference/criteria.md` now exists and carries the `precedence_basis` +vocabulary; `SKILL.md` gained the Phase 2 pipeline. + +**The phase as written assumed the wrong merge model, and Phase 0's 2026-08-11 addendum overrides +it.** The two governing sections were re-fetched before any code was written, per the standing +re-fetch constraint, and they say permission rules *"merge across scopes rather than override"*. So: + +- **There is no same-kind winner, and electing one would have been an uncited precedence claim.** A + rule text in the same list at two scopes has both entries live. Provenance for that case is the + full contributor set (`precedence_basis=merged-across-scopes`), not an origin. +- **A winner exists only across kinds**, elected by evaluation order — *"deny rules from any scope are + evaluated before allow rules"*, stated in both directions on the page. Losing entries ship as + `inert` records naming what beat them, which is what answers "why is my allow rule ignored". +- **The phase's `origin` field is therefore not implemented as written.** `scopes=` carries every + contributing scope; the reader's emission order is presentation, never a ranking. +- **The sanity check's one-winner assertion is bound to the cross-kind fixture**, where it is true, + and a second fixture asserts that a same-kind duplicate elects nothing and reports nothing beaten. + The basis invariant is anchored on the `effective` record prefix rather than a bare + `precedence_basis` grep, so it cannot pass on unrelated text. +- **The start-directory-versus-project rank was deleted, not caveated.** With no same-kind election it + is never consulted, so the undocumented rank never has to be invented. +- **Pattern subsumption is a known false-positive class with a known direction**, not an + undecidability. The page documents that a broad deny beats a narrower allow; this merge compares + exact text, so it over-reports allow and never over-reports blocking. Stated that way in the caveat. +- **Two invisible sources bound every effective claim**: server-managed settings (already stated by + Phase 1) and the command-line scope, which ranks above local/project/user and has no file. The + merge prints the second on every run. +- **An empty merge cannot mean an empty machine.** A pipeline swallowing the reader's `exit 2` would + report "nothing in effect" on a machine full of rules — the same family as Phase 1's three defects. + Zero scope records on input is `exit 2` before any merge line is emitted. +- **`/permissions` is prior art the skill was overclaiming against.** The permissions page states it + "lists all permission rules and the `settings.json` file each rule comes from". `SKILL.md`'s Purpose + now names it, points readers to it for "where is this rule written", and claims only the + difference: outcome resolution, read-vs-empty distinction, and a consumable output. Acceptance criterion 1. @@ -876,6 +913,12 @@ Remaining genuinely open, carried into implementation: **main checkout**. In a worktree they therefore look for the local file where it is not. Phase 1's new reader must resolve the main checkout; retrofitting the two existing detectors is a real but separate behavior change, deliberately not folded into Phase 9's approved scope. +- **`check-skill.sh` warns `stale fresh-eyes-exempt directive` on `audit-permission-state`, and the + directive is right.** Verified 2026-08-11: the check's own judgment-language regex matches nothing + in the file, at HEAD or after Phase 2, so the warning is pre-existing and the heuristic list is the + gap — which is what the warning text itself says to check. The declaration is honest (the skill + interprets the consumer's configuration, never its own output) and stays. Fixing the heuristic is a + `skill-quality` change, not a `claude-config` one; Phase 8 decides whether to raise it there. ## Handoff to implementation diff --git a/docs/topics/permission-model/phase0-fresh-docs.md b/docs/topics/permission-model/phase0-fresh-docs.md index 81ea14b192..764665cc4d 100644 --- a/docs/topics/permission-model/phase0-fresh-docs.md +++ b/docs/topics/permission-model/phase0-fresh-docs.md @@ -119,6 +119,42 @@ Consequences carried into the plan: managed source with no local path, so "the deployed managed policy" always means the local surfaces. A report that omits this implies a completeness it cannot have. +## Addendum — 2026-08-11, the merge semantics Phase 2 rests on + +Phase 2 claims an *effective* permission set. Nothing in the table above says how rules from two +scopes combine, so the two governing sections were re-fetched before any merge was written +( §How scopes interact and + §Settings precedence). Verbatim: + +| Fact | Wording | +|---|---| +| Permission rules **merge**, they do not override | "For example, if your user settings set `spinnerTipsEnabled` to `true` and project settings set it to `false`, the project value applies. Permission rules behave differently because they merge across scopes rather than override, and a few security-sensitive settings honor a restrictive value from certain scopes that otherwise couldn't override them." | +| Managed permission rules cannot be overridden | "Permission rules follow the same settings precedence as all other Claude Code settings, with managed settings highest: no other level, including command line arguments, can override a managed permission rule." | +| Deny wins from **any** scope, in both directions | "If a tool is denied at any level, no other level can allow it… The same holds across settings scopes: if user settings allow a permission and project settings deny it, the deny rule blocks it. The reverse is also true: a user-level deny blocks a project-level allow, because deny rules from any scope are evaluated before allow rules." | +| A broad deny beats a narrower allow | "A broad deny rule like `Bash(aws *)` blocks every matching call, including calls that also match a narrower allow rule like `Bash(aws s3 ls)`, so a deny rule can't carry allowlist exceptions. The same precedence applies between ask and allow." | +| The command-line scope is a real scope | "**Command line arguments**: temporary session overrides" — ranked second, above local, project and user | +| `/permissions` already shows rules and their source file | "You can view and manage Claude Code's tool permissions with `/permissions`. This UI lists all permission rules and the `settings.json` file each rule comes from." | + +Consequences carried into the plan: + +- **There is no same-kind winner to elect.** Because rules merge rather than override, a rule text + present in the same list at two scopes has both entries in effect; naming one as *the* origin would + be a precedence claim no page supports. Provenance for that case is the full contributor set. A + winner exists only **across kinds**, and the mechanic that elects it is evaluation order, which the + wording above makes explicitly scope-independent in both directions. +- **The start-directory copy never needs ranking against project settings.** That rank is undocumented, + and with no same-kind election it is never consulted. +- **Pattern subsumption is a known false-positive class, not an unknown.** A merge over exact rule text + reports `Bash(aws s3 ls)` as an effective allow even where `Bash(aws *)` is denied, because the page + documents that the broad deny wins. The direction is known — over-reporting allow — so the caveat + states it rather than pleading undecidability. +- **The command-line scope is invisible to any file reader**, so an effective-set claim is bounded to + what the settings files define. This is a second invisible source alongside server-managed settings. +- **`/permissions` is prior art and the skill must stop overclaiming.** It lists rules with their + source file interactively. It does not resolve deny-over-allow across scopes, does not distinguish a + scope that was empty from one that could not be read, and is not scriptable. The skill's framing is + narrowed to that difference rather than claiming there is no way to see rules at all. + ## Version constants cleared for use `v2.1.75`, `v2.1.193`, `v2.1.198`, `v2.1.200`, `v2.1.203`, `v2.1.207`, `v2.1.208`, `v2.1.211`, diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index 99c946dcac..6f4ea529ba 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -29,14 +29,26 @@ All notable changes to the `claude-config` plugin are documented here. Format fo ### Added - **`audit-permission-state`** — a new skill reporting which permission rules are actually in effect - and where each comes from. Claude Code ships no `claude permissions` subcommand and no - machine-readable export, so the honest answer had been "read five files in five places and hope you - know all five". The reader discovers managed policy, user-global, project, local, and any + and where each comes from. `/permissions` lists your rules and the file each came from, but it does + not resolve which of two conflicting rules wins, cannot distinguish a scope that was empty from one + it could not read, and exists only inside a live session — there is no `claude permissions` + subcommand and no machine-readable export. The reader discovers managed policy, user-global, + project, local, and any pre-v2.1.211 start-directory copy, and inventories each scope's `allow`/`ask`/`deny` rules with its source named. Every scope and every managed surface emits a record on every OS, so a surface that was never attempted can never be mistaken for one that is genuinely empty: `absent` means looked and found nothing, `skipped` means could not look. Server-managed settings are disclosed as having no local path rather than assumed absent. Report-only, and managed policy is read-only by construction. + A second pass merges those scopes into the set actually in force, each rule naming every scope that + contributes it and the documented mechanic that put it there. Permission rules merge across scopes + rather than override, so a rule written at two scopes has no winner and is never reported as one; + what a rule can lose is its kind, because deny is evaluated before ask and ask before allow from any + scope in either direction — a user-level deny blocks a project-level allow just as the reverse. The + beaten entry is reported as inert alongside the rule that beat it, which is the answer to "why is my + allow rule ignored". Every run states the two bounds on the claim: the command-line scope + (`--settings`, `--allowedTools`, `--disallowedTools`) outranks the files and has none to read, and + rules are compared by exact text, so a narrow allow blocked only by a broader deny pattern is still + reported effective — the error direction is over-reporting allow, never over-reporting blocking. - **`lib/permission-patterns.sh`** — the auto-mode drop vocabulary (blanket, wildcarded-interpreter, package-manager-runner, and script-glob rule shapes, plus the top-level tool-token grammar) as a define-only library. It was inline in the P1 detector, which self-executes and cannot be sourced, diff --git a/plugins/claude-config/skills/audit-permission-state/SKILL.md b/plugins/claude-config/skills/audit-permission-state/SKILL.md index 0648dca0c9..9987454e81 100644 --- a/plugins/claude-config/skills/audit-permission-state/SKILL.md +++ b/plugins/claude-config/skills/audit-permission-state/SKILL.md @@ -10,10 +10,12 @@ metadata: ## Purpose -Claude Code gives you no way to see the permission rules actually in effect. There is no -`claude permissions` subcommand and no documented machine-readable export, so the honest answer to -"where is this rule coming from" has been "read five files in five places and hope you know all -five". This skill computes that locally. +`/permissions` lists your rules and the settings file each one came from, and for "where is this rule +written" that is the answer — use it. What it does not do is resolve the outcome: it will show you an +allow and a deny for the same tool without saying which wins, it cannot tell a scope that was empty +from one it could not read, there is no `claude permissions` subcommand or machine-readable export, +and none of it exists outside a live session. This skill computes that locally, in a form another +tool can consume. It answers a question the siblings do not. `audit-permission-grants` asks whether the grants you **wrote** are durable and portable; `audit` asks whether your config files are **correct**. This @@ -63,6 +65,39 @@ NOTE: anything the operator must know | `status` | `present`, `absent`, `unreadable`, `invalid-json`, `skipped`, `not-applicable` | | `kind` | `allow`, `ask`, `deny` | +## Phase 2: Merge into the effective set + +Pipe the inventory through the merge to get what is actually in force, each rule carrying its +provenance: + +```shell +bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-state/scripts/permission-state.sh" | + bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-state/scripts/permission-merge.sh" +``` + +It passes the records above through, then appends: + +```text +CAVEAT: what bounds the claim +effective scopes= precedence_basis= one per live rule +inert scopes= outranked_by= one per beaten entry +``` + +Two mechanics decide those records, and conflating them produces confident wrong answers: + +- **Rules merge across scopes rather than override**, so the same rule in the same list at two scopes + has no winner — both are live, and `scopes=` names every contributor. Never report one of them as + having overridden the other. +- **Kind is decided by evaluation order — deny, then ask, then allow — from any scope, in both + directions.** A user-level deny blocks a project-level allow just as a project-level deny blocks a + user-level allow. Scope rank does not enter into it. This is what answers "why is my allow rule + ignored": the `inert` record names the rule that beat it. + +`reference/criteria.md` maps every `precedence_basis` token to the sentence it follows from, and +states the two standing bounds the run prints — the command-line scope has no file to read, and rules +are compared by exact text, so a narrow allow blocked only by a broader deny **pattern** is still +reported effective. The error direction is over-reporting allow. + ## Reading the output honestly @@ -140,6 +175,10 @@ Observed failures, each of which produced a confidently wrong answer before it w looks where the file is not and reports `absent`. Three documented exceptions keep it in the start directory — outside a git repository, when the repository root is the home directory, and in Agent SDK sessions. The reader detects the first two and states that it cannot detect the third. +- **An empty merge is not an empty machine.** Piping a reader that died into the merge would have + produced a clean "nothing in effect" on a machine full of rules. The merge now exits 2 when the + input carries no scope records at all; if you build your own pipeline around these scripts, check + the status rather than the output. - **Two live copies of `settings.local.json` are normal, not a bug.** When a pre-v2.1.211 copy sits in the start directory, the repository-root copy wins on a shared key but permission rules from both stay in effect. Reporting only one of them under-reports what is live. diff --git a/plugins/claude-config/skills/audit-permission-state/reference/criteria.md b/plugins/claude-config/skills/audit-permission-state/reference/criteria.md new file mode 100644 index 0000000000..d821114429 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/reference/criteria.md @@ -0,0 +1,80 @@ +# Effective-Permission Merge Criteria + +Version: 1.0.0 +Last updated: 2026-08-11 + +This file defines what `permission-merge.sh` may claim and on which documented mechanic each claim +rests. It exists because an *effective* permission set is a precedence claim, and a precedence claim +with no cited mechanic is folklore. The reader's own record contract lives in `SKILL.md`; the +per-check grant vocabulary lives in the sibling `audit-permission-grants` — neither is restated here. + +Sources, both fetched 2026-08-11: §How scopes interact and + §Manage permissions and §Settings precedence. + +--- + +## The one thing that is not a contest + +> "Permission rules behave differently because they merge across scopes rather than override." + +Every scope's rules are in effect at once. A rule text present in the **same list** at several scopes +therefore has no winner and no loser — the entries are all live and identical in outcome. Naming one +of them "the" origin would assert an override the documentation explicitly denies, so provenance for +that case is the whole contributor set. + +`scopes=` lists contributors in the order the reader emitted them. That order is presentation only, +never a ranking. + +## The one thing that is + +> "Rules are evaluated in order: deny, then ask, then allow. The first match in that order determines +> the outcome, and rule specificity doesn't change the order." + +> "If a tool is denied at any level, no other level can allow it… The same holds across settings +> scopes: if user settings allow a permission and project settings deny it, the deny rule blocks it. +> The reverse is also true: a user-level deny blocks a project-level allow, because deny rules from +> any scope are evaluated before allow rules." + +The winner is decided by **kind**, and the mechanic is scope-independent in both directions. An +implementation that ranked scopes here would get the second sentence exactly backwards: `user` is the +lowest scope and its deny still wins. + +## `precedence_basis` vocabulary + +Every `effective` record carries exactly one token. A record without one is a defect. + +| Token | Emitted when | Mechanic it cites | +| --- | --- | --- | +| `uncontested` | the rule text appears once, in one kind, at one scope | none needed — nothing contests it | +| `merged-across-scopes` | one kind, two or more scopes | rules merge across scopes rather than override | +| `evaluation-order` | two or more kinds for the same text | deny, then ask, then allow; first match wins, from any scope | +| `evaluation-order+merged-across-scopes` | both of the above | both, in that order | + +An `inert` record is an entry whose kind lost. It carries `outranked_by=` and deliberately +carries no basis — it is not part of the effective set, and a basis on it would read as a claim about +what is in force. + +## Bounds every run states + +Neither is a limitation to apologise for; both change what a finding means. + +- **The command-line scope has no file.** `--settings`, `--allowedTools` and `--disallowedTools` rank + above local, project and user settings, and no file reader can see them. The merge is the effective + set the settings **files** define. +- **Rules are compared by exact text, and the error direction is known.** "A broad deny rule like + `Bash(aws *)` blocks every matching call, including calls that also match a narrower allow rule like + `Bash(aws s3 ls)`." This merge does not evaluate pattern subsumption, so a narrow allow that a + broader deny blocks is still reported effective. It over-reports allow; it never over-reports + blocking. +- **A surface that could not be read bounds the result.** `skipped`, `unreadable` and `invalid-json` + each raise a caveat naming the surface. `absent` and `not-applicable` raise none — the reader looked + and there was nothing, which is a complete answer. + +## Managed policy, and what it does not buy + +> "no other level, including command line arguments, can override a managed permission rule." + +A managed rule cannot be removed by a lower scope. It does **not** follow that managed rules win every +contest: a deny at any scope still beats an allow at managed, because deny is evaluated first +everywhere. Conformance of managed intent against what is deployed is a separate question and belongs +to the managed-policy report, not to this merge. diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh new file mode 100644 index 0000000000..38a3e29dc9 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# permission-merge.sh — effective permission set with per-rule provenance, from +# permission-state.sh's scope records. +# +# Permission rules "merge across scopes rather than override", so every scope's +# rules are live at once and there is no same-kind winner to elect. What a rule +# text CAN lose is its kind: "deny rules from any scope are evaluated before +# allow rules", in both directions — a user deny blocks a project allow and a +# project deny blocks a user allow. That is the only election this script makes, +# and every merged rule says which mechanic put it where it is. +# +# Input: permission-state.sh records, on stdin. With no piped input the sibling +# reader is run directly, and its exit status is propagated (a reader that could +# not run must not become an empty merge). +# +# Output (merge section; the input records pass through above it unless +# --merge-only): +# CAVEAT: what bounds the claim +# effective scopes= precedence_basis= one per live rule +# inert scopes= outranked_by= one per beaten entry +# +# token uncontested | merged-across-scopes | evaluation-order +# | evaluation-order+merged-across-scopes +# +# `scopes=` lists contributors in the reader's emission order. That is NOT a +# precedence claim: the rules merge, so no contributor outranks another. +# reference/criteria.md maps every token to the sentence it follows from. +# +# Prerequisites: none beyond POSIX text tools. Invoked with no piped input it +# inherits the reader's jq requirement, and its exit 2. +# +# Usage: +# permission-state.sh | permission-merge.sh +# permission-merge.sh [--merge-only|--help] + +set -uo pipefail + +usage() { + cat <<'EOF' +permission-merge.sh — compute the effective allow/ask/deny set with provenance. + +Usage: permission-state.sh | permission-merge.sh [--merge-only] + permission-merge.sh [--merge-only|--help] + + (no arg) the input records, then the merge section + --merge-only the merge section alone + --help this message + +Records: "effective scopes= precedence_basis= ", +"inert scopes= outranked_by= ", and "CAVEAT: ". + +With --merge-only the reader's own NOTE records are dropped, including the one +stating that server-managed settings have no local path. Read both sections when +the question is what the machine's permission state actually is. + +Reads only. Exits 2 when the input carries no scope records at all. +EOF +} + +passthrough=1 +case "${1:-}" in +-h | --help) + usage + exit 0 + ;; +--merge-only) passthrough=0 ;; +"") ;; +*) + echo "ERROR: unknown argument '$1'" >&2 + exit 2 + ;; +esac + +if [[ -t 0 ]]; then + STATE_SCRIPT="${BASH_SOURCE[0]%/*}/permission-state.sh" + if [[ ! -r "$STATE_SCRIPT" ]]; then + echo "ERROR: cannot read $STATE_SCRIPT — nothing to merge" >&2 + exit 2 + fi + records="$(bash "$STATE_SCRIPT")" || exit $? +else + records="$(cat)" +fi + +# A reader that failed and a machine with no settings look identical downstream, +# and the second is a lie the first can tell. No scope records at all is an +# error, never an empty merge — and the output is held until that is known, so a +# failed run never emits a half-written merge section ahead of its own error. +merged="$(printf '%s\n' "$records" | awk -v passthrough="$passthrough" ' +function text_of(start, i, s) { + s = $start + for (i = start + 1; i <= NF; i++) s = s " " $i + return s +} + +{ if (passthrough) print } + +$1 == "rule" { + kind = $4 + scope = $2 + text = text_of(5) + if (!(text in text_seen)) { text_seen[text] = 1; text_order[++n_texts] = text } + k = text SUBSEP kind + kind_seen[k] = 1 + ks = k SUBSEP scope + if (!(ks in scope_seen)) { + scope_seen[ks] = 1 + scopes[k] = (k in scopes) ? scopes[k] "," scope : scope + n_scopes[k]++ + } + next +} + +$1 == "NOTE:" { next } + +NF >= 3 { + n_surfaces++ + status = $3 + if (status == "skipped" || status == "unreadable" || status == "invalid-json") { + unread[++n_unread] = $1 " " $2 " (" status ") " $4 + } +} + +END { + if (n_surfaces == 0) exit 2 + + print "CAVEAT: the command-line scope (--settings, --allowedTools, --disallowedTools) ranks above local, project and user settings and has no file to read. This merge is the effective set the settings FILES define." + print "CAVEAT: rules are compared by exact text. A broad deny blocks calls that also match a narrower allow, so a narrow allow shadowed only by a broader deny pattern is still reported effective here — the error direction is over-reporting allow." + for (i = 1; i <= n_unread; i++) + print "CAVEAT: " unread[i] " contributed no rules because it could not be read, not because it is empty. The merged set below is incomplete by that surface." + + order["deny"] = 1; order["ask"] = 2; order["allow"] = 3 + split("deny ask allow", kinds, " ") + + for (t = 1; t <= n_texts; t++) { + text = text_order[t] + win = "" + n_kinds = 0 + for (i = 1; i <= 3; i++) { + if ((text SUBSEP kinds[i]) in kind_seen) { + n_kinds++ + if (win == "") win = kinds[i] + } + } + wk = text SUBSEP win + basis = "" + if (n_kinds > 1) basis = "evaluation-order" + if (n_scopes[wk] > 1) basis = (basis == "") ? "merged-across-scopes" : basis "+merged-across-scopes" + if (basis == "") basis = "uncontested" + print "effective " win " scopes=" scopes[wk] " precedence_basis=" basis " " text + for (i = 1; i <= 3; i++) { + if (kinds[i] == win) continue + k = text SUBSEP kinds[i] + if (k in kind_seen) print "inert " kinds[i] " scopes=" scopes[k] " outranked_by=" win " " text + } + } +} +')" || { + echo "ERROR: no scope records on input — permission-merge.sh will not report an effective set it never read" >&2 + exit 2 +} + +printf '%s\n' "$merged" diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh new file mode 100644 index 0000000000..1273dfc066 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# Regression tests for permission-merge.sh (self-contained — ships with the plugin). +# +# Most cases feed hand-written scope records on stdin, so they exercise the merge +# without touching any settings file anywhere. The one end-to-end case pipes the +# real reader through the merge against a fully fixtured tree — project root, +# start directory, managed policy and user home all inside a temp directory, with +# CLAUDE_CONFIG_DIR unset. No test reads the operator's real ~/.claude. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/permission-merge.sh" +STATE_SCRIPT="$SCRIPT_DIR/permission-state.sh" + +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +FAILED=0 +CASE_NUM=0 +pass() { + CASE_NUM=$((CASE_NUM + 1)) + printf 'PASS: %s\n' "$1" +} +fail() { + CASE_NUM=$((CASE_NUM + 1)) + FAILED=$((FAILED + 1)) + printf 'FAIL: %s\n detail: %s\n' "$1" "$2" >&2 +} +assert_eq() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected: $2, actual: $3"; fi +} +assert_exit() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected exit $2, got $3"; fi +} +assert_contains() { + case "$2" in + *"$3"*) pass "$1" ;; + *) fail "$1" "expected to contain: $3" ;; + esac +} +assert_not_contains() { + case "$2" in + *"$3"*) fail "$1" "unexpected substring: $3" ;; + *) pass "$1" ;; + esac +} +count_matching() { printf '%s\n' "$1" | grep -cE "$2"; } + +merge() { printf '%s\n' "$1" | bash "$SCRIPT" --merge-only; } + +# --- Case 1: a rule in two KINDS has exactly one winner ----------------------- +# The only election this script makes. "if user settings allow a permission and +# project settings deny it, the deny rule blocks it." +CROSS_KIND=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +project settings present /proj/.claude/settings.json +rule user settings allow Bash(git status) +rule project settings deny Bash(git status) +EOF +) +OUT=$(merge "$CROSS_KIND") +assert_eq "cross-kind contest yields exactly one effective record" 1 "$(count_matching "$OUT" '^effective .*Bash\(git status\)$')" +assert_contains "the deny wins" "$OUT" "effective deny scopes=project precedence_basis=evaluation-order Bash(git status)" +assert_contains "the beaten allow is reported inert" "$OUT" "inert allow scopes=user outranked_by=deny Bash(git status)" + +# The reverse direction is documented just as explicitly and is the one a +# scope-ranked implementation would get wrong: user is the LOWEST scope, and its +# deny still blocks a project allow. +REVERSE=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +project settings present /proj/.claude/settings.json +rule user settings deny Bash(git push) +rule project settings allow Bash(git push) +EOF +) +OUT=$(merge "$REVERSE") +assert_contains "a user-level deny blocks a project-level allow" "$OUT" "effective deny scopes=user precedence_basis=evaluation-order Bash(git push)" +assert_contains "the project allow is inert, not the winner" "$OUT" "inert allow scopes=project outranked_by=deny Bash(git push)" + +# ask beats allow by the same mechanic. +ASK=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +project settings present /proj/.claude/settings.json +rule user settings allow WebFetch +rule project settings ask WebFetch +EOF +) +OUT=$(merge "$ASK") +assert_contains "ask outranks allow" "$OUT" "effective ask scopes=project precedence_basis=evaluation-order WebFetch" +assert_contains "the allow beneath an ask is inert" "$OUT" "inert allow scopes=user outranked_by=ask WebFetch" + +# --- Case 2: the same rule in the same KIND at two scopes elects NOTHING ------ +# "Permission rules behave differently because they merge across scopes rather +# than override." Naming one scope the origin here would be a precedence claim +# no documented mechanic supports, so both contributors are carried instead. +SAME_KIND=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +user settings present /home/.claude/settings.json +project settings present /proj/.claude/settings.json +rule managed file deny Read(./.env) +rule user settings deny Read(./.env) +rule project settings deny Read(./.env) +EOF +) +OUT=$(merge "$SAME_KIND") +assert_eq "same-kind duplication yields one effective record" 1 "$(count_matching "$OUT" '^effective .*Read')" +assert_contains "every contributing scope is named" "$OUT" "effective deny scopes=managed,user,project precedence_basis=merged-across-scopes Read(./.env)" +assert_not_contains "nothing is reported as beaten when nothing lost" "$OUT" "inert" + +# Both mechanics at once stay both, rather than one silently swallowing the other. +BOTH=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +project settings present /proj/.claude/settings.json +rule user settings deny Bash(curl *) +rule project settings deny Bash(curl *) +rule project settings allow Bash(curl *) +EOF +) +OUT=$(merge "$BOTH") +assert_contains "both mechanics are cited when both applied" "$OUT" "precedence_basis=evaluation-order+merged-across-scopes" + +# --- Case 3: no merged rule ships without a basis ----------------------------- +MIXED=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +user settings present /home/.claude/settings.json +project settings present /proj/.claude/settings.json +rule managed file deny Read(./.env) +rule user settings allow Bash(git status) +rule project settings deny Bash(git status) +rule user settings allow Bash(npm test) +rule project settings allow Bash(npm test) +rule project settings ask Bash(rm *) +EOF +) +OUT=$(merge "$MIXED") +assert_eq "every effective record carries a precedence_basis" \ + "$(count_matching "$OUT" '^effective ')" "$(count_matching "$OUT" '^effective .* precedence_basis=')" +assert_eq "four distinct rule texts, four effective records" 4 "$(count_matching "$OUT" '^effective ')" +assert_not_contains "inert records never carry a basis they did not earn" \ + "$(printf '%s\n' "$OUT" | grep '^inert ')" "precedence_basis" + +# --- Case 4: two managed surfaces are one contributing scope ------------------ +# The drop-in files and the base file are separate surfaces at the same scope; +# listing "managed" twice would read as two independent sources agreeing. +DUP_SURFACE=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +managed dropin-file:10-first.json present /policy/managed-settings.d/10-first.json +rule managed file deny Read(./.env) +rule managed dropin-file:10-first.json deny Read(./.env) +EOF +) +OUT=$(merge "$DUP_SURFACE") +assert_contains "one scope, however many of its surfaces carry the rule" "$OUT" "scopes=managed precedence_basis=uncontested Read(./.env)" + +# --- Case 5: rule text containing spaces survives intact ---------------------- +SPACED=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +rule user settings allow Bash(git commit -m *) +EOF +) +OUT=$(merge "$SPACED") +assert_contains "spaces inside a rule are preserved" "$OUT" "precedence_basis=uncontested Bash(git commit -m *)" + +# --- Case 6: unread surfaces bound the claim; empty ones do not --------------- +STATUSES=$( + cat <<'EOF' +managed registry skipped - +managed plist not-applicable - +user settings absent /home/.claude/settings.json +project settings invalid-json /proj/.claude/settings.json +local settings unreadable /proj/.claude/settings.local.json +startdir-local settings not-applicable /start/.claude/settings.local.json +EOF +) +OUT=$(merge "$STATUSES") +assert_eq "one caveat per surface that could not be read" 3 "$(count_matching "$OUT" '^CAVEAT: .*could not be read')" +assert_contains "a skipped surface is named" "$OUT" "managed registry (skipped)" +assert_contains "invalid JSON is not an empty scope" "$OUT" "project settings (invalid-json)" +assert_not_contains "an absent scope raises no caveat" "$OUT" "user settings (absent)" +assert_not_contains "a not-applicable scope raises no caveat" "$OUT" "not-applicable" + +# --- Case 7: the two standing bounds are always stated ------------------------ +OUT=$(merge "$MIXED") +assert_contains "the invisible command-line scope is stated" "$OUT" "CAVEAT: the command-line scope" +assert_contains "the exact-text limitation states its error direction" "$OUT" "over-reporting allow" + +# --- Case 8: no scope records is an error, never an empty merge --------------- +# A reader that died and a machine with no settings must not look the same. +rc=0 +err_out=$(printf '' | bash "$SCRIPT" --merge-only 2>&1) || rc=$? +assert_exit "exit 2 on empty input" 2 "$rc" +assert_contains "the empty-input error says why" "$err_out" "no scope records on input" + +rc=0 +printf 'NOTE: a note and nothing else\n' | bash "$SCRIPT" >/dev/null 2>&1 || rc=$? +assert_exit "notes alone are not scope records" 2 "$rc" + +rc=0 +err_out=$(bash "$SCRIPT" --bogus &1) || rc=$? +assert_exit "exit 2 on an unknown argument" 2 "$rc" +assert_contains "the unknown argument is named" "$err_out" "unknown argument" + +rc=0 +help_out=$(bash "$SCRIPT" --help &1) || rc=$? +assert_exit "--help exits 0" 0 "$rc" +assert_contains "--help documents the merge-only mode" "$help_out" "--merge-only" + +# --- Case 9: pass-through is the default, and is suppressible ----------------- +PASS_IN=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +NOTE: something the operator must know +rule user settings allow Bash(ls) +EOF +) +OUT=$(printf '%s\n' "$PASS_IN" | bash "$SCRIPT") +assert_contains "the reader's surface records pass through" "$OUT" "user settings present" +assert_contains "the reader's notes pass through" "$OUT" "NOTE: something the operator must know" +assert_contains "the merge section follows them" "$OUT" "effective allow scopes=user" +OUT=$(merge "$PASS_IN") +assert_not_contains "--merge-only drops the input records" "$OUT" "NOTE: something the operator" + +# --- Case 10: end to end, real reader into the merge -------------------------- +if command -v jq >/dev/null 2>&1; then + FX="$TEST_TMPDIR/fx" + mkdir -p "$FX/proj/.claude" "$FX/home/.claude" "$FX/policy/managed-settings.d" "$FX/startdir/.claude" + jq -n '{permissions:{allow:["Bash(git status)"],deny:["WebFetch"]}}' >"$FX/proj/.claude/settings.json" + jq -n '{permissions:{allow:["Bash(npm test)"]}}' >"$FX/proj/.claude/settings.local.json" + jq -n '{permissions:{allow:["Bash(npm test)","WebFetch"]}}' >"$FX/home/.claude/settings.json" + jq -n '{permissions:{deny:["Read(./.env)"]}}' >"$FX/policy/managed-settings.json" + jq -n '{permissions:{allow:["Bash(ls)"]}}' >"$FX/startdir/.claude/settings.local.json" + + E2E=$(env -u CLAUDE_CONFIG_DIR \ + HOME="$FX/home" \ + PERMISSION_STATE_FIXTURE_DIR="$FX/proj" \ + PERMISSION_STATE_STARTDIR="$FX/startdir" \ + PERMISSION_STATE_MANAGED_PATH="$FX/policy/managed-settings.json" \ + PERMISSION_STATE_REGISTRY_KEYS="" \ + PERMISSION_STATE_PLIST_DOMAIN="" \ + bash "$STATE_SCRIPT" | bash "$SCRIPT" --merge-only) + + assert_contains "the project deny beats both allows of the same tool" "$E2E" "effective deny scopes=project precedence_basis=evaluation-order WebFetch" + assert_contains "the user allow of that tool is inert" "$E2E" "inert allow scopes=user outranked_by=deny WebFetch" + assert_contains "a rule at user and local scope merges without an election" "$E2E" "effective allow scopes=user,local precedence_basis=merged-across-scopes Bash(npm test)" + assert_contains "the start-directory copy contributes its own rules" "$E2E" "scopes=startdir-local precedence_basis=uncontested Bash(ls)" + assert_contains "managed policy contributes" "$E2E" "scopes=managed precedence_basis=uncontested Read(./.env)" + assert_eq "every end-to-end effective record carries a basis" \ + "$(count_matching "$E2E" '^effective ')" "$(count_matching "$E2E" 'precedence_basis=')" +else + pass "end-to-end reader merge (skipped — jq not installed)" +fi + +if [[ "$FAILED" -eq 0 ]]; then + printf '\nAll %d checks passed.\n' "$CASE_NUM" + exit 0 +fi +printf '\n%d/%d checks failed.\n' "$FAILED" "$CASE_NUM" >&2 +exit 1 From 837d503ecc141dd3dce5a15abda145370b0de834 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:56:41 -0400 Subject: [PATCH 09/14] fix(claude-config): stop reporting rules for a tool that whole-tool deny removed Exact-text comparison treated `deny: ["Bash"]` and `allow: ["Bash(git status)"]` as two unrelated rules and reported both as effective. The permissions page says the bare name "removes the tool from Claude's context entirely, so Claude never sees it" -- so the report claimed prompt-free access to a tool the model cannot see, in managed policy, which is exactly where whole-tool denies get written. Not a case for the exact-text caveat. That caveat covers pattern subsumption, which needs a matcher; the tool token is the text before the first `(` and "contains no `(`" is the whole test, so this was decidable and therefore a defect. A whole-tool deny now makes every rule naming that tool inert -- other denies included, moot rather than weakened -- and a whole-tool ask outranks every scoped allow for its tool, since it matches every call and ask is evaluated first. `EndConversation` is exempt from removal per its documented carve-out. Both cases print a NOTE naming the tool rather than leaving a reader to infer it from a run of inert records. Found by fresh-context review after the merge had already passed 40 checks and its own author had signed off. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/topics/permission-model/PLAN.md | 7 +++ plugins/claude-config/CHANGELOG.md | 5 +- .../skills/audit-permission-state/SKILL.md | 5 ++ .../reference/criteria.md | 31 ++++++++++-- .../scripts/permission-merge.sh | 47 +++++++++++++++++- .../scripts/permission-merge.test.sh | 49 +++++++++++++++++++ 6 files changed, 139 insertions(+), 5 deletions(-) diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index a066bf0309..78fd57fa3f 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -451,6 +451,13 @@ re-fetch constraint, and they say permission rules *"merge across scopes rather `precedence_basis` grep, so it cannot pass on unrelated text. - **The start-directory-versus-project rank was deleted, not caveated.** With no same-kind election it is never consulted, so the undocumented rank never has to be invented. +- **Whole-tool rules are computed, not caveated.** Caught by fresh-context review after the first + merge shipped: with exact-text comparison alone, managed `deny: ["Bash"]` and user + `allow: ["Bash(git status)"]` were two unrelated groups and BOTH were reported effective, while the + page says the bare name "removes the tool from Claude's context entirely". The tool token is the + text before the first `(` and needs no matcher, so this was a defect, not a bound. A whole-tool deny + now makes every rule for that tool inert (`removed_by=deny@`), a whole-tool ask outranks every + scoped allow for it, and `EndConversation` is exempt per its documented carve-out. - **Pattern subsumption is a known false-positive class with a known direction**, not an undecidability. The page documents that a broad deny beats a narrower allow; this merge compares exact text, so it over-reports allow and never over-reports blocking. Stated that way in the caveat. diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index 6f4ea529ba..a2ede41651 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -45,7 +45,10 @@ All notable changes to the `claude-config` plugin are documented here. Format fo what a rule can lose is its kind, because deny is evaluated before ask and ask before allow from any scope in either direction — a user-level deny blocks a project-level allow just as the reverse. The beaten entry is reported as inert alongside the rule that beat it, which is the answer to "why is my - allow rule ignored". Every run states the two bounds on the claim: the command-line scope + allow rule ignored". A rule that is a bare tool name reaches every call of that tool: a whole-tool + deny removes the tool from context entirely, so every other rule naming it is inert — including + other denies, which are moot rather than weakened — and a whole-tool ask prompts for every call, so + no scoped allow for that tool applies. `EndConversation` is exempt from removal, as documented. Every run states the two bounds on the claim: the command-line scope (`--settings`, `--allowedTools`, `--disallowedTools`) outranks the files and has none to read, and rules are compared by exact text, so a narrow allow blocked only by a broader deny pattern is still reported effective — the error direction is over-reporting allow, never over-reporting blocking. diff --git a/plugins/claude-config/skills/audit-permission-state/SKILL.md b/plugins/claude-config/skills/audit-permission-state/SKILL.md index 9987454e81..11e4a7238f 100644 --- a/plugins/claude-config/skills/audit-permission-state/SKILL.md +++ b/plugins/claude-config/skills/audit-permission-state/SKILL.md @@ -92,6 +92,11 @@ Two mechanics decide those records, and conflating them produces confident wrong directions.** A user-level deny blocks a project-level allow just as a project-level deny blocks a user-level allow. Scope rank does not enter into it. This is what answers "why is my allow rule ignored": the `inert` record names the rule that beat it. +- **A rule that is a bare tool name reaches every call of that tool.** A whole-tool deny removes the + tool from context entirely, so every other rule naming it is inert — including other denies, which + are moot rather than weakened; `EndConversation` is the documented exception. A whole-tool ask + prompts for every call, so no scoped allow for that tool applies. Both print a `NOTE:` naming the + tool. `reference/criteria.md` maps every `precedence_basis` token to the sentence it follows from, and states the two standing bounds the run prints — the command-line scope has no file to read, and rules diff --git a/plugins/claude-config/skills/audit-permission-state/reference/criteria.md b/plugins/claude-config/skills/audit-permission-state/reference/criteria.md index d821114429..464939acaa 100644 --- a/plugins/claude-config/skills/audit-permission-state/reference/criteria.md +++ b/plugins/claude-config/skills/audit-permission-state/reference/criteria.md @@ -50,9 +50,34 @@ Every `effective` record carries exactly one token. A record without one is a de | `evaluation-order` | two or more kinds for the same text | deny, then ask, then allow; first match wins, from any scope | | `evaluation-order+merged-across-scopes` | both of the above | both, in that order | -An `inert` record is an entry whose kind lost. It carries `outranked_by=` and deliberately -carries no basis — it is not part of the effective set, and a basis on it would read as a claim about -what is in force. +An `inert` record is an entry that is not in force. It deliberately carries no basis — a basis on it +would read as a claim about what is in force — and instead names what displaced it: + +| Field | Meaning | +| --- | --- | +| `outranked_by=` | the same rule text exists in a kind that is evaluated earlier | +| `removed_by=deny@` | a whole-tool deny took the tool out of the model context, so this rule has nothing to act on | +| `outranked_by=ask@` | a whole-tool ask prompts for every call of that tool, so this scoped allow never applies | + +## Whole-tool rules + +> "A bare tool name like `Bash` removes the tool from Claude's context entirely, so Claude never sees +> it… A scoped rule like `Bash(rm *)` leaves the tool available and blocks matching calls when Claude +> attempts them." + +The tool token is the text before the first `(`; a rule that **is** its own token names the whole +tool. That test needs no pattern matcher, so it is computed rather than caveated. + +- **A whole-tool deny makes every other rule for that tool inert**, whatever its kind. An inert deny + is moot, not weakened — the tool is gone, so a second deny has nothing left to block. Reporting a + scoped allow as effective underneath one would claim access to a tool that is not in context. +- **`EndConversation` is the documented exception**: "a deny rule can't remove it while any other tool + remains, and an ask rule never prompts for it." It is exempt from removal here. +- **A whole-tool ask outranks every scoped allow for that tool**, because it matches every call and + ask is evaluated before allow. + +Both cases print a `NOTE:` naming the tool, so the removal is announced rather than inferred from a +run of `inert` records. ## Bounds every run states diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh index 38a3e29dc9..c228ab8e7b 100644 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh @@ -93,6 +93,12 @@ function text_of(start, i, s) { return s } +# The tool token is everything before the first "(" — "Bash(rm *)" is a rule +# about Bash. A rule that IS its bare tool token is the whole-tool form, and +# whole-tool rules reach every call of that tool, which is decidable here with +# no pattern matcher. +function tool_of(t, p) { p = index(t, "("); return p ? substr(t, 1, p - 1) : t } + { if (passthrough) print } $1 == "rule" { @@ -100,6 +106,24 @@ $1 == "rule" { scope = $2 text = text_of(5) if (!(text in text_seen)) { text_seen[text] = 1; text_order[++n_texts] = text } + tool[text] = tool_of(text) + if (text == tool[text]) { + # A bare tool name removes the tool from the model context entirely, so the + # model never sees it. Every other rule naming that tool is then moot, + # whatever its kind. EndConversation is the documented exception: a deny + # rule cannot remove it while any other tool remains. + if (kind == "deny" && text != "EndConversation" && !(text in bare_deny)) { + bare_deny[text] = 1 + bare_order[++n_bare] = "deny " text + } + # A whole-tool ask prompts for every call of the tool, and a matching ask + # rule prompts even when a more specific allow rule also matches the same + # call — so scoped allows for that tool never take effect. + if (kind == "ask" && !(text in bare_ask)) { + bare_ask[text] = 1 + bare_order[++n_bare] = "ask " text + } + } k = text SUBSEP kind kind_seen[k] = 1 ks = k SUBSEP scope @@ -129,11 +153,20 @@ END { for (i = 1; i <= n_unread; i++) print "CAVEAT: " unread[i] " contributed no rules because it could not be read, not because it is empty. The merged set below is incomplete by that surface." - order["deny"] = 1; order["ask"] = 2; order["allow"] = 3 + for (i = 1; i <= n_bare; i++) { + split(bare_order[i], b, " ") + if (b[1] == "deny") + print "NOTE: deny " b[2] " names the whole tool, which removes " b[2] " from the model context entirely. Every other rule naming that tool is reported inert below — including denies, which are moot rather than weakened." + else + print "NOTE: ask " b[2] " names the whole tool, so every " b[2] " call prompts and no scoped allow for it can take effect." + } + split("deny ask allow", kinds, " ") for (t = 1; t <= n_texts; t++) { text = text_order[t] + tk = tool[text] + scoped = (text != tk) win = "" n_kinds = 0 for (i = 1; i <= 3; i++) { @@ -142,6 +175,18 @@ END { if (win == "") win = kinds[i] } } + if (scoped && (tk in bare_deny)) { + for (i = 1; i <= 3; i++) { + k = text SUBSEP kinds[i] + if (k in kind_seen) print "inert " kinds[i] " scopes=" scopes[k] " removed_by=deny@" tk " " text + } + continue + } + if (scoped && win == "allow" && (tk in bare_ask)) { + print "inert allow scopes=" scopes[text SUBSEP "allow"] " outranked_by=ask@" tk " " text + continue + } + wk = text SUBSEP win basis = "" if (n_kinds > 1) basis = "evaluation-order" diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh index 1273dfc066..7a9a428f72 100644 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh @@ -124,6 +124,55 @@ EOF OUT=$(merge "$BOTH") assert_contains "both mechanics are cited when both applied" "$OUT" "precedence_basis=evaluation-order+merged-across-scopes" +# --- Case 2b: a whole-tool rule reaches every call of that tool --------------- +# Decidable with no pattern matcher: the tool token is the text before the first +# "(", and a rule that IS its own token names the whole tool. Reporting a scoped +# allow as effective under a bare deny would claim access to a tool that is no +# longer in the model context at all. +BARE=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +user settings present /home/.claude/settings.json +rule managed file deny Bash +rule user settings allow Bash(git status) +rule user settings deny Bash(rm *) +EOF +) +OUT=$(merge "$BARE") +assert_contains "the bare deny itself is effective" "$OUT" "effective deny scopes=managed precedence_basis=uncontested Bash" +assert_contains "a scoped allow under a bare deny is inert" "$OUT" "inert allow scopes=user removed_by=deny@Bash Bash(git status)" +assert_contains "a scoped deny under a bare deny is moot too" "$OUT" "inert deny scopes=user removed_by=deny@Bash Bash(rm *)" +assert_eq "removal leaves exactly one effective record for the tool" 1 "$(count_matching "$OUT" '^effective .*Bash')" +assert_contains "removal is announced, not just implied" "$OUT" "removes Bash from the model context entirely" + +# EndConversation is the documented exception: a deny rule cannot remove it while +# any other tool remains, so its scoped rules stay live. +END_CONV=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +user settings present /home/.claude/settings.json +rule managed file deny EndConversation +rule user settings allow EndConversation(x) +EOF +) +OUT=$(merge "$END_CONV") +assert_not_contains "EndConversation is exempt from bare-name removal" "$OUT" "removed_by" +assert_contains "its scoped rule stays effective" "$OUT" "effective allow scopes=user precedence_basis=uncontested EndConversation(x)" + +# A whole-tool ask prompts for every call, so no scoped allow for that tool can +# take effect — the ask/allow half of the same mechanic. +BARE_ASK=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +project settings present /proj/.claude/settings.json +rule user settings ask WebFetch +rule project settings allow WebFetch(domain:example.com) +EOF +) +OUT=$(merge "$BARE_ASK") +assert_contains "a scoped allow under a whole-tool ask is inert" "$OUT" "inert allow scopes=project outranked_by=ask@WebFetch WebFetch(domain:example.com)" +assert_eq "and it is not also reported effective" 0 "$(count_matching "$OUT" '^effective .*WebFetch\(')" + # --- Case 3: no merged rule ships without a basis ----------------------------- MIXED=$( cat <<'EOF' From 0e158f63caf695d4ed9b854c04cb23653fc63cde Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 01:39:58 +0000 Subject: [PATCH 10/14] fix(claude-config): prune contract slice, fix CI blockers, address review threads - Remove docs/topics/permission-model contract-tier slice (gate requires graduation before merge) - Anchor Windows registry JSON extraction at the Settings type column (P1 review) - Validate permissions object shape in classify_json_file (schema-wrong JSON) - Skip duplicate user-global scan when it resolves to the project settings path - Document removed_by=/outranked_by=ask@ inert record variants in permission-merge.sh - Add portability-ok annotations for Windows registry path test fixtures Co-authored-by: Kyle Sexton --- docs/topics/permission-model/PLAN.md | 960 ------------------ .../design/design-resolution.md | 76 -- .../permission-model/phase0-fresh-docs.md | 161 --- .../claude-config/lib/managed-scope.test.sh | 8 +- .../scripts/permission-rule-check.sh | 17 +- .../scripts/permission-merge.sh | 6 +- .../scripts/permission-state.sh | 13 +- .../scripts/permission-state.test.sh | 7 +- 8 files changed, 43 insertions(+), 1205 deletions(-) delete mode 100644 docs/topics/permission-model/PLAN.md delete mode 100644 docs/topics/permission-model/design/design-resolution.md delete mode 100644 docs/topics/permission-model/phase0-fresh-docs.md diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md deleted file mode 100644 index 78fd57fa3f..0000000000 --- a/docs/topics/permission-model/PLAN.md +++ /dev/null @@ -1,960 +0,0 @@ -# permission-model - -## Brief - -### TLDR - -Add **two** new skills to the `claude-config` plugin. `audit-permission-state` computes the -**effective merged Claude Code permission state** across all five settings scopes with per-rule -provenance, plus a decidability-bounded set of lints over that state and over the `autoMode` block; -`draft-auto-mode-rules` is the authoring lane, drafting an `autoMode` block to stdout for a human to -paste. Both audit or generate and print; neither enforces and neither writes configuration. Existing -`claude-config` permission checks get in-place scope widening at the same time — **both are now named: -`audit-permission-grants` check P1 gains user-global scope, and `claude-config:audit`'s settings scan -gains the start-directory `settings.local.json` copy.** No new plugin. - -*Amended 2026-08-09: one skill → two, on the operator's admission of the authoring lane. `MIGRATION-PLAYBOOK.md` -§Naming binds a skill name to its kind, so an `audit-*` skill cannot host a `draft` action.* - -### Goal - -Close the legibility gap in Claude Code's permission plane for downstream consumers of this -marketplace. - -Research established the gap is total: there is no `claude permissions` CLI subcommand (established -by controlled live probe on 2.1.225 — the same probe form returns real usage for seven other hidden -commands and falls back to top-level usage for `permissions`), no documented machine-readable export -of resolved permission state, and across roughly thirty third-party tools nobody audits an `autoMode` -block, resolves cross-scope precedence, or validates a managed policy against the scopes beneath it. -The highest-adoption linter in the space carries one permission rule out of 447 and zero -managed-settings coverage. The official plugin marketplace ships 284 plugins and none of them manage -permission configuration. - -Demand is documented rather than assumed: measured dead allow rules written by the harness's own -"Always allow" path (77 of 154 rules in one Windows project; 6 of 9 on macOS), a session carrying 900+ -allow rules still prompting 700+ times in two days, and two production fleet incidents caused by the -managed-tier no-merge rule. - -Auto mode becomes the default permission mode for new sessions on Pro, Max, and Team plans on -2026-08-14. That does not create the gap, but it widens it: broad allow rules and every `Agent` allow -rule are silently dropped on entering auto mode, and consumers have no way to see that happen. - -### Constraints - -**Product shape** - -- Audit and legibility first. Auto-mode authoring is a second lane, not v1's core. -- **Report-only.** No enforcement in v1 and no writes to consumer configuration. An enforcing hook, if - ever built, is an explicitly separate opt-in component. Research confirms enforcement is genuinely - available to a plugin — hook `deny` and exit-2 both hold, measured — so this is a deliberate posture - choice, not a capability limit. -- The managed/enterprise tier is in scope, **read-only**. A plugin can never author managed policy: - those are admin-write OS paths or a claude.ai Owner role. - -**Packaging** - -- A new sibling skill under `claude-config`, plus in-place scope widening of two existing checks. - **Not** a new plugin. `marketplace.json`'s `renames` map is a flat plugin→plugin map and cannot - express a partial extraction, so extracting would silently strip `audit-permission-grants` from - every consumer on update. Extraction would also turn `audit-pass/SKILL.md`'s route-out section into an unguarded - cross-plugin reference, which `PLUGIN-PHILOSOPHY.md` §Organization names a defect. - -**Reading state** - -- **Compute** the merge, bounded by a per-item decidability criterion with a **stated basis**. In - scope: anything following from documented mechanics over readable inputs, each claim citing the - mechanic it follows from. Out of scope: classifier judgment, runtime demotion state, and anything - resting only on an open upstream discrepancy — each becomes a **named caveat** on the affected - finding, never a silent drop and never an assertion. -- **Shell out only for `autoMode` built-in defaults.** The changelog does not track the content of the - shipped rule lists (zero matches across all 359 releases), so version-gating them from release notes - is impossible — probe and diff, never infer from a version number. -- **Defensive contract for every CLI read, each item measured on 2.1.225, not assumed:** never a - strict JSON parser (`claude auto-mode config` emits raw control characters inside string values; - `jq` and Python's default `json.loads` both reject, exit code still 0); tolerate a **missing** key, - not merely an empty array (`defaults --label` omits `environment` entirely); split a rule label at - the first `[`, not the first `:` (`soft_deny` labels carry a bracketed annotation before the colon); - **never trust exit status** (`critique` returned 0 on all three runs including the one producing no - output at all). - -**Upstream posture** - -- **Fetch at read time.** Bake no upstream catalogs — not the default rule lists, the block/allow - catalogs, the protected-path enumeration, the version-gate table, or the footgun list. -- Every durable claim derived from upstream carries a recheck trigger naming an **observable event**, - per `docs/conventions/upstream-drift/README.md`. A bare date does not qualify in this repository. -- A record carries claim **and** basis **and** trigger. A trigger without its basis is untestable: the - invalid-JSON defect is machine-conditional (attributed to user-supplied entries spliced without - re-escaping), so "`config` parses under a strict parser" would pass on a clean machine and clear a - defect that was never present. The basis must state the probe. - -**Leverage built-ins, never re-implement their judgment** - -- `claude auto-mode critique` is surfaced and wrapped, never replaced. It owns the semantic judgment - (clarity, completeness, conflicts, actionability). We own only the mechanical layer it does not do. - Its unreliability is wrapped, not hidden: truncation and empty-output detection, with a plain - "critique returned nothing; run it yourself" surface. - -**Surface and output** - -- A skill driving deterministic scripts. No CI-gate entry point. -- Findings use the `review` plugin's `severity.md` vocabulary — the marketplace's neutral baseline. - -### Acceptance criteria - -1. Given a repository and a user home with settings at two or more scopes, the skill reports the - effective merged `permissions.allow` / `.ask` / `.deny` set with the source scope named per rule, - and each precedence claim cites the documented mechanic it follows from. -2. The skill detects **both** dead-config traps at their **two distinct gates** — `autoMode.*` ignored - in project and local settings (local also read before v2.1.207), and `defaultMode: "auto"` ignored - in project and local settings (project could set it before v2.1.142) — and reports each separately. - `useAutoModeDuringPlan`, which is not read from shared project settings, is covered as a third. -3. The skill classifies every allow rule that auto mode drops on entry: blanket `Bash(*)` / - `PowerShell(*)`, wildcarded interpreters, package-manager run commands, and **all `Agent` allow - rules**. -4. The skill detects a `$defaults` omission per `autoMode` section and states which built-in rule list - that omission discards. -5. The skill detects `disableAutoMode` typed as a boolean rather than the string `"disable"`, in any - scope (it is not managed-only). -6. The skill detects allow rules that cannot match — doubled-backslash Windows paths, unanchored allow - globs, `Write(path)`-shaped rules that are accepted but never consulted, and `:*` used anywhere but - at pattern end. -7. The skill reads a pre-v2.1.211 `settings.local.json` copy left in the start directory as well as - the repository-root copy, because permission rules from both stay in effect. -8. Every CLI read survives the measured defect set: invalid JSON, a missing key, a bracket-prefixed - label, and exit 0 on empty output. A run that produced no usable output reports that fact and never - claims success. -9. The skill performs no writes to any consumer settings file, in any scope, under any flag present in - v1. -10. Every finding whose basis is an open upstream discrepancy carries that discrepancy as a named - caveat. -11. `claude-config:audit-permission-grants` check P1 sees user-global rules. Today - `reference/criteria.md`'s settings-scan section scans project and local settings only, so a user-global - interpreter-wildcard rule is invisible to it. -12. `skill-quality:check listing-budget` is run against the resulting shape; the new skill's listing - cost is stated rather than assumed. - -### Captured assumptions - -- Every CLI behavior recorded here comes from a single capture on **Claude Code 2.1.225, Windows 11**, - 2026-08-09. The measured defects are shell-dependent (Git Bash truncated vs PowerShell empty), so - per-platform behavior is assumed similar and **not verified**. Re-probe before acting. -- The five research slices carry no `preload_token` — the `/discovery:research` preload failed - silently across the whole fan-out. Only `permissions-core` received full independent adversarial - verification (7/7 priority claims survived, 15/15 drift hashes reproduced, 3 defects found and - corrected). The other four are cross-checked against each other but not separately verified. -- Roughly fifteen GitHub issues in the ecosystem sweep were title-verified only, bodies not read. -- Reddit was unreachable during the sweep; no absence claim is possible from that venue. -- #83766 and #42797 report `permissions.ask` patterns auto-approved under `defaultMode: auto`, which - contradicts current documented behavior. Treated as **open discrepancies, not settled facts**. - -### Out-of-scope - -- Enforcement of any kind in v1, including the PreToolUse `"ask"` lever that is documented to bind the - classifier in auto mode. -- Writing or generating consumer configuration, including a `--fix` mode. -- Authoring managed policy. Auditing it is in scope; authoring it is structurally impossible for a - plugin. -- Re-implementing the semantic judgment `claude auto-mode critique` performs. -- A new plugin, and the extraction of `audit-permission-grants` out of `claude-config`. Re-opens only - on the trigger recorded under Q5 below. -- Skill naming, which still needs deriving against `MIGRATION-PLAYBOOK.md` §Naming. One live - constraint: `PLUGIN-PHILOSOPHY.md` §Naming requires the namespace noun to be true of every skill under - it, and the locked authoring lane is a mutating verb — `claude-config` survives that test, a - narrower noun would not. - -### Deferred questions - -- **Q11 — RESOLVED 2026-08-09, no longer deferred.** A marketplace-installed plugin's skill - `allowed-tools` grant is **NOT** gated by workspace trust: it takes effect at user scope in a - never-trusted workspace, under `-p` where no trust dialog can appear. Measured on 2.1.225 — the - covered command ran, the uncovered one blocked with `This command requires approval`, and a - no-grant baseline proves that shape blocks. Method, the two stated bounds (local-directory - marketplace, user scope only), and four invalid prior attempts are recorded in - `.work/permission-model/EXPERIMENT-marketplace-allowed-tools-trust.md`. - - **Consequence for this repository, carried into the plugin-acceptance security review:** a skill - this marketplace ships can grant itself a prompt-free tool invocation in a consumer's untrusted - workspace, and the install-time plugin trust prompt is the only gate in front of that — there is no - second, per-workspace one. This does not change v1's scope, which ships no self-grant. - -- **Q12 — CLOSED 2026-08-09, shipped.** PR #2089 merged at `2026-08-09T17:31:08Z`; - `docs/OFFICIAL-DOCS.md:100` now carries - `| Configure auto mode (autoMode, claude auto-mode) | https://code.claude.com/docs/en/auto-mode-config | 2026-08-09 |`. - The USER-RESERVED gate is discharged — nothing here waits on it. - -- **Unprobed CLI surfaces — RESOLVED 2026-08-09 by `/planning:plan`'s own probe** - (`.work/permission-model/EXPERIMENT-debug-channel-merge-narration.md`). The debug channel narrates - the merge per destination with full rule text **and** narrates every auto-mode-dropped allow rule - individually with its absolute source path and reason. `claude config` does **not** exist as a - subcommand (controlled probe: `claude config --help` falls back to top-level usage; control - `claude plugin --help` returns real usage), so there was nothing to enumerate. `claude --safe-mode` - as a differential control was not needed — the drop narration is explicit rather than differential. - Consequence: the computed merge stays as the read path; the channel becomes an optional oracle whose - disagreement with the prediction is itself a finding. Bounds are in the experiment file — scopes - absent from the capture were not disproved, obtaining the narration costs a session spawn, and the - `[DEBUG]` strings carry no stability contract. - -- **Runtime prerequisite and its degradation path.** A non-strict JSON parser implies Python or Node — - a new undeclared runtime prerequisite, which `PLUGIN-PHILOSOPHY.md` §Prerequisites and failure behavior and §Cross-platform contract govern - ("never assume Bash, `jq` … is present"; never execute an undeclared tool as an incidental - fallback). The measured defects are shell-dependent, so the declaration and the degradation path are - per-platform. **Arbiter: `/planning:plan`.** - -- **Fresh-context delegation directive.** `PLUGIN-PHILOSOPHY.md` §Fresh-eyes checkpoints requires any - skill step that judges output the same context produced to state a fresh-context delegation or a - greppable exemption; `skill-quality:check` enforces it. Not yet stated for this skill. **Arbiter: - `/planning:plan`.** - -- **Two-lane security-floor posture.** `audit/reference/required-permissions.md` is a shipped lane-1 - default security floor. Bringing an enterprise audience into scope raises the odds a consuming org - holds its own floor, pushing it toward lane 2 (discover-and-externalize) per - `PLUGIN-PHILOSOPHY.md` §Two-lane convention posture. That re-derivation is owed regardless of this work. **Arbiter: - `/planning:plan`.** - -### Recheck triggers - -- **Plugin extraction re-opens when** the auto-mode authoring lane acquires its first persistent-state - or settings-writing component. -- **The local merge retires when** `claude permissions --help` returns real usage instead of falling - back to the top-level `Usage:` line — the same controlled probe that established its absence. - This trigger is **necessary but not sufficient on its own**: a second, narrower route already - exists. `claude --debug-file -p ` narrates the merge per destination with full rule - text, and narrates every auto-mode-dropped allow rule individually with its source file path and - the reason `(bypasses classifier)` — measured on 2.1.225, 216 drop lines in one session - (`.work/permission-model/EXPERIMENT-debug-channel-merge-narration.md`). It does not retire the - computed merge, because it costs a session spawn and parses undocumented `[DEBUG]` strings with no - stability contract. It is an oracle, not a read path. -- **The debug-channel oracle degrades when** a `--debug-file` capture on a machine with known - auto-mode-dropped rules stops emitting `Ignoring dangerous permission from (bypasses - classifier)`. Basis: the strings are undocumented internal output, so only a capture proves them. - -## Plan - -**Skill name: `claude-config:audit-permission-state`.** Derived against `MIGRATION-PLAYBOOK.md` -§Naming: an action / user-invoked skill takes an action verb, and a sibling family orders -base-concept-first — the plugin's existing family is already `audit`, `audit-automation-gaps`, -`audit-instructions`, `audit-pass`, `audit-permission-grants`, `audit-prompting-postures`. `state` -is the discriminator against the `grants` sibling: grants are what you wrote, state is what is -actually in effect. `claude-config` survives the namespace-noun test that a narrower noun would fail. - -**Standards grounding — cited by SECTION NAME, never by line number.** `PLUGIN-PHILOSOPHY.md` -§Cross-platform contract and §Prerequisites and failure behavior govern Phases 1 and 5; -§Two-lane convention posture governs the Phase 6 boundary; §Fresh-eyes checkpoints governs the -Phase 8 declaration; §Naming governs both skill names. `AGENTS.md` governs staging (explicit paths, -never `git add -A`). An earlier draft of this plan cited line ranges and **every one of them was -wrong within a day** — the file moved under them. Line-number citations into living files are -forbidden in this plan; grep the section heading instead. - -### Phase 0: Discharge the fresh-docs mandate [DONE] - -**Completed 2026-08-10 — output: [`phase0-fresh-docs.md`](phase0-fresh-docs.md).** Eleven facts -confirmed against pages fetched this session, five corrections to this plan, two open upstream -discrepancies, and three claims moved to *not stated* that must not ship as fact. The corrections -change downstream phases; the material ones are folded into the phase bodies below: - -- **Phase 1** — managed policy is a plist domain, a Windows **registry hive**, a JSON file, **and** a - `managed-settings.d/` drop-in directory, per OS. Not a jq-over-two-paths read. And - `.claude/settings.local.json` resolves **through worktrees to the main checkout**, with three stated - exceptions. -- **Phase 4** — `autoMode.classifyAllShell` (v2.1.193+) suspends **every** Bash/PowerShell allow rule - when true, which inverts criterion 3's answer. No criterion covers it. -- **Criterion 2** — the `v2.1.142` gate is **not stated** on any governing page, and neither is the - claim that `useAutoModeDuringPlan` is unread from shared project settings. -- **Criterion 6** — the `Write(path)` item needs re-deriving; the page describes a different mechanic - (parameter-form rules on a tool's primary content field are ignored **and emit a startup warning**). -- **Phase 6** — the no-`allowManagedAutoModeRulesOnly` claim is now confirmed by the governing page and - is no longer resting on an unverified research slice. - -### Phase 0 (original brief, retained for the record) - -`CLAUDE.md` names this non-negotiable and this work is squarely inside its scope: the plan edits a -plugin manifest and the skills' whole contract surface is documented harness behavior. Every upstream -fact currently in the Brief rests on one machine's 2026-08-09 capture, which the mandate does not -accept as a substitute. The Brief's "fetch at read time" constraint governs the **shipped skill's -runtime**; it does not discharge the **implementer's** obligation. - -- Open `docs/OFFICIAL-DOCS.md`, WebFetch every page it indexes for permissions, permission modes, - settings, server-managed settings, sandboxing, hooks, and auto-mode configuration, and cite each URL. -- Re-confirm from those fetched pages, not from recall or from this file: the version gates - `v2.1.142` / `v2.1.207` / `v2.1.211`; the `autoMode` key set; the precedence order Phase 2 encodes; - the `disableAutoMode` string-vs-boolean shape; and the claim that no `allowManagedAutoModeRulesOnly` - exists. -- Any fact not confirmed from a page fetched during this phase is marked unverified in the phase that - consumes it and carries a caveat per criterion 10. - -**Sanity Check:** the phase's output file names one fetched URL per fact above; assert every version -gate constant appearing anywhere in the two new skills also appears in that file -(`comm -23` of the two sorted constant lists is empty). - -### Phase 1: Walking skeleton — scope discovery across all five scopes [DONE] - -**Completed 2026-08-11.** `permission-state.test.sh` 38/38, covering every sanity check below: -all four file scopes exactly once, all four managed surfaces exactly once, drop-ins in the documented -alphabetical order with dotfiles ignored, `absent` distinguished from `invalid-json` and from -`skipped`, the start-directory copy never double-counted, jq-absent exit 2, and the optional-leg -degradation (stub `PATH` without `reg`: exit 0, announced skip, portable core still read). -`check-skill.sh audit-permission-state` PASS, `check-evals-quality.sh` PASS, -`validate-plugins.sh` clean, cheat sheet and catalog regenerated, listing budget 5892/8000 across -eight skills. `shellcheck -x` clean; both Phase 9 suites still pass after the fail-loud retrofit below. - -**The registry leg is verified against the real registry, not a fixture.** A scratch key -(`HKCU\SOFTWARE\ClaudeCodePluginTest`, deliberately **not** under `Policies`, so no policy was ever -deployed to the machine) was created, read, parsed, and deleted; `HKCU\SOFTWARE\Policies\ClaudeCode` -was confirmed still absent afterwards. The shipped test uses the key-list seam instead, because a test -that writes to a consumer's registry is not something this marketplace should ship. - -**Three wrong-answer defects caught before they shipped**, two by the stub-`PATH` case and one by -reviewing the code against this plan's own `HKCU`-is-a-fallback claim: - -- **MSYS argument conversion silently broke every registry read.** Git Bash rewrites an argument - containing backslashes as though it were a POSIX path, so `reg query 'HKLM\SOFTWARE\...'` reaches - `reg.exe` mangled and exits non-zero with `ERROR: Invalid syntax` — which the reader scored as "no - managed policy deployed" on a machine that has one. Exactly the failure mode the Option A decision - was chosen to avoid, arriving by a different route. Fixed by scoping `MSYS2_ARG_CONV_EXCL` to those - calls; measured both ways. -- **A stray admin-level registry key could have made user-level policy look like the managed - policy.** The search stopped at the first key whose `Settings` value parsed, so an `HKLM` key with a - missing or malformed value fell through to `HKCU` — reporting lowest-priority policy as the one in - force, the exact failure the `HKCU`-is-a-fallback rule exists to prevent. `reg query /v - Settings` cannot distinguish a missing key from a missing value; a bare `reg query ` can, and - is now the existence probe. Covered by a read-only test using `HKCU\SOFTWARE`, which exists - everywhere and carries no `Settings` value. -- **A missing shared library reported a clean machine.** With `dirname` off `PATH`, plugin-root - resolution collapsed, the `source` failed, and every managed surface reported `absent` while the run - still exited 0. Now resolved with builtins only (`${BASH_SOURCE[0]%/*}`) and a hard `exit 2` when the - library is unreadable. The same fail-loud guard was retrofitted to `check-structure.sh`, which had - the identical fall-through from Phase 9. - -**Deviations from the phase as written, recorded rather than silent:** - -- **No `reference/criteria.md` yet.** Phase 1 ships an inventory, not a check, so a criteria file - today could only restate the record contract that `SKILL.md` already carries at run time — the - duplication this repository's no-duplication rule exists to prevent. It lands with the first - mechanical check (Phase 4), or with Phase 2's precedence basis, whichever comes first. -- **`evals/evals.json` shipped now rather than in Phase 8**, since the skill directory is created - here and a new skill without evals is a gap for however many phases it stays open. -- **`plugin.json`'s description and the generated catalog and cheat sheet were updated now**, not - deferred: both are generated artifacts with a required CI parity check, and the manifest's skill - count was factually wrong the moment the directory existed. Phase 8 still owns extending both for - `draft-auto-mode-rules`; the version bump remains the single one already taken. -- **The macOS `plist` surface reports presence, not contents.** The reader names the domain and says - so explicitly. Inventorying it needs a machine that can be verified, which this plan does not have. - -The integration slice. Everything downstream reads what this produces. - -- Create the skill directory following the sibling's exact topology: - `plugins/claude-config/skills/audit-permission-state/{SKILL.md,reference/criteria.md,scripts/,evals/evals.json}`. -- `scripts/permission-state.sh` discovers and reads every settings scope: managed policy (read-only, - per-platform paths), user (`~/.claude/settings.json`), project (`.claude/settings.json`), local - (`.claude/settings.local.json`), and the **pre-v2.1.211 start-directory copy** as a distinct fifth - member — acceptance criterion 7 requires it read alongside the repo-root copy because rules from - both stay in effect. -- Emit one scope record per file: scope, absolute path, present/parsed, and the three rule arrays. -- Declare `jq` as **required for correctness** with a hard stop at the entry point, matching - `permission-rule-check.sh`'s own jq gate (`ERROR: jq required`, exit 2). Declare it in the plugin - README. (This plan forbids line-number citations into living files; the earlier `:63-64` here was - already stale.) - -- **Fixture seam is Phase 1 work, not an afterthought.** The sibling exposes only - `PERMISSION_HYGIENE_FIXTURE_DIR`, which sets `ROOT` and therefore reaches project and local scopes - only. This script needs **separate** overrides for the user-home, managed-policy, and - start-directory roots, so every scope is testable without ever reading or writing the operator's - real `~/.claude/`. No test may touch the real user home. -- **Managed-policy reader scope — DECIDED 2026-08-10: portable core plus declared optional platform - legs.** Phase 0 correction 1 splits the managed scope into four sub-surfaces with different costs, - so the reader is split the same way rather than being all-or-nothing: - - **Portable core, always read, on every OS:** the per-OS `managed-settings.json` and its sibling - `managed-settings.d/` drop-in directory. - - **Declared optional platform integrations:** the Windows registry keys - `HKLM\SOFTWARE\Policies\ClaudeCode` and `HKCU\SOFTWARE\Policies\ClaudeCode`, and the macOS - `com.anthropic.claudecode` managed-preferences domain. Each is read where it is native and - readable; where its tool is missing or the read fails it **warns visibly and skips that leg - only**, preserving the portable core result. That is §Prerequisites' *required for an optional - feature* class and §Cross-platform contract's *optional platform integrations must degrade - visibly* clause — a declared classification, not an unexplained gap. - - **Basis for splitting here rather than dropping the registry:** Phase 6's headline output is which - managed intents are enforced versus loosenable. A Windows reader that checks only - `%PROGRAMFILES%\ClaudeCode\managed-settings.json` does not under-report a registry-deployed - policy — it reports *no managed policy deployed* while one is in force. That is a wrong finding on - the plugin's primary platform, not a blind spot. - - **Elevation is not required.** Measured 2026-08-10 on Windows 11, unelevated: - `reg query "HKLM\SOFTWARE\Policies"` returns subkeys and exits 0. Recheck trigger: an unelevated - `reg query` of that path starts returning `ERROR: Access is denied` — basis, only a live probe - proves the ACL, and the plugin never elevates. - - **Verification honesty, per §Cross-platform contract.** The Windows registry leg is verified - empirically against a synthetic `HKCU` fixture key (no real policy is deployed on the development - machine, so a synthetic key is the only available positive case). The macOS plist domain and the - Linux paths **cannot** be verified from the development machine and ship with an honest - manual-verification gap recorded in the skill. This applies to the fully-built option too — no - option available here ships every leg verified. - - **`managed-settings.d/` merge semantics are decided, not caveated.** The settings page states the - systemd-convention ordering verbatim (base first, then `*.json` sorted alphabetically on top; - scalars overridden, arrays concatenated and de-duplicated, objects deep-merged, dotfiles ignored) — - see the Phase 0 addendum. The reader implements that order rather than reporting an inventory. - - **The Windows registry leg reads one value, and `HKCU` is a fallback, not a peer.** The policy - JSON lives in a `Settings` value (`REG_SZ`/`REG_EXPAND_SZ`) on the key, so the read targets that - value; `HKCU` is documented as lowest policy priority, "only used when no admin-level source - exists", so consulting it while `HKLM` carries policy would report policy that is not in force. - **The search therefore ends at the first key that EXISTS, not at the first key whose value - parses** — see the Phase 0 addendum for why the `/v` form cannot make that distinction. An - existing key with no readable value is reported unread, never as permission to consult the next. - - **Server-managed settings are a managed source with no local path.** Delivered remotely at - sign-in, so no local reader can see them. Every managed finding says "the local managed surfaces", - never "the managed policy" — the completeness claim is not available. - - Legacy `C:\ProgramData\ClaudeCode\managed-settings.json` is **never probed** — unsupported since - v2.1.75, and reading it would report policy not in force (Phase 0 correction 2). - -**Sanity Check:** - -- Point the fixture seams at a tree carrying **all five** scopes, then assert each named scope appears - exactly once: `grep -c '^user'` = 1, `^project` = 1, `^local` = 1, and `^startdir-local` = 1. A `≥2` - count is not acceptable — it passes on project+local alone and leaves criterion 7's dedicated scope - member entirely unverified. -- The managed scope is four sub-surfaces, so it gets a **per-surface** assertion instead of one row. - Every leg emits a row on every OS — a non-native or unreadable leg emits an explicit - `not-applicable` / `skipped` row rather than nothing, so the row count is deterministic per OS and a - silently-missing leg is detectable: `grep -c '^managed file'` = 1, `^managed dropin` = 1, - `^managed registry` = 1, `^managed plist` = 1. Asserting only an aggregate `^managed` row would pass - with three of the four legs never attempted. -- Optional-leg degradation, per §Prerequisites: run on Windows with a stub `PATH` directory carrying - every needed tool **except** `reg`; assert exit 0, a visible warning naming the registry leg, a - `^managed registry ... skipped` row, and that the `^managed file` and `^managed dropin` rows are - still emitted — the portable core survives the optional leg's absence. -- jq-absent behavior: create a stub directory containing every needed tool **except** `jq`, run with - `PATH=`, assert exit 2 and `ERROR: jq required`. Do **not** use bare `PATH=` — measured, it - yields `bash: command not found` and exit 127, because the interpreter itself becomes unresolvable, - so the check would fail for a reason unrelated to jq. - -### Phase 2: Merge and per-rule provenance [DONE] - -**Completed 2026-08-11.** `permission-merge.test.sh` 40/40, `shellcheck -x` clean, -`permission-state.test.sh` still 42/42. `scripts/permission-merge.sh` consumes Phase 1's records and -emits the effective set; `reference/criteria.md` now exists and carries the `precedence_basis` -vocabulary; `SKILL.md` gained the Phase 2 pipeline. - -**The phase as written assumed the wrong merge model, and Phase 0's 2026-08-11 addendum overrides -it.** The two governing sections were re-fetched before any code was written, per the standing -re-fetch constraint, and they say permission rules *"merge across scopes rather than override"*. So: - -- **There is no same-kind winner, and electing one would have been an uncited precedence claim.** A - rule text in the same list at two scopes has both entries live. Provenance for that case is the - full contributor set (`precedence_basis=merged-across-scopes`), not an origin. -- **A winner exists only across kinds**, elected by evaluation order — *"deny rules from any scope are - evaluated before allow rules"*, stated in both directions on the page. Losing entries ship as - `inert` records naming what beat them, which is what answers "why is my allow rule ignored". -- **The phase's `origin` field is therefore not implemented as written.** `scopes=` carries every - contributing scope; the reader's emission order is presentation, never a ranking. -- **The sanity check's one-winner assertion is bound to the cross-kind fixture**, where it is true, - and a second fixture asserts that a same-kind duplicate elects nothing and reports nothing beaten. - The basis invariant is anchored on the `effective` record prefix rather than a bare - `precedence_basis` grep, so it cannot pass on unrelated text. -- **The start-directory-versus-project rank was deleted, not caveated.** With no same-kind election it - is never consulted, so the undocumented rank never has to be invented. -- **Whole-tool rules are computed, not caveated.** Caught by fresh-context review after the first - merge shipped: with exact-text comparison alone, managed `deny: ["Bash"]` and user - `allow: ["Bash(git status)"]` were two unrelated groups and BOTH were reported effective, while the - page says the bare name "removes the tool from Claude's context entirely". The tool token is the - text before the first `(` and needs no matcher, so this was a defect, not a bound. A whole-tool deny - now makes every rule for that tool inert (`removed_by=deny@`), a whole-tool ask outranks every - scoped allow for it, and `EndConversation` is exempt per its documented carve-out. -- **Pattern subsumption is a known false-positive class with a known direction**, not an - undecidability. The page documents that a broad deny beats a narrower allow; this merge compares - exact text, so it over-reports allow and never over-reports blocking. Stated that way in the caveat. -- **Two invisible sources bound every effective claim**: server-managed settings (already stated by - Phase 1) and the command-line scope, which ranks above local/project/user and has no file. The - merge prints the second on every run. -- **An empty merge cannot mean an empty machine.** A pipeline swallowing the reader's `exit 2` would - report "nothing in effect" on a machine full of rules — the same family as Phase 1's three defects. - Zero scope records on input is `exit 2` before any merge line is emitted. -- **`/permissions` is prior art the skill was overclaiming against.** The permissions page states it - "lists all permission rules and the `settings.json` file each rule comes from". `SKILL.md`'s Purpose - now names it, points readers to it for "where is this rule written", and claims only the - difference: outcome resolution, read-vs-empty distinction, and a consumable output. - -Acceptance criterion 1. - -- Compute the effective merged `allow` / `ask` / `deny` set from the Phase 1 scope records. -- Every merged rule carries `origin` (the winning scope) and `precedence_basis` — the documented - mechanic the placement follows from. Basis is a per-rule field, not prose, because criterion 1 - requires each precedence claim to cite its mechanic. -- Per the Brief's decidability bound: anything resting on classifier judgment, runtime demotion state, - or an open upstream discrepancy becomes a named caveat on the affected finding, never a silent drop. -- **Treat `not-applicable` exactly like `absent` when merging, and keep them distinct in the report.** - Phase 1 emits a record for every scope on every OS; `startdir-local` is `not-applicable` when the - session started at the repository root, and the optional managed legs are `not-applicable` off their - native OS. A merge that skips only `absent` trips over the others. `skipped` and `unreadable` are a - third case again — they contribute no rules but must not be merged as "this scope is empty", because - the scope was never read. -- **`reference/criteria.md` does not exist yet.** Phase 1 deliberately did not create it rather than - fill it with a restatement of the record contract `SKILL.md` already carries. This phase's - `precedence_basis` is the first content that genuinely belongs in it — create it here, or leave it - to Phase 4, but do not recreate the duplication. - -**Sanity Check:** run against a fixture with a rule defined at two scopes; assert the output names -exactly one winner and that `grep -c 'precedence_basis'` equals the merged-rule count (no rule -without a basis). - -### Phase 3: Auto-mode drop classification and entry diff [TODO] - -Acceptance criterion 3, plus brainstorm candidate 4. - -- Classify every allow rule auto mode drops on entry: blanket `Bash(*)` / `PowerShell(*)`, wildcarded - interpreters, package-manager run commands, and **all `Agent` allow rules**. -- **Sharing the sibling's pattern vocabulary requires real refactoring — plan for it.** - `permission-rule-check.sh` is self-executing (it scans and `exit 0`s at load), so it cannot be - sourced, and the repo's no-duplication rule forecloses copying the `_interp` block. Extract the - pattern definitions into a shared, side-effect-free file both detectors source. That extraction - edits a **Phase 9-owned** file, so it is sequenced with Phase 9 and destroys the claim that the two - are independent — see the execution shape. -- **Criterion 8 binds this CLI read too.** The oracle's `claude --debug-file … -p …` invocation gets - the same defensive contract as the `autoMode` lane: never trust exit status, tolerate a missing or - empty capture, and never infer an empty drop set from a capture that produced no drop lines. -- Render the entry diff: effective state before and after the drop, per rule, with the drop reason. - -- **Debug-channel oracle — opt-in, explicitly priced.** Behind a flag that states the cost *before* - spawning anything, run `claude --debug-file -p ""` and parse the - harness's own drop narration — `Ignoring dangerous permission from (bypasses - classifier)`, closed by a `Removing N allow rule(s) from source ''` summary. Cross-check - it against this phase's prediction; **report disagreement in either direction as a finding.** The - prediction stays the default read path — the oracle costs a session spawn in the consumer's - environment and parses undocumented `[DEBUG]` strings with no stability contract. Never spawn - without the flag; never spawn silently. Write the capture to a scratch path, never to - `~/.claude/debug/`. Evidence and bounds: - `.work/permission-model/EXPERIMENT-debug-channel-merge-narration.md`. - -**Sanity Check:** - -- Feed a fixture containing one rule of each of the four classes; assert all four appear in the - dropped set and that a narrow exact rule (`Bash(git status)`) does not. -- Oracle off by default: run the phase with no flag and assert zero `claude` child processes and no - file created under the scratch path. -- Oracle on: assert the run prints the cost notice before the spawn, and that the count of - `AGREES`/`DIVERGES` lines **equals the compared-rule count exactly** — not `≥ 1`, which passes on a - single line while the per-rule guarantee is unmet. -- Oracle on with the drop strings absent (simulate with a fixture capture): the run reports the oracle - as unavailable and falls back to the prediction rather than reporting an empty drop set. - -### Phase 4: Permission-plane lints [TODO] - -Acceptance criteria 2, 5, 6. - -- **Criterion 2 — both dead-config traps at their two distinct gates, reported separately:** - `autoMode.*` ignored in project and local settings (local also read before v2.1.207), and - `defaultMode: "auto"` ignored in project and local settings (project could set it before v2.1.142). - `useAutoModeDuringPlan` is covered as a third, because it is not read from shared project settings. -- **Criterion 5** — `disableAutoMode` typed as a boolean rather than the string `"disable"`, in any - scope; it is not managed-only. -- **Criterion 6** — allow rules that cannot match: doubled-backslash Windows paths, unanchored allow - globs, `Write(path)`-shaped rules that are accepted but never consulted, and `:*` used anywhere but - at pattern end. - -**Sanity Check:** a fixture per check; assert each fires exactly once and that criterion 2's two gates -emit two separately-labeled findings rather than one merged finding -(`grep -c '\[C2-autoMode\]'` = 1 and `grep -c '\[C2-defaultMode\]'` = 1). - -### Phase 5: `autoMode`-block lane [TODO] - -Acceptance criteria 4 and 8, plus brainstorm candidates 1, 2, 3. - -- **Criterion 4** — `$defaults` omission per `autoMode` section, stating which built-in rule list the - omission discards. Probe and diff via `claude auto-mode defaults`; never infer from a version number - (the changelog does not track the content of the shipped lists — zero matches across all 359 - releases). -- **Criterion 8 — the measured defensive contract, every item, each already reconfirmed on 2.1.225:** - never a strict JSON parser; tolerate a **missing** key rather than an empty array (`defaults --label` - omits a non-matching key entirely); split a rule label at the first `[`, not the first `:`; never - trust exit status. A run producing no usable output reports that fact and never claims success. -- **Candidate 2** — intra-`autoMode` contradiction lint: allow vs `soft_deny`, `hard_deny` vs allow. -- **Candidate 3** — dead / unactionable rule lint: rules shadowed by an earlier `hard_deny`, and prose - entries with no observable predicate. **Not a duplicate of criterion 6** — criterion 6 is syntactic - non-matching on the `permissions.allow` plane; this is semantic shadowing inside the `autoMode` - block. Different surface, different inputs. The prose half either states a mechanical basis or - routes to `critique`, which owns that judgment. -- **Candidate 1** — surface `claude auto-mode critique` as its own action, wrapped in truncation and - empty-output detection with a plain "critique returned nothing; run it yourself" surface. Wrapped, - never replaced: it owns the semantic judgment. -- **Runtime, decided and measured** (`.work/permission-model/EXPERIMENT-nonstrict-json-runtime.md`): - Python 3 is **required for an optional feature** — this lane only. Absent → warn visibly, skip this - lane, continue with the documented reduced result. Pure POSIX was tested and cannot substitute: the - offending byte is a raw line feed inside a string value, which no line-oriented filter can - distinguish from the pretty-printer's structural newlines. Node is an equally valid host and is - deliberately not adopted — a second optional runtime doubles the declaration surface for one feature. - -**Sanity Check:** - -- Parse a **checked-in fixture** carrying a raw line feed inside a string value: the shipped reader - returns the four `autoMode` sections while `jq -e .` on the same fixture exits non-zero. Do **not** - run this against `claude auto-mode config` on the developer's machine — the Brief records the defect - as machine-conditional, so on a clean config `jq` exits 0 and the check fails while the skill is - behaving correctly. The check must exercise shipped code against a fixture, not the operator's - environment. -- The other three measured defects each get their own fixture assertion, because criterion 8 names - four and only one was covered: a **missing** key (not an empty array) is tolerated; a label carrying - a bracketed annotation splits at the first `[`, not the first `:`; and a run that exits 0 with empty - output is reported as "produced no usable output" rather than as success. -- With Python unreachable, the skill exits 0, prints a visible skip notice naming the lane, and still - emits Phase 2 merge output — assert both the notice string and the merge rows in one run. - -### Phase 6: Managed-policy conformance report [TODO] - -Brainstorm candidate 6 — the highest-value residue of the ecosystem sweep. - -- Read the deployed managed policy (read-only always; a plugin can never author managed policy — - admin-write OS paths or a claude.ai Owner role) and diff it against every scope beneath it. -- Report which managed intents are genuinely enforced versus silently loosenable. The load-bearing - claim: **there is no `allowManagedAutoModeRulesOnly`** — permissions, hooks, MCP, - sandbox-filesystem, and sandbox-network each got an exclusivity lock and auto mode did not, so - managed `autoMode` rules *can* be loosened by a developer and only `permissions.deny` in managed - settings is unoverridable. -- **That claim is NOT independently verified and must not ship as "measured".** It originates in the - `managed-policy` research slice, and the Brief's own `### Captured assumptions` records that only - `permissions-core` received independent adversarial verification — the other four are cross-checked - against each other only. Phase 0 re-confirms it from a fetched page. Until it does, the phase's - headline finding carries an explicit caveat naming its provenance. A whole phase resting on an - unverified slice with no caveat is exactly the failure criterion 10 exists to prevent. -- **Two-lane posture (`PLUGIN-PHILOSOPHY.md` §Two-lane convention posture), decided:** this phase ships **no security floor - of its own**. It reports what the consumer's own policy does and does not achieve — it never - prescribes which rules a policy should contain. That keeps it lane-neutral by construction and out - of the lane-1/lane-2 question entirely. The owed re-derivation of - `audit/reference/required-permissions.md` toward lane 2 is a real debt but belongs to the `audit` - skill that ships it, not to this plan. - -**Sanity Check:** against a fixture managed policy containing one `permissions.deny` rule and one -`autoMode` rule, assert the report marks the deny rule enforced and the `autoMode` rule loosenable. -For lane-neutrality, assert the **positive** property: every rule string appearing in the report also -appears in the fixture policy or in a scope file beneath it (set difference is empty). Do not assert -`grep -c 'RECOMMEND ADD' = 0` — nothing emits that string, so it passes unconditionally and proves -nothing about the guarantee it claims to protect. - -### Phase 7: Authoring lane — `claude-config:draft-auto-mode-rules` [TODO] - -Brainstorm candidate 7. Drafts an `autoMode` block from an interview plus the Phase 2 merge, prints it -to stdout, human pastes. **No write, no persistent state**, so it does not trip the Brief's -plugin-extraction recheck trigger. - -This is a **second sibling skill**, not an action on `audit-permission-state`: §Naming binds a skill -name to its KIND, and an `audit-*` skill hosting a `draft` action would make its own name untrue. -**Admitted 2026-08-09**, which amends the Brief's TLDR from one new skill to two and adds a second -listing-budget entry that Phase 8 must state rather than assume. - -- **History source — DECIDED 2026-08-10: dropped.** "The repo's observed prompt and denial history" - was not a location, and an unnamed read surface in a skill shipped to consumers is unreviewable. The - draft is driven by the interview plus the Phase 2 merge alone. This removes a read surface and a - second dependency on the priced oracle; binding it to the Phase 3 debug capture was the alternative - and was not taken. The skill must not acquire a history input without re-opening this decision. - -**Sanity Check:** a skill is a markdown surface, not a process, so nothing is piped from it. Assert -instead against the deterministic script the skill drives: run it on a fixture and pipe **its** stdout -through a strict parser (`jq -e .`), asserting exit 0 — strict is correct here because we author this -output, and the non-strict allowance exists only for the CLI's malformed emission. This keeps Phase 7 -free of the Python dependency that Phase 5 scopes to one optional lane. Zero-writes is covered by -Phase 8's sweep, which must include this skill. - -### Phase 8: Cross-cutting close [TODO] - -- **Criterion 9 — no writes, any scope, any flag.** Assert mechanically, not by inspection. -- **Criterion 10** — every finding whose basis is an open upstream discrepancy carries that - discrepancy as a named caveat. The two live ones are #83766 and #42797 (`permissions.ask` patterns - auto-approved under `defaultMode: auto`, contradicting current documented behavior). -- **Fresh-context delegation directive, decided — in the enforced machine-readable form.** - `PLUGIN-PHILOSOPHY.md` §Fresh-eyes checkpoints requires a skill step whose output judges work the - same context produced to delegate to a fresh-context subagent or carry a greppable exemption. These - skills read consumer configuration and run deterministic scripts over it; no step judges output they - authored. Ship the exemption in the form `skill-quality` Check 21 actually enforces — - `` with class from - `deterministic-gate|external-input|deferred` — and **class `external-input`**, since the judged - material is the consumer's configuration, not our own output. **Both** skills carry one; a prose - paragraph containing the words does not satisfy the checker and would error. -- **Criterion 12** — run `skill-quality:check listing-budget`; state the resulting cost rather than - assuming it. With Phase 7 gated in, state the two-entry cost. -- Register as an `audit-pass` lane per that skill's documented lane rule; add `evals/evals.json`; - update `plugins/claude-config/.claude-plugin/plugin.json` (`version`, `description`). -- **Frontmatter `name:` — verify at implementation time, do not assume.** At `main` - (`30be2a0b`) all 191 skills still carry `name:`. A branch dropping it repo-wide - (`refactor/drop-redundant-skill-name-frontmatter`) exists but is **not merged**. Match whatever - `main` holds when the branch is cut; re-check rather than copying this sentence's answer. - -**Sanity Check:** - -- Write-assertion, **oracle explicitly ON for this run**: checksum the whole fixture tree **and a - fixture `HOME`** before and after running every action of **both** skills, and assert zero changes - in either. Running only the default configuration proves nothing about criterion 9, because the - default disables the one code path that spawns a process capable of writing outside the tree. -- `skill-quality:check` returns PASS for **both** `audit-permission-state` and - `draft-auto-mode-rules`, and its Check 21 passes on each — that is the real verification of the - fresh-eyes declaration; a `grep` for the words matches ordinary prose and certifies nothing. -- Version bump: assert `plugins/claude-config/.claude-plugin/plugin.json` `version` differs from its - value at the branch point, and that its `description` names both new skills. - -### Phase 9: existing-check scope widening and the shared extractions [DONE] - -**Completed 2026-08-10.** Every sanity check below was run and passed: `permission-rule-check.test.sh` -57/57 (7 of them new), `check-structure.test.sh` 44/44 (10 new), `scope-report.test.sh` 21/21, -`managed-scope.test.sh` 16/16, `permission-patterns.test.sh` 12/12, -`check-cross-plugin-source-drift.sh --check` clean and proven to FAIL on a deliberately perturbed -copy, `shellcheck -x` clean, `check-shell-portability.sh` clean, `check-changelog-parity.sh --check` -and `--check-bump origin/main` clean, and `check-skill.sh audit-permission-grants` PASS against the -pre-change ref — the only gate covering the frontmatter edit, which the trigger-preservation check -confirms kept all four base-ref phrases at 552/1536 characters. - -No machine consumer gates on the detector's finding count: a repo-wide sweep for -`permission-rule-check` and `audit-permission-grants` outside the skill's own directory returns -documentation and lane routing only, no `--count` threshold in a hook, a CI job, or an `audit-pass` -verdict. The widening therefore adds findings a human reads; it cannot turn a previously-green -automated check red. - -Two deliberate divergences from the phase as written, both recorded rather than silent: - -- **No bespoke fixture-home environment variable was added.** The phase assumed one had to be - invented because "there is no way to point it at a fake user home". There is: the user scope - resolves `${CLAUDE_CONFIG_DIR:-$HOME/.claude}` — the same resolver `claude-memory` already uses and - cites to the `.claude-directory` doc — so a test points `$HOME` at a fixture home and unsets - `CLAUDE_CONFIG_DIR`. Reusing that seam beats a third way of saying the same thing, and it made the - relocated-config-root case testable for free. -- **The shared managed-scope cluster ships without a dedicated sync script.** - `check-cross-plugin-source-drift.sh --check` is already a required CI job and fails on a drifted - registered cluster, so registration alone enforces the invariant; the registry entry names the - canonical copy so a drift failure says which direction to fix. The registry's header, which claimed - every listed cluster already had a dedicated check, was corrected rather than left untrue. - -Acceptance criterion 11, the Brief's **second** widening, and the two extractions later phases consume. -**Not independent** — see the execution shape; it shares `plugin.json` with Phase 8 and owns the file -Phase 3's pattern extraction touches. - -- The criteria file and the detector scan project and local settings only, so a user-global - interpreter-wildcard rule is invisible to check P1. Add the user-global scope. -- **Build the fixture-home seam first — it does not exist.** The detector's only override is - `PERMISSION_HYGIENE_FIXTURE_DIR`, which sets `ROOT`, and the scans read `$ROOT/.claude/settings.json` - and `$ROOT/.claude/settings.local.json` only; the test harness passes exactly that one variable. - There is no way to point it at a fake user home, so user-global cannot be tested at all until a - second override exists. Adding it **is** the phase's first work item; without it the only way to - exercise the new scope is to read the operator's real `~/.claude/settings.json`, which no test may do. -- **Extract the shared pattern vocabulary here**, side-effect-free, so Phase 3 can source it. The - current file self-executes and `exit 0`s at load, so it cannot be sourced as it stands. - -- **The Brief's second widening — DECIDED 2026-08-10: `claude-config:audit`'s settings scan gains the - start-directory `settings.local.json` copy.** `check-structure.sh` reads the repository-root copy - only. The settings page states the harness still reads a `.claude/settings.local.json` an earlier - version left in the starting directory, and that permission rules from **both** files stay in - effect — so a rule set nobody audits is live. This is the same criterion-7 surface - `audit-permission-state` covers, applied in place to the existing check. It rides Phase 8's single - `plugin.json` bump like the P1 widening does. - -- **Shared managed-scope enumeration — DECIDED 2026-08-10: extract, do not write a third copy.** - Approving Phase 1's managed reader makes this the **third** in-repo component enumerating managed - paths, after `claude-config:audit/scripts/check-structure.sh` (per-OS JSON file + `managed-settings.d/`, - test seam `SETTINGS_AUDIT_MANAGED_PATH`) and `claude-memory:stateless/scripts/scope-report.sh` - (file only; registry flagged, deliberately not read). §Convention registry binds a cross-plugin - convention to an owner doc **before** a second adopter, and we are already past two. - - Plugin-form isolation forbids a runtime reach-out across plugin roots, so the repo's established - mechanism is a byte-identical copy at the same path-within-plugin, plus a dedicated sync/drift - check, registered in `scripts/cross-plugin-source-registry.txt`. Follow that mechanism rather than - inventing a second one — an unregistered identical cluster is exactly what - `check-cross-plugin-source-drift.sh` exists to flag. - - Scope the shared source to **path enumeration and OS detection only**. Presentation, redaction - posture, and each caller's existing output stay with the caller: `check-structure.sh` deliberately - reports managed policy as counts rather than values, and `scope-report.sh` deliberately reports - presence only. Migrating either one's *output* is not in this plan. - - **Blast radius to state, not discover later:** this edits a second plugin (`claude-memory`), which - owes its own version bump and CHANGELOG entry independent of `claude-config`'s. - -**Sanity Check:** - -- With the new fixture-home seam pointed at a fake home containing one interpreter-wildcard rule, - assert exactly one P1 finding naming that file; assert the finding does **not** appear when the seam - is unset (proving the fixture, not the real home, produced it); assert - `scripts/permission-rule-check.test.sh` still passes. -- Second widening: with a fixture tree carrying a start-directory `.claude/settings.local.json` that - the repository root does not carry, assert `check-structure.sh` emits a row naming that file, and - that its existing tests still pass. Assert the row is **absent** on a fixture with no start-directory - copy, so the check cannot pass by always emitting it. -- Extractions: `bash -n` plus a source-and-return test proves the extracted files are side-effect-free - (sourcing them runs nothing and exits nothing); `scripts/check-cross-plugin-source-drift.sh --check` - exits 0 with the new cluster registered; and deliberately perturbing one copy makes it exit non-zero, - proving the drift check actually covers the new cluster rather than silently ignoring it. - -### The Brief's second scope-widening — RESOLVED 2026-08-10 - -The Brief commits twice to widening **two** existing checks (`### Constraints` → Packaging: "plus -in-place scope widening of two existing checks"; and the TLDR), and only P1 was ever named. The -operator resolved it rather than the plan guessing it: the second is **`claude-config:audit`'s -settings-file scan**, widened to the pre-v2.1.211 start-directory `settings.local.json` copy. It is a -Phase 9 work item with its own sanity check. The alternative — striking "two" from the Brief and -shipping one widening — was offered and not taken. - -## Blast radius - -**MEDIUM-HIGH.** A new component shipped from a marketplace consumed downstream, plus in-place -behavior changes to **two** existing checks (Phase 9) that widen what they flag — consumers will see -new findings on unchanged repos. Phase 9's shared managed-scope extraction also edits a **second -plugin** (`claude-memory`), which owes its own version bump and CHANGELOG entry. Mitigated by: -report-only throughout (criterion 9), no consumer writes under any flag, the managed tier read-only by -construction, and the extraction scoped to path enumeration so no caller's existing output changes. -The genuinely irreversible surface is the published skill name, which is why naming was derived -against §Naming rather than chosen. - -## Stress-test summary - -Fresh-context reviewer dispatched 2026-08-09 with the rationale withheld. **19 findings, 2 CRITICAL. -Every finding was independently re-verified against the repository before any edit; all 19 held.** -No finding was rejected. - -The two CRITICALs were factual, not stylistic, and both invalidated premises this plan was resting on: - -1. **Three load-bearing premises were stale.** PR #2089 is **merged**; the `auto-mode-config` row is - already in `docs/OFFICIAL-DOCS.md`; `main` is `a013d204`, not `30be2a0b`; and the frontmatter - `name:` refactor **has merged**, so copying the sibling's frontmatter would re-introduce a field - `main` deliberately removed. The Brief's Q12 gate was being held open on shipped work. -2. **The whole topic slice is untracked on a spent branch** and absent from `main`. Following the - handoff's own instruction — cut a branch from `main` — would produce a worktree with no plan in it. - -The remaining seventeen clustered into four honest weaknesses, all now corrected in place: - -- **Sanity checks that could not fail or could not run.** `grep -c 'RECOMMEND ADD' = 0` asserted a - string nothing emits; `PATH= bash` yields exit 127 (`bash: command not found`), never the exit 2 it - claimed to test; Phase 5's headline check ran against the operator's live config, which the Brief - itself calls machine-conditional, so it would fail on a clean machine while the code was correct; - Phase 7 piped a markdown skill into a JSON parser; a `≥2` scope count passed without ever asserting - the two novel scopes; a `grep` for "fresh-eyes" matched prose while the real checker enforces a - strict directive form. -- **A parallel wave built on false file-disjointness.** Phases 4, 5 and 6 all write the same - `criteria.md` and `SKILL.md`; Phases 8 and 9 collide on `plugin.json`. The plan is now sequential, - and Phase 9 moved early because it owns seams the earlier phases need. -- **Governance skipped or mis-cited.** The `CLAUDE.md` fresh-docs mandate had no step at all — now - Phase 0. Every `PLUGIN-PHILOSOPHY.md` line-range citation pointed at the wrong section within a day; - citations are now by section name, and line numbers are forbidden in this plan. -- **Coverage gaps.** Criterion 8 binds *every* CLI read but the oracle's new CLI read had no - defensive contract and 3 of its 4 measured defects had no check; criterion 9's write assertion ran - only with the risky path disabled; the Brief's commitment to widen **two** existing checks has only - one assigned, now flagged OPEN rather than guessed. - -One item the reviewer explicitly declined to assert: whether a `-p` oracle session writes transcript -files outside the scratch path. It is unverified, not a finding — carried into Open questions. - -## Execution shape - -Ten phases, 0 through 9. - -**Corrected after review: there is no parallel-safe set. This plan is sequential.** - -The earlier draft claimed Phases 4, 5 and 6 were file-disjoint "own criteria rows, own scripts". They -are not: criteria rows are not separate files — the sibling and this plan both put every check in one -`reference/criteria.md` — and Phase 5's `critique` action and Phase 6's report are both new actions in -the single `SKILL.md`. All three write the same two files. Phase 9 was likewise called "zero file -overlap", but Phase 8 bumps `plugins/claude-config/.claude-plugin/plugin.json` and Phase 9 is a -behavior change in the same plugin that must ride the same bump — and Phase 9 now also owns the -pattern extraction Phase 3 consumes. - -| Order | Phase | Gated by | -|---|---|---| -| 1 | 0 | — (fresh-docs mandate; everything downstream cites it) | -| 2 | 9 | 0. Moved early: it owns the fixture-home seam, the extracted pattern vocabulary, and the shared managed-scope enumeration that Phases 1 and 3 consume. | -| 3 | 1 | 9 (fixture seams) | -| 4 | 2 | 1 | -| 5 | 3 | 2, 9 (pattern vocabulary) | -| 6 | 4 | 3 (shares the drop vocabulary) | -| 7 | 5 | 2 | -| 8 | 6 | 2 | -| 9 | 7 | 2 | -| 10 | 8 | all — it measures the finished surface and owns the single version bump | - -| Phase | Surface | Basis | -|---|---|---| -| 0 | Main session | Fetch-and-cite judgment; its output is the citation base for everything after. | -| 9, 1, 2, 3 | Main session | Seams, merge, and precedence basis are the contract. | -| 4, 5, 6 | Main session, sequential | They share `criteria.md` and `SKILL.md`; a worker fan-out would collide. | -| 7 | Main session | New public surface; naming and scope judgment. | -| 8 | Main session | Measures and closes. | - -| Phase | Surface | Basis | -|---|---|---| -| 1, 2, 3 | Main session | Judgment-heavy; the merge and precedence basis are the contract. | -| 4, 5, 6 | Sub-agent worker (if used) | File-disjoint, criteria-row shaped, mechanical once the merge exists. | -| 7 | Main session | New public surface; naming and scope judgment. | -| 8 | Main session | Measures and closes; must see everything. | -| 9 | Sub-agent worker or main | Fully independent, small, well-fenced. | - -**Cost note:** no parallel wave survives the file-overlap check, so there is no agent-count tradeoff to -offer. Sequential is the shape, not a fallback from one. - -**Sub-topic promotion watch:** Phases 5 and 6 each carry ≥5 distinct work items and could earn their -own topic slice. Promote if either exceeds ~300 LOC delta or grows sub-phases. - -## Open questions - -All three approval-round questions are resolved (2026-08-09): - -1. **Debug-channel oracle — SHIP IT, opt-in and explicitly priced.** Folded into Phase 3 with its own - four sanity checks. The prediction remains the default read path. -2. **Phase 7 — IN.** Ships as the second sibling skill `claude-config:draft-auto-mode-rules`. -3. **Fresh-context plan reviewer — dispatched** at the user's explicit request. - -Remaining genuinely open, carried into implementation: - -- **Scopes absent from the oracle capture were not disproved.** No `projectSettings` line appeared - because this repository's `.claude/settings.json` carries no `permissions` key; no `policySettings` - line appeared because no managed policy is deployed on the probe machine. Re-probe on a - managed-policy machine before claiming Phase 6 coverage from the oracle. -- **What governs whether a `-p` probe session is in auto mode** was not isolated, and it determines - whether the oracle emits drop lines at all. Phase 3's fallback sanity check covers the failure, but - the mechanic is unknown. -- **The `[DEBUG]` string format** carries no stability contract; the recheck trigger is recorded under - `### Recheck triggers`. -- **What a `-p` oracle session writes outside the scratch path is unverified.** It plausibly writes a - transcript, project state, or telemetry under the user's config directory. Criterion 9 forbids - writes to consumer *settings*, and a transcript is not a settings file, but the boundary was never - measured. Measure it in Phase 3 before the oracle ships, and state the result in the flag's cost - notice — a feature that spawns a session must be honest about everything it leaves behind. -- **P2's machine-path vocabulary overlaps a standards-managed upstream body.** - `guardrails/lib/path-detection/machine-path-patterns.sh` is the org-shared materialization of the - per-OS machine-path regexes, kept upstream "so a pattern change lands once and reaches every scan - driver in lockstep". `permission-rule-check.sh` assembles its own `P2_ERE` independently. The two - serve different inputs (permission-rule text with its own `$`/`{`/`~` exemptions vs file content - with driver-side boundary prefixes), so this is not an obvious merge — and the upstream body is - standards-owned, so any convergence lands there, not here. Deliberately left alone by Phase 9; - recorded so it is not lost. -- **The worktree resolution gap in the two existing detectors.** `permission-rule-check.sh` and - `check-structure.sh` both anchor on `git rev-parse --show-toplevel`, which yields the **worktree** - root, while the settings page says `.claude/settings.local.json` resolves through worktrees to the - **main checkout**. In a worktree they therefore look for the local file where it is not. Phase 1's - new reader must resolve the main checkout; retrofitting the two existing detectors is a real but - separate behavior change, deliberately not folded into Phase 9's approved scope. -- **`check-skill.sh` warns `stale fresh-eyes-exempt directive` on `audit-permission-state`, and the - directive is right.** Verified 2026-08-11: the check's own judgment-language regex matches nothing - in the file, at HEAD or after Phase 2, so the warning is pre-existing and the heuristic list is the - gap — which is what the warning text itself says to check. The declaration is honest (the skill - interprets the consumer's configuration, never its own output) and stays. Fixing the heuristic is a - `skill-quality` change, not a `claude-config` one; Phase 8 decides whether to raise it there. - -## Handoff to implementation - -### User-approval gates - -- Any proposal to add a `--fix` mode, or any consumer write in any scope, stops and asks — the Brief - puts both out of scope and criterion 9 asserts against them. -- Any change that would make the debug-channel oracle spawn by default, rather than behind its - explicit priced flag, stops and asks. The opt-in *is* the approved shape. -- Promoting Phase 5 or Phase 6 to its own topic slice (the sub-topic watch below) stops and asks. - -### Execution shape (`[EXEC-SHAPE]` tagged) - -The wave table, the routing table, and the sequential fallback above. - -### Mechanical work - -- **This slice is not durable yet — fix before anything else.** `docs/topics/permission-model/` is - **untracked** in a worktree sitting on `docs/index-auto-mode-config`, whose PR **#2089 merged** - (`2026-08-09T17:31:08Z`) carrying only the `OFFICIAL-DOCS.md` row. The branch is spent and nothing - in this slice is on `main` (`git ls-tree -r --name-only main | grep topics/permission-model` → - no matches). Cut `feat/audit-permission-state` from current `origin/main` — untracked files carry - across the switch — and commit the slice before any implementation work begins. -- **Branch:** `feat/audit-permission-state`, from current `origin/main`. The existing worktree is 21 - commits behind. Do **not** reuse `docs/index-auto-mode-config`; it is merged. -- **Frontmatter `name:` is GONE on `main` — do not copy the sibling's.** `main` is at `a013d204`; - `git grep -l '^name:' main -- '*/SKILL.md'` returns only `vendor/` files, so - `refactor/drop-redundant-skill-name-frontmatter` **has merged**. Both new skills ship without a - frontmatter `name`. Re-verify at branch-cut time rather than trusting this sentence. -- Commit at phase boundaries; stage explicit paths only, never `git add -A` (`AGENTS.md`). -- PR body must satisfy `.github/workflows/pr-issue-linkage.yml`: a closing keyword or the literal - `No linked issue`, plus a non-empty `## Related` section. diff --git a/docs/topics/permission-model/design/design-resolution.md b/docs/topics/permission-model/design/design-resolution.md deleted file mode 100644 index 73aeafd1f4..0000000000 --- a/docs/topics/permission-model/design/design-resolution.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -outcome: early-exit -tier: B -date: 2026-08-09 ---- - -# Design resolution — permission-model - -`/planning:plan`'s design gate, resolved as a **Tier B early exit**. No `/planning:design` session is -owed. This file records the classification, its evidence, and the type sketch the tier requires. - -## Why Tier B and not Tier A - -Tier A asks whether the work introduces new types/contracts, a new module, a package-topology change, -or cross-module integration whose shape is still open. Most of that surface is **already closed** — -by the Brief, or by repository precedent strong enough that inventing an alternative would itself be -the defect. - -| Design axis | Status | Closed by | -|---|---|---| -| Packaging / module boundary | Closed | Brief `### Constraints` → Packaging: a sibling skill under `claude-config`, not a new plugin. Basis is hard: the flat `renames` map and the `audit-pass/SKILL.md:36` cross-plugin reference. | -| Component topology | Closed by precedent | Every skill in this marketplace is `SKILL.md` + `reference/*.md` + `scripts/*.sh` + `evals/evals.json`. Verified against `plugins/claude-config/skills/audit-permission-grants/`, which is the nearest sibling and the direct template. | -| Read strategy | Closed | Brief: compute the merge, bounded by a per-item decidability criterion with a stated basis. | -| Output vocabulary | Closed | Brief: the `review` plugin's `severity.md`. | -| Finding-line format | Closed by precedent | ` [] : `, from `audit-permission-grants/reference/criteria.md`. | -| External contracts consumed | Closed | The measured CLI defensive contract in the Brief; no new external surface is introduced. | -| Cross-module integration | Closed by precedent | Registration as an `audit-pass` lane follows that skill's documented lane rule; no new integration mechanism is invented. | - -What genuinely remains open is **script decomposition** (how many scripts, where the seam between -them falls) and the **criteria-row schema** for the new checks. Both are localized, both are -single-file-ish decisions, and both are resolved inside the plan body rather than needing a design -session. That is the Tier B signature. - -## Type sketch - -No programming-language type system is involved — the artifacts are shell scripts and markdown. The -"types" are the data shapes the scripts pass and the report consumes. - -**Scope record** — one per settings file discovered. - -- `scope`: one of `managed` | `user` | `project` | `local` | `startdir-local` -- `path`: absolute path as read -- `present`: whether the file exists and parsed -- `arrays`: the `permissions.allow` / `.ask` / `.deny` entries found - -`startdir-local` is a distinct member, not a variant of `local`: acceptance criterion 7 requires the -pre-v2.1.211 start-directory copy to be read **alongside** the repository-root copy, because rules -from both stay in effect. - -**Merged rule** — one per rule in the effective set. - -- `rule`: the verbatim pattern -- `bucket`: `allow` | `ask` | `deny` -- `origin`: the `scope` it came from -- `precedence_basis`: the documented mechanic the placement follows from (criterion 1 requires this - per rule, so it is a field, not prose) -- `auto_mode_drop`: whether auto mode discards it on entry, plus which criterion-3 class - -**Finding** — one per detected problem, matching the existing line format. - -- `severity`, `check`, `source`, `detail` -- `caveat`: optional; populated when the basis is an open upstream discrepancy (criterion 10 makes - this mandatory-when-applicable, so it is a field rather than an ad-hoc sentence) - -**Oracle observation** — only if the debug-channel candidate ships (see the plan's open decision). - -- `rule`, `source_path`, `reason` parsed from the harness's own drop narration -- `agrees_with_prediction`: the cross-check result against `auto_mode_drop` - -## What this early exit does not cover - -- **Skill naming.** Still owed against `MIGRATION-PLAYBOOK.md` §Naming, with the live constraint that - `PLUGIN-PHILOSOPHY.md:41-46` requires the namespace noun to be true of every skill under it. Naming - is not a design thread in the Tier A sense; it is resolved in the plan. -- **The runtime prerequisite.** A non-strict JSON parser implies Python or Node, which - `PLUGIN-PHILOSOPHY.md:387-397` and `:436-447` govern. Decided in the plan, not here. diff --git a/docs/topics/permission-model/phase0-fresh-docs.md b/docs/topics/permission-model/phase0-fresh-docs.md deleted file mode 100644 index 764665cc4d..0000000000 --- a/docs/topics/permission-model/phase0-fresh-docs.md +++ /dev/null @@ -1,161 +0,0 @@ -# Phase 0 — fresh-docs mandate, discharged - -Every fact the two skills ship is re-confirmed here against pages fetched **2026-08-10**, per -`CLAUDE.md`'s fresh-docs mandate. Facts carried from the 2026-08-09 local capture are not accepted as -verified; each row below says which page it came from, or says the page does not state it. - -Pages fetched this session, all from the `docs/OFFICIAL-DOCS.md` index: - -- -- -- -- - -## Confirmed — safe to ship - -| Fact | Source | Wording | -|---|---|---| -| Scope precedence | settings | Managed (highest) → command line → local → project → user (lowest) | -| Rule evaluation order | permissions | "Rules are evaluated in order: deny, then ask, then allow. The first match in that order determines the outcome, and rule specificity doesn't change the order." | -| `autoMode` scope set | auto-mode-config | Read from `~/.claude/settings.json`, managed settings, and `--settings`/Agent SDK inline JSON. "The classifier doesn't read `autoMode` from project settings in `.claude/settings.json` or `.claude/settings.local.json`." | -| `autoMode` local-settings gate | auto-mode-config | "Before v2.1.207, the classifier also read `.claude/settings.local.json`" | -| Criterion 3's four drop classes | permission-modes | "On entering auto mode, broad allow rules that grant arbitrary code execution are dropped: Blanket `Bash(*)` or `PowerShell(*)`; Wildcarded interpreters like `Bash(python*)`; Package-manager run commands; `Agent` allow rules." Plus "Narrow rules like `Bash(npm test)` carry over. Dropped rules are restored when you leave auto mode." | -| `defaultMode: "auto"` gate | permission-modes | "Claude Code ignores `defaultMode: \"auto\"` in project and local settings." | -| `disableAutoMode` shape | permissions | "set `permissions.disableBypassPermissionsMode` or `permissions.disableAutoMode` to `\"disable\"` in any settings file" — the **string**, and **any** scope, confirming it is not managed-only | -| `:*` position rule | permissions | "The `:*` form is only recognized at the end of a pattern. In a pattern like `Bash(git:* push)`, the colon is treated as a literal character and won't match git commands." | -| Start-directory `settings.local.json` | settings | "Before v2.1.211, the file always lived in the starting directory. Claude Code still reads a `.claude/settings.local.json` that an earlier version left there. When both files set the same key, the repository root's value wins, **except that permission rules from both files stay in effect**." | -| Hook cannot override deny/ask | permissions | "Hook decisions don't bypass permission rules… a matching deny rule blocks the call, and a matching ask rule still prompts even when the hook returned `\"allow\"`" — independently corroborates the local four-leg experiment | -| No `allowManagedAutoModeRulesOnly` | all four pages | Zero occurrences. Affirmatively supported rather than merely absent: "A developer can extend `environment`, `allow`, `soft_deny`, and `hard_deny` with personal entries but can't remove entries that managed settings provide… a developer-added `allow` entry can override an organization `soft_deny` entry: the combination is additive, not a hard policy boundary," alongside "For actions that must never run regardless of user intent or classifier configuration, use `permissions.deny` in managed settings, which… can't be overridden." | - -That last row **upgrades** the claim's status. The plan flagged it as resting on an unverified research -slice; it now rests on the governing page. Phase 6's caveat can be narrowed to the precise wording: -managed `autoMode` entries cannot be **removed**, but a developer `allow` **can** override an -organization `soft_deny`, so managed auto-mode rules are not a hard policy boundary. - -## Corrections — the plan was wrong or incomplete - -1. **Managed policy is not two JSON files, and on Windows it is partly the registry.** The settings - page enumerates: macOS — the `com.anthropic.claudecode` managed-preferences domain (a plist), - `/Library/Application Support/ClaudeCode/managed-settings.json`, and a - `managed-settings.d/` directory; Linux and WSL — `/etc/claude-code/managed-settings.json` and - `/etc/claude-code/managed-settings.d/`; Windows — `HKLM\SOFTWARE\Policies\ClaudeCode`, - `HKCU\SOFTWARE\Policies\ClaudeCode`, `C:\Program Files\ClaudeCode\managed-settings.json`, and a - `managed-settings.d/` directory. **Phase 1 cannot read the managed scope with `jq` over a fixed - pair of paths.** It needs a per-OS reader covering a plist domain, a registry hive, and a - drop-in directory whose file count is unknown ahead of time. This is the single largest scope - change Phase 0 produced. -2. **Legacy Windows managed path is dead.** "The legacy Windows path - `C:\ProgramData\ClaudeCode\managed-settings.json` is no longer supported as of v2.1.75." Reading it - would report policy that is not in force. -3. **`.claude/settings.local.json` resolves through worktrees to the main checkout.** "Claude Code - reads and writes this file at the root of the git repository, resolved through worktrees to the - main checkout, so one file covers sessions started in any subdirectory or worktree." Three stated - exceptions keep it in the start directory: outside a git repository, when the repository root is - the home directory, and in Agent SDK sessions. Phase 1's scope discovery must resolve the worktree - rather than assuming the current root — this very topic is being planned inside a worktree, so the - case is live, not hypothetical. -4. **`autoMode.classifyAllShell` exists and no criterion covers it.** Requires v2.1.193 or later; - when `true` it "suspend[s] every Bash and PowerShell allow rule while auto mode is active." That - changes criterion 3's answer wholesale — with it on, narrow rules do **not** carry over. An audit - reporting the drop set without reading this key can be exactly wrong. Gap to close in Phase 4. -5. **`claude auto-mode reset` needs v2.1.212+** and "removes the `autoMode` section from your user - settings file," asking `Reset auto mode configuration to defaults?` unless `--yes` is passed. The - standing prohibition on running it against the operator's config is reinforced, not relaxed. - -## Open upstream discrepancies — criterion 10 caveats - -- **`defaults --label` on a non-matching key.** The page states "sections with no match print as empty - lists." The 2026-08-09 local capture measured the key **omitted entirely**. Both cannot be true. - The defensive contract already tolerates a missing key, so the code is safe either way, but the - divergence is now documented rather than folkloric and any finding derived from it carries the - caveat. -- **`permissions.ask` under auto mode.** Issues #83766 and #42797 report ask patterns auto-approved. - The page contradicts them: content-scoped ask rules "always force a permission prompt, even in auto - mode… The classifier cannot auto-approve a matching action." The discrepancy stands; the caveat the - Brief already requires stays. - -## Not stated — must not ship as fact - -- **The `v2.1.142` gate** on project-scope `defaultMode` ("project could set it before v2.1.142") - appears on none of the four pages. Criterion 2 asserts it. Either relocate it to a page that states - it, or ship the gate without the version number and caveat it. -- **Which scopes read `useAutoModeDuringPlan`.** The setting is confirmed to exist and to be on by - default; no page states it is not read from shared project settings, which is what criterion 2's - third item claims. -- **`Write(path)`-shaped rules "accepted but never consulted."** The permissions page states something - adjacent but different: rules matching a tool's *primary content field* by parameter — `Bash(command:…)`, - `Write(file_path:…)` — are ignored **and emit a startup warning**. That is a different mechanic with - a different observable. Criterion 6's fourth item needs re-deriving against this wording before it - ships. - -The startup warning in that last item is itself useful: it is a readable signal carrying rule text, -one of the channels the Brief listed as unexplored. - -## Addendum — 2026-08-10, managed-surface detail Phase 9 needed - -Same page (), re-fetched while building Phase 9's shared -managed-scope enumeration. These four facts were not in the original pass and each changes what a -reader must do: - -| Fact | Wording | -|---|---| -| Drop-in merge order **is** documented | "Following the systemd convention, `managed-settings.json` is merged first as the base, then all `*.json` files in the drop-in directory are sorted alphabetically and merged on top. Later files override earlier ones for scalar values, arrays are concatenated and de-duplicated, and objects are deep-merged. Hidden files starting with `.` are ignored." | -| The Windows policy key holds JSON in one **value** | `HKLM\SOFTWARE\Policies\ClaudeCode` "registry key with a `Settings` value (REG_SZ or REG_EXPAND_SZ) containing JSON" — a reader wants that value, not the key's subkeys | -| `HKCU` is **not** a peer of `HKLM` | `HKCU\SOFTWARE\Policies\ClaudeCode` is "lowest policy priority, only used when no admin-level source exists". Merging both would report policy that is not in force | -| A managed source exists that no local reader can see | "Server-managed settings: delivered remotely at sign-in from Anthropic's servers via the claude.ai admin console or from a self-hosted Claude apps gateway" | - -Consequences carried into the plan: - -- Phase 1 can state drop-in merge results as **decided**, not caveated — the ordering is documented. - The `$defaults`-style caveat the Brief's decidability bound calls for does not apply here. -- Phase 1's Windows registry leg reads the `Settings` value and consults `HKCU` **only when no - admin-level key exists** — key existence, not value readability, ends the search. Measured - 2026-08-11: `reg query /v Settings` returns the same exit code and the same message for a - missing key and for a present key with no such value, so keying the search on the `/v` form would - let an `HKLM` key with an unreadable value fall through and report user-level policy as the managed - policy. A bare `reg query ` does distinguish the two (exit 0 when the key exists), so that is - the existence probe; an existing key that yields nothing readable is reported unread. -- Phase 6's managed-conformance report carries a standing caveat that server-managed settings are a - managed source with no local path, so "the deployed managed policy" always means the local - surfaces. A report that omits this implies a completeness it cannot have. - -## Addendum — 2026-08-11, the merge semantics Phase 2 rests on - -Phase 2 claims an *effective* permission set. Nothing in the table above says how rules from two -scopes combine, so the two governing sections were re-fetched before any merge was written -( §How scopes interact and - §Settings precedence). Verbatim: - -| Fact | Wording | -|---|---| -| Permission rules **merge**, they do not override | "For example, if your user settings set `spinnerTipsEnabled` to `true` and project settings set it to `false`, the project value applies. Permission rules behave differently because they merge across scopes rather than override, and a few security-sensitive settings honor a restrictive value from certain scopes that otherwise couldn't override them." | -| Managed permission rules cannot be overridden | "Permission rules follow the same settings precedence as all other Claude Code settings, with managed settings highest: no other level, including command line arguments, can override a managed permission rule." | -| Deny wins from **any** scope, in both directions | "If a tool is denied at any level, no other level can allow it… The same holds across settings scopes: if user settings allow a permission and project settings deny it, the deny rule blocks it. The reverse is also true: a user-level deny blocks a project-level allow, because deny rules from any scope are evaluated before allow rules." | -| A broad deny beats a narrower allow | "A broad deny rule like `Bash(aws *)` blocks every matching call, including calls that also match a narrower allow rule like `Bash(aws s3 ls)`, so a deny rule can't carry allowlist exceptions. The same precedence applies between ask and allow." | -| The command-line scope is a real scope | "**Command line arguments**: temporary session overrides" — ranked second, above local, project and user | -| `/permissions` already shows rules and their source file | "You can view and manage Claude Code's tool permissions with `/permissions`. This UI lists all permission rules and the `settings.json` file each rule comes from." | - -Consequences carried into the plan: - -- **There is no same-kind winner to elect.** Because rules merge rather than override, a rule text - present in the same list at two scopes has both entries in effect; naming one as *the* origin would - be a precedence claim no page supports. Provenance for that case is the full contributor set. A - winner exists only **across kinds**, and the mechanic that elects it is evaluation order, which the - wording above makes explicitly scope-independent in both directions. -- **The start-directory copy never needs ranking against project settings.** That rank is undocumented, - and with no same-kind election it is never consulted. -- **Pattern subsumption is a known false-positive class, not an unknown.** A merge over exact rule text - reports `Bash(aws s3 ls)` as an effective allow even where `Bash(aws *)` is denied, because the page - documents that the broad deny wins. The direction is known — over-reporting allow — so the caveat - states it rather than pleading undecidability. -- **The command-line scope is invisible to any file reader**, so an effective-set claim is bounded to - what the settings files define. This is a second invisible source alongside server-managed settings. -- **`/permissions` is prior art and the skill must stop overclaiming.** It lists rules with their - source file interactively. It does not resolve deny-over-allow across scopes, does not distinguish a - scope that was empty from one that could not be read, and is not scriptable. The skill's framing is - narrowed to that difference rather than claiming there is no way to see rules at all. - -## Version constants cleared for use - -`v2.1.75`, `v2.1.193`, `v2.1.198`, `v2.1.200`, `v2.1.203`, `v2.1.207`, `v2.1.208`, `v2.1.211`, -`v2.1.212` — each appears verbatim on a page fetched above. **`v2.1.142` is not cleared.** diff --git a/plugins/claude-config/lib/managed-scope.test.sh b/plugins/claude-config/lib/managed-scope.test.sh index a592d1cb93..e81e6e9192 100644 --- a/plugins/claude-config/lib/managed-scope.test.sh +++ b/plugins/claude-config/lib/managed-scope.test.sh @@ -73,13 +73,13 @@ assert_eq "override relocates the drop-in dir with it" \ # --- Non-file surfaces, emitted only where they exist ------------------------- win_keys="$(OSTYPE=msys bash -c "source '$LIB'; mscope::registry_keys")" -# portability-ok: the `\S` below is the literal first character of SOFTWARE in a -# single-quoted Windows registry path, not a GNU regex escape. The assertions are +# portability-ok: the `\S` below are literal first characters of SOFTWARE in +# single-quoted Windows registry paths, not GNU regex escapes. The assertions are # shell string comparisons; no regex engine sees these values. assert_contains "Windows emits the admin-level policy key" "$win_keys" 'HKLM\SOFTWARE\Policies\ClaudeCode' -assert_contains "Windows emits the user-level policy key" "$win_keys" 'HKCU\SOFTWARE\Policies\ClaudeCode' +assert_contains "Windows emits the user-level policy key" "$win_keys" 'HKCU\SOFTWARE\Policies\ClaudeCode' # portability-ok: Windows registry path string in a test fixture, not a regex construct assert_eq "HKLM is listed first (HKCU is lowest policy priority)" \ - 'HKLM\SOFTWARE\Policies\ClaudeCode' "$(printf '%s\n' "$win_keys" | head -1)" + 'HKLM\SOFTWARE\Policies\ClaudeCode' "$(printf '%s\n' "$win_keys" | head -1)" # portability-ok: Windows registry path string in a test fixture, not a regex construct assert_eq "no registry keys off Windows" "" \ "$(OSTYPE=linux-gnu bash -c "source '$LIB'; mscope::registry_keys")" assert_eq "macOS preferences domain" "com.anthropic.claudecode" \ diff --git a/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh b/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh index 0930d67330..57ba41a378 100755 --- a/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh +++ b/plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.sh @@ -76,6 +76,15 @@ case "${1:-}" in *) ;; esac +canonical_path() { + local p="$1" + if [[ -e "$p" ]]; then + printf '%s/%s' "$(cd "$(dirname "$p")" && pwd -P)" "$(basename "$p")" + else + printf '%s' "$p" + fi +} + if ! command -v jq >/dev/null 2>&1; then echo "ERROR: jq required" >&2 exit 2 @@ -281,7 +290,13 @@ else USER_CONFIG_ROOT="" fi if [[ -n "$USER_CONFIG_ROOT" ]]; then - scan_settings_allow "$USER_CONFIG_ROOT/settings.json" "$USER_CONFIG_ROOT/settings.json permissions.allow" + user_settings="$USER_CONFIG_ROOT/settings.json" + project_settings="$ROOT/.claude/settings.json" + user_canonical="$(canonical_path "$user_settings")" + project_canonical="$(canonical_path "$project_settings")" + if [[ "$user_canonical" != "$project_canonical" ]]; then + scan_settings_allow "$user_settings" "$user_settings permissions.allow" + fi else # An unresolvable user scope is a skipped check, not a clean one. Silence here # would let a report claiming "no fragile grants" rest on a scope never read. diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh index c228ab8e7b..4f0d159958 100644 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh @@ -18,6 +18,8 @@ # CAVEAT: what bounds the claim # effective scopes= precedence_basis= one per live rule # inert scopes= outranked_by= one per beaten entry +# inert scopes= removed_by=deny@ whole-tool deny removal +# inert scopes= outranked_by=ask@ whole-tool ask removal # # token uncontested | merged-across-scopes | evaluation-order # | evaluation-order+merged-across-scopes @@ -47,7 +49,9 @@ Usage: permission-state.sh | permission-merge.sh [--merge-only] --help this message Records: "effective scopes= precedence_basis= ", -"inert scopes= outranked_by= ", and "CAVEAT: ". +"inert scopes= outranked_by= ", +"inert scopes= removed_by=deny@ ", +"inert scopes= outranked_by=ask@ ", and "CAVEAT: ". With --merge-only the reader's own NOTE records are dropped, including the one stating that server-managed settings have no local path. Read both sections when diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh index a6c30066b5..54c80cb434 100644 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh @@ -186,6 +186,17 @@ classify_json_file() { printf 'invalid-json\n' return 0 } + tr -d '\r' <"$path" | jq -e ' + (.permissions | type) as $pt + | if $pt == "null" then true + elif $pt == "object" then + (.permissions | to_entries[] | .value | type) as $kt + | ($kt == "null" or $kt == "array") + else false end + ' >/dev/null 2>&1 || { + printf 'invalid-json\n' + return 0 + } printf 'present\n' } @@ -277,7 +288,7 @@ else # line; cut at the type token rather than by field count, because the JSON # payload contains spaces. reg_json="$(reg_cmd query "$registry_path" /v Settings 2>/dev/null | tr -d '\r' | - sed -n 's/.*REG_\(EXPAND_\)\{0,1\}SZ[[:space:]]*//p' | head -1)" + sed -n 's/^[[:space:]]*Settings[[:space:]]*REG_\(EXPAND_\)\{0,1\}SZ[[:space:]]*//p' | head -1)" if [[ -z "$reg_json" ]] || ! printf '%s' "$reg_json" | jq empty 2>/dev/null; then note "Windows managed policy key $registry_path carries a Settings value that did not parse as JSON — reporting it as unread rather than as empty." else diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh index 436d90f4f2..aef5c91fe5 100644 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh @@ -179,6 +179,8 @@ for tool in jq git tr find sort sed head grep cat mktemp rm; do chmod +x "$STUB/$tool" done rc=0 +# portability-ok: `\S` in the Windows registry path strings below are literal +# backslash-S characters in fixture data, not GNU regex escapes. OUT_NOREG=$(env -u CLAUDE_CONFIG_DIR PATH="$STUB" HOME="$FX/home" \ PERMISSION_STATE_FIXTURE_DIR="$FX/proj" \ PERMISSION_STATE_STARTDIR="$FX/startdir" \ @@ -203,6 +205,8 @@ assert_contains "other scopes unaffected" "$OUT_NOREG" "rule user settings allow # cannot exist and HKCU\SOFTWARE, which exists on every Windows install and # carries no Settings value — exactly the key-present/value-absent case. if command -v reg >/dev/null 2>&1; then + # portability-ok: `\S` in the printf registry key strings are literal path + # separators in Windows registry paths, not GNU regex escapes. OUT_REG=$(env -u CLAUDE_CONFIG_DIR HOME="$FX/home" \ PERMISSION_STATE_FIXTURE_DIR="$FX/proj" \ PERMISSION_STATE_STARTDIR="$FX/startdir" \ @@ -210,10 +214,11 @@ if command -v reg >/dev/null 2>&1; then PERMISSION_STATE_REGISTRY_KEYS="$(printf 'HKCU\\SOFTWARE\\ClaudeCodeNoSuchKeyExists\nHKCU\\SOFTWARE')" \ PERMISSION_STATE_PLIST_DOMAIN="" \ bash "$SCRIPT") - assert_contains "an absent key is skipped, the existing one is selected" "$OUT_REG" "managed registry unreadable HKCU\\SOFTWARE" + assert_contains "an absent key is skipped, the existing one is selected" "$OUT_REG" "managed registry unreadable HKCU\\SOFTWARE" # portability-ok: Windows registry path string in a test fixture, not a regex construct assert_contains "a key with no readable value is not a licence to fall through" "$OUT_REG" "Lower-priority policy keys are NOT consulted" assert_eq "no rules are claimed from an unreadable key" "0" "$(count_matching "$OUT_REG" '^rule managed registry ')" + # portability-ok: Windows registry path strings in the printf below are fixture data, not regex constructs. OUT_REG_NONE=$(env -u CLAUDE_CONFIG_DIR HOME="$FX/home" \ PERMISSION_STATE_FIXTURE_DIR="$FX/proj" \ PERMISSION_STATE_STARTDIR="$FX/startdir" \ From 1740735f59792b7815c1e120c72a602c6bfc4570 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 01:47:15 +0000 Subject: [PATCH 11/14] =?UTF-8?q?fix(claude-config):=20hygiene=20=E2=80=94?= =?UTF-8?q?=20exec=20bits,=20typos,=20portable=20test=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kyle Sexton --- .../scripts/permission-merge.test.sh | 24 +++++++++---------- .../scripts/permission-state.sh | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh index 7a9a428f72..6ed4c717d1 100644 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh @@ -53,7 +53,7 @@ merge() { printf '%s\n' "$1" | bash "$SCRIPT" --merge-only; } # project settings deny it, the deny rule blocks it." CROSS_KIND=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /.claude/settings.json project settings present /proj/.claude/settings.json rule user settings allow Bash(git status) rule project settings deny Bash(git status) @@ -69,7 +69,7 @@ assert_contains "the beaten allow is reported inert" "$OUT" "inert allow scopes= # deny still blocks a project allow. REVERSE=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /.claude/settings.json project settings present /proj/.claude/settings.json rule user settings deny Bash(git push) rule project settings allow Bash(git push) @@ -82,7 +82,7 @@ assert_contains "the project allow is inert, not the winner" "$OUT" "inert allow # ask beats allow by the same mechanic. ASK=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /.claude/settings.json project settings present /proj/.claude/settings.json rule user settings allow WebFetch rule project settings ask WebFetch @@ -99,7 +99,7 @@ assert_contains "the allow beneath an ask is inert" "$OUT" "inert allow scopes=u SAME_KIND=$( cat <<'EOF' managed file present /policy/managed-settings.json -user settings present /home/.claude/settings.json +user settings present /.claude/settings.json project settings present /proj/.claude/settings.json rule managed file deny Read(./.env) rule user settings deny Read(./.env) @@ -114,7 +114,7 @@ assert_not_contains "nothing is reported as beaten when nothing lost" "$OUT" "in # Both mechanics at once stay both, rather than one silently swallowing the other. BOTH=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /.claude/settings.json project settings present /proj/.claude/settings.json rule user settings deny Bash(curl *) rule project settings deny Bash(curl *) @@ -132,7 +132,7 @@ assert_contains "both mechanics are cited when both applied" "$OUT" "precedence_ BARE=$( cat <<'EOF' managed file present /policy/managed-settings.json -user settings present /home/.claude/settings.json +user settings present /.claude/settings.json rule managed file deny Bash rule user settings allow Bash(git status) rule user settings deny Bash(rm *) @@ -150,7 +150,7 @@ assert_contains "removal is announced, not just implied" "$OUT" "removes Bash fr END_CONV=$( cat <<'EOF' managed file present /policy/managed-settings.json -user settings present /home/.claude/settings.json +user settings present /.claude/settings.json rule managed file deny EndConversation rule user settings allow EndConversation(x) EOF @@ -163,7 +163,7 @@ assert_contains "its scoped rule stays effective" "$OUT" "effective allow scopes # take effect — the ask/allow half of the same mechanic. BARE_ASK=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /.claude/settings.json project settings present /proj/.claude/settings.json rule user settings ask WebFetch rule project settings allow WebFetch(domain:example.com) @@ -177,7 +177,7 @@ assert_eq "and it is not also reported effective" 0 "$(count_matching "$OUT" '^e MIXED=$( cat <<'EOF' managed file present /policy/managed-settings.json -user settings present /home/.claude/settings.json +user settings present /.claude/settings.json project settings present /proj/.claude/settings.json rule managed file deny Read(./.env) rule user settings allow Bash(git status) @@ -211,7 +211,7 @@ assert_contains "one scope, however many of its surfaces carry the rule" "$OUT" # --- Case 5: rule text containing spaces survives intact ---------------------- SPACED=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /.claude/settings.json rule user settings allow Bash(git commit -m *) EOF ) @@ -223,7 +223,7 @@ STATUSES=$( cat <<'EOF' managed registry skipped - managed plist not-applicable - -user settings absent /home/.claude/settings.json +user settings absent /.claude/settings.json project settings invalid-json /proj/.claude/settings.json local settings unreadable /proj/.claude/settings.local.json startdir-local settings not-applicable /start/.claude/settings.local.json @@ -265,7 +265,7 @@ assert_contains "--help documents the merge-only mode" "$help_out" "--merge-only # --- Case 9: pass-through is the default, and is suppressible ----------------- PASS_IN=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /.claude/settings.json NOTE: something the operator must know rule user settings allow Bash(ls) EOF diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh index 54c80cb434..77f7ae70cf 100644 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh @@ -135,7 +135,7 @@ fi # Three documented exceptions keep the file in the start directory: outside a git # repository, when the repository root is the home directory, and in Agent SDK # sessions. The first two are detectable here; the third is not, so it is stated -# rather than silently mis-resolved. +# rather than silently wrongly resolved. LOCAL_ROOT="$PROJECT_ROOT" local_basis="repository root" if [[ -z "${PERMISSION_STATE_FIXTURE_DIR:-}" ]]; then From 2413b6b48cc103f1a20d6e0087337b8dd1a8a525 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 01:47:26 +0000 Subject: [PATCH 12/14] fix(claude-config): mark audit-permission-state shell scripts executable Co-authored-by: Kyle Sexton --- plugins/claude-config/lib/managed-scope.test.sh | 0 plugins/claude-config/lib/permission-patterns.test.sh | 0 .../skills/audit-permission-state/scripts/permission-merge.sh | 0 .../audit-permission-state/scripts/permission-merge.test.sh | 0 .../skills/audit-permission-state/scripts/permission-state.sh | 0 .../audit-permission-state/scripts/permission-state.test.sh | 0 6 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 plugins/claude-config/lib/managed-scope.test.sh mode change 100644 => 100755 plugins/claude-config/lib/permission-patterns.test.sh mode change 100644 => 100755 plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh mode change 100644 => 100755 plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh mode change 100644 => 100755 plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh mode change 100644 => 100755 plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh diff --git a/plugins/claude-config/lib/managed-scope.test.sh b/plugins/claude-config/lib/managed-scope.test.sh old mode 100644 new mode 100755 diff --git a/plugins/claude-config/lib/permission-patterns.test.sh b/plugins/claude-config/lib/permission-patterns.test.sh old mode 100644 new mode 100755 diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh old mode 100644 new mode 100755 diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh old mode 100644 new mode 100755 diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh old mode 100644 new mode 100755 diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.test.sh old mode 100644 new mode 100755 From 9386daa4e96273ada4cdabf6a28bfe17be84232e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 01:54:28 +0000 Subject: [PATCH 13/14] fix(claude-config): preserve exact rule text in permission-merge comparisons Co-authored-by: Kyle Sexton --- .../scripts/permission-merge.sh | 15 ++++++++++----- .../scripts/permission-merge.test.sh | 13 +++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) mode change 100755 => 100644 plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh mode change 100755 => 100644 plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh old mode 100755 new mode 100644 index 4f0d159958..148af43367 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh @@ -91,10 +91,15 @@ fi # error, never an empty merge — and the output is held until that is known, so a # failed run never emits a half-written merge section ahead of its own error. merged="$(printf '%s\n' "$records" | awk -v passthrough="$passthrough" ' -function text_of(start, i, s) { - s = $start - for (i = start + 1; i <= NF; i++) s = s " " $i - return s +function rule_text(line, i, start) { + start = 1 + for (i = 1; i <= 4; i++) { + if (match(substr(line, start), /^[^ ]+/)) { + start += RLENGTH + while (substr(line, start, 1) == " ") start++ + } else return "" + } + return substr(line, start) } # The tool token is everything before the first "(" — "Bash(rm *)" is a rule @@ -108,7 +113,7 @@ function tool_of(t, p) { p = index(t, "("); return p ? substr(t, 1, p - 1) : t $1 == "rule" { kind = $4 scope = $2 - text = text_of(5) + text = rule_text($0) if (!(text in text_seen)) { text_seen[text] = 1; text_order[++n_texts] = text } tool[text] = tool_of(text) if (text == tool[text]) { diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh old mode 100755 new mode 100644 index 6ed4c717d1..f16a2c437c --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh @@ -218,6 +218,19 @@ EOF OUT=$(merge "$SPACED") assert_contains "spaces inside a rule are preserved" "$OUT" "precedence_basis=uncontested Bash(git commit -m *)" +# Internal whitespace is significant: collapsing it would treat distinct rules as one. +DOUBLE_SPACE=$( + cat <<'EOF' +user settings present /.claude/settings.json +project settings present /proj/.claude/settings.json +rule user settings allow Bash(echo hi) +rule project settings deny Bash(echo hi) +EOF +) +OUT=$(merge "$DOUBLE_SPACE") +assert_eq "double-space allow and single-space deny stay distinct rules" 2 "$(count_matching "$OUT" '^effective ')" +assert_contains "the allow is not falsely inert against a different-text deny" "$OUT" "effective allow scopes=user precedence_basis=uncontested Bash(echo hi)" + # --- Case 6: unread surfaces bound the claim; empty ones do not --------------- STATUSES=$( cat <<'EOF' From 797f2eb1d50a4cee8a9cba7c1a021deddab25646 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 01:54:35 +0000 Subject: [PATCH 14/14] fix(claude-config): restore exec bit on permission-merge scripts Co-authored-by: Kyle Sexton --- .../skills/audit-permission-state/scripts/permission-merge.sh | 0 .../audit-permission-state/scripts/permission-merge.test.sh | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh mode change 100644 => 100755 plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh old mode 100644 new mode 100755 diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.test.sh old mode 100644 new mode 100755