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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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 de99df90afc675db1cdd27c4cf6476b7f26b0202 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:58:48 -0400 Subject: [PATCH 10/32] fix(claude-config): satisfy the hygiene and portability gates on the new scripts Four CI lanes were red on this branch, all on gates that had never run against these files before. Every shipped script carried index mode 100644 despite a shebang. The exec-bit gate names them individually; the two define-only libraries stay 644, correctly, since they have no shebang and are only ever sourced. The spell checker splits hyphenated compounds, so "mis-resolved" and "mis-cited" each read as the non-word "mis". Reworded rather than suppressed -- "resolved to the wrong place" and "cited the wrong section" are also plainer. Fixture records in the merge suite used /home/.claude/settings.json, which the machine-specific-path detector reads as a real Linux user path. They are synthetic strings in heredoc records, never touched on disk, so they move to an unmistakably fake /fx/home. The two places that write a real file under the temp fixture home are unchanged. The portability gate flagged Windows registry key literals as regex escapes. They are neither -- they are key paths compared as strings -- so each site takes the sanctioned per-site `portability-ok:` escape. The four inside multi-line `env` continuations could not carry a comment where they stood, so they are hoisted to named variables that can. The oracle fixture capture is a different case: it reproduces Claude Code's own [DEBUG] narration byte for byte, and that narration prints Windows paths in native form, so rewriting it to POSIX form would make the fixture stop matching what the parser must survive. That file takes a whole-file `portability-scope:` declaration stating exactly that. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/topics/permission-model/PLAN.md | 2 +- .../claude-config/lib/managed-scope.test.sh | 6 +- .../lib/permission-patterns.test.sh | 0 .../scripts/automode-entry-diff.sh | 277 +++++++++++++++ .../scripts/automode-entry-diff.test.sh | 316 ++++++++++++++++++ .../scripts/permission-merge.sh | 4 +- .../scripts/permission-merge.test.sh | 24 +- .../scripts/permission-state.sh | 32 +- .../scripts/permission-state.test.sh | 18 +- 9 files changed, 655 insertions(+), 24 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 create mode 100755 plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.sh create mode 100755 plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.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/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index 78fd57fa3f..9e54c21009 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -821,7 +821,7 @@ The remaining seventeen clustered into four honest weaknesses, all now corrected - **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 +- **Governance skipped, or cited against the wrong section.** 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 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 index a592d1cb93..f9cf1c1937 --- a/plugins/claude-config/lib/managed-scope.test.sh +++ b/plugins/claude-config/lib/managed-scope.test.sh @@ -76,10 +76,10 @@ 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_contains "Windows emits the admin-level policy key" "$win_keys" 'HKLM\SOFTWARE\Policies\ClaudeCode' # portability-ok: a Windows registry key path, compared as a literal string; no regex engine sees it +assert_contains "Windows emits the user-level policy key" "$win_keys" 'HKCU\SOFTWARE\Policies\ClaudeCode' # portability-ok: a Windows registry key path, compared as a literal string; no regex engine sees it 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: a Windows registry key path, compared as a literal string; no regex engine sees it 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/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/automode-entry-diff.sh b/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.sh new file mode 100755 index 0000000000..aba413e7a3 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +# automode-entry-diff.sh — what entering auto mode does to the effective allow +# set, per rule, with the drop reason named. +# +# "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. Narrow rules +# like Bash(npm test) carry over." This script classifies every effective allow +# rule from the merge into exactly one of those documented classes or `kept`, +# using the same shared vocabulary (lib/permission-patterns.sh) that +# audit-permission-grants check P1 scans with — one definition, two consumers. +# +# `autoMode.classifyAllShell` inverts the carry-over answer wholesale: when +# true it "suspend[s] every Bash and PowerShell allow rule while auto mode is +# active", so narrow rules do NOT carry over. The classifier reads `autoMode` +# from user settings, managed settings, and inline --settings/SDK JSON only — +# never project or local settings — so this script resolves the key from the +# managed and user conf records alone and says so when a non-read scope sets it. +# +# Input: permission-merge.sh output on stdin (records pass through above the +# diff section unless --diff-only). With no piped input the sibling pipeline is +# run directly and its exit status is propagated. +# +# Output (diff section): +# DIFF-NOTE: classifyAllShell state, bounds +# entry-diff dropped class= scopes= one per dropped allow rule +# entry-diff suspended reason=classifyAllShell scopes= +# entry-diff kept scopes= one per carried-over allow rule +# entry-diff summary allow_before= dropped= suspended= kept= +# +# class blanket | interpreter-wildcard | package-manager-run | agent +# (the documented four; the vocabulary's script-glob alternative is an +# interpreter-wildcard shape and reports as that class) +# +# Only allow rules change on entry: deny and ask rules are evaluated before the +# classifier in every mode and are not part of this diff. +# +# --oracle (opt-in, explicitly priced): cross-check the prediction against the +# harness's own drop narration by spawning `claude --debug-file -p` and +# parsing `Ignoring dangerous permission from (bypasses +# classifier)` lines. The prediction stays the default read path: the oracle +# costs a real session spawn (tokens, and the session writes its own state — see +# the cost notice) and parses undocumented [DEBUG] strings with no stability +# contract. Never spawned without the flag; never spawned silently. Criterion +# 8's defensive contract binds this read too: exit status is never trusted, a +# missing or empty capture is reported as "oracle unavailable" and the +# prediction stands — an empty capture is NEVER read as an empty drop set. +# +# Test seams: +# ENTRY_DIFF_ORACLE_CAPTURE parse this capture file instead of spawning +# ENTRY_DIFF_ORACLE_PROMPT probe prompt (default "Reply with exactly: OK") +# +# Prerequisites: POSIX text tools only. The oracle additionally needs `claude` +# on PATH; absent, it degrades to "oracle unavailable" and the prediction stands. +# +# Usage: +# permission-state.sh | permission-merge.sh | automode-entry-diff.sh +# automode-entry-diff.sh [--diff-only] [--oracle] [--help] + +set -uo pipefail + +usage() { + cat <<'EOF' +automode-entry-diff.sh — classify what entering auto mode drops from the effective allow set. + +Usage: permission-state.sh | permission-merge.sh | automode-entry-diff.sh [--diff-only] [--oracle] + automode-entry-diff.sh [--diff-only] [--oracle] [--help] + + (no arg) input records pass through, then the diff section + --diff-only the diff section alone + --oracle ALSO spawn a claude session (real token cost, real side effects — + a cost notice prints before anything is spawned) and cross-check + the prediction against the harness's own drop narration + --help this message + +Records: "entry-diff dropped class= scopes= ", +"entry-diff suspended reason=classifyAllShell scopes= ", +"entry-diff kept scopes= ", a closing "entry-diff summary" count line, +and with --oracle one "oracle AGREES " or "oracle DIVERGES ..." per compared rule. + +Reads only; writes nothing except the oracle's scratch capture. Exits 2 when the +input carries no records at all. +EOF +} + +diff_only=0 +oracle=0 +for arg in "$@"; do + case "$arg" in + -h | --help) + usage + exit 0 + ;; + --diff-only) diff_only=1 ;; + --oracle) oracle=1 ;; + *) + echo "ERROR: unknown argument '$arg'" >&2 + exit 2 + ;; + esac +done + +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 shared drop vocabulary is missing, so the drop set cannot be classified" >&2 + exit 2 +fi +# shellcheck source=../../../lib/permission-patterns.sh +source "$PATTERNS_LIB" + +if [[ -t 0 ]]; then + MERGE_SCRIPT="${BASH_SOURCE[0]%/*}/permission-merge.sh" + if [[ ! -r "$MERGE_SCRIPT" ]]; then + echo "ERROR: cannot read $MERGE_SCRIPT — nothing to diff" >&2 + exit 2 + fi + records="$(bash "$MERGE_SCRIPT")" || exit $? +else + records="$(cat)" +fi + +if [[ -z "$records" ]]; then + echo "ERROR: no records on input — automode-entry-diff.sh will not report a drop set it never read" >&2 + exit 2 +fi + +[[ "$diff_only" == 1 ]] || printf '%s\n' "$records" + +# --- classifyAllShell resolution -------------------------------------------- +# +# Managed is the highest settings scope, so a managed value wins over user. +# conf records from scopes the classifier does not read change nothing here and +# are called out instead of silently honored. +cas_value="" +cas_scope="" +cas_ignored="" +while read -r rec scope _surface key value _; do + [[ "$rec" == "conf" && "$key" == "classifyAllShell" ]] || continue + case "$scope" in + managed) + cas_value="$value" + cas_scope="managed" + ;; + user) + if [[ "$cas_scope" != "managed" ]]; then + cas_value="$value" + cas_scope="user" + fi + ;; + *) cas_ignored="${cas_ignored:+$cas_ignored, }$scope" ;; + esac +done <<<"$records" + +cas_active=0 +if [[ "$cas_value" == "true" ]]; then + cas_active=1 + echo "DIFF-NOTE: autoMode.classifyAllShell is true in $cas_scope settings — every Bash and PowerShell allow rule is suspended while auto mode is active, so narrow shell rules do NOT carry over. Requires Claude Code v2.1.193 or later; earlier versions ignore the key and carry narrow rules." +elif [[ -n "$cas_value" ]]; then + echo "DIFF-NOTE: autoMode.classifyAllShell in $cas_scope settings is '$cas_value', not the documented boolean true — treated as inactive here; the harness's handling of a non-boolean value is undocumented." +fi +if [[ -n "$cas_ignored" ]]; then + echo "DIFF-NOTE: autoMode.classifyAllShell also appears in scope(s) the classifier does not read ($cas_ignored) — the classifier reads autoMode from user settings, managed settings, and inline --settings/SDK JSON only. Those entries have no effect and are not part of this diff." +fi + +# --- Per-rule classification -------------------------------------------------- + +n_before=0 n_dropped=0 n_suspended=0 n_kept=0 +predicted_dropped="" +diff_lines="" +while read -r rec kind scopes_field _basis rule; do + [[ "$rec" == "effective" && "$kind" == "allow" && -n "$rule" ]] || continue + n_before=$((n_before + 1)) + tool="${rule%%(*}" + verdict="" + if [[ "$rule" == "Agent" || "$rule" == "Agent("* ]]; then + verdict="dropped class=agent" + elif printf '%s\n' "$rule" | grep -qE "$CCPERM_P1_BLANKET_ERE"; then + verdict="dropped class=blanket" + elif printf '%s\n' "$rule" | grep -qE "$CCPERM_P1_INTERP_ERE|$CCPERM_P1_SCRIPTGLOB_ERE"; then + verdict="dropped class=interpreter-wildcard" + elif printf '%s\n' "$rule" | grep -qE "$CCPERM_P1_RUNNER_ERE"; then + verdict="dropped class=package-manager-run" + elif [[ "$cas_active" == 1 && ("$tool" == "Bash" || "$tool" == "PowerShell") ]]; then + verdict="suspended reason=classifyAllShell" + fi + if [[ "$verdict" == dropped* ]]; then + n_dropped=$((n_dropped + 1)) + predicted_dropped="${predicted_dropped}${rule}"$'\n' + elif [[ "$verdict" == suspended* ]]; then + n_suspended=$((n_suspended + 1)) + predicted_dropped="${predicted_dropped}${rule}"$'\n' + else + verdict="kept" + n_kept=$((n_kept + 1)) + fi + diff_lines="${diff_lines}entry-diff $verdict $scopes_field $rule"$'\n' +done <<<"$records" + +printf '%s' "$diff_lines" +echo "entry-diff summary allow_before=$n_before dropped=$n_dropped suspended=$n_suspended kept=$n_kept" + +[[ "$oracle" == 1 ]] || exit 0 + +# --- Debug-channel oracle (opt-in, priced) ------------------------------------ + +capture="${ENTRY_DIFF_ORACLE_CAPTURE:-}" +if [[ -z "$capture" ]]; then + cat >&2 <<'EOF' +ORACLE COST NOTICE — nothing has been spawned yet. + --oracle starts a real `claude -p` session on this machine to capture the + harness's own drop narration. That costs API tokens, and the session leaves + state behind like any -p session: a transcript and project entry under your + Claude config directory (~/.claude by default) and updated internal state + files there. It does NOT modify any settings file. The debug capture itself + goes to a scratch path, never to ~/.claude/debug/. +EOF + if ! command -v claude >/dev/null 2>&1; then + echo "oracle UNAVAILABLE: 'claude' is not on PATH — the prediction above stands, uncorroborated." + exit 0 + fi + scratch="$(mktemp -d "${TMPDIR:-/tmp}/entry-diff-oracle.XXXXXX")" || { + echo "oracle UNAVAILABLE: could not create a scratch directory — the prediction above stands, uncorroborated." + exit 0 + } + capture="$scratch/capture.log" + # The probe session must be IN auto mode or the channel narrates no drops: + # measured 2026-08-11 on 2.1.225, the drop lines appear exactly when the + # session's permission mode is auto (via --permission-mode auto here, so the + # result does not depend on the consumer's defaultMode). Exit status is + # deliberately not consulted — the capture file is the evidence either way. + claude --debug-file "$capture" --permission-mode auto -p "${ENTRY_DIFF_ORACLE_PROMPT:-Reply with exactly: OK}" >/dev/null 2>&1 || true +fi + +if [[ ! -s "$capture" ]]; then + echo "oracle UNAVAILABLE: the capture at ${capture:-} is missing or empty — a session that produced no usable output is reported as exactly that, never as an empty drop set. The prediction above stands, uncorroborated." + exit 0 +fi +if ! grep -q 'Applying permission update' "$capture"; then + echo "oracle UNAVAILABLE: the capture carries no permission-merge narration (the undocumented [DEBUG] strings may have changed, or the session did not reach the merge) — the prediction above stands, uncorroborated." + exit 0 +fi + +# `Ignoring dangerous permission from (bypasses classifier)`. +# The rule text is everything up to the LAST " from ", because a rule may +# legitimately contain that word (`Bash(python3 import from x *)`) and a +# leftmost strip would truncate it there. `sed` alternation is leftmost-first +# with no greedy-suffix form, so the last occurrence is taken by anchoring a +# greedy `.*` on the prefix instead: `\(.*\) from ` consumes as much as it can +# before the final separator. The trailing path never contains " from ". +oracle_dropped="$(sed -n 's/^.*Ignoring dangerous permission \(.*\) (bypasses classifier).*$/\1/p' "$capture" | + sed 's/^\(.*\) from [^ ].*$/\1/' | LC_ALL=C sort -u)" + +if [[ -z "$oracle_dropped" ]]; then + echo "oracle NOTE: the session narrated the permission merge but emitted zero drop lines. If this machine has rules the prediction above says are dropped, the session was likely not in auto mode; treat the oracle as unavailable rather than as an empty drop set." + [[ -z "$(printf '%s' "$predicted_dropped")" ]] || exit 0 +fi + +# Compare on rule text over the union of both sets: one verdict line per rule, +# exactly — a disagreement in EITHER direction is a finding. +predicted_sorted="$(printf '%s' "$predicted_dropped" | LC_ALL=C sort -u)" +while IFS= read -r rule; do + [[ -n "$rule" ]] || continue + in_pred=0 + in_orc=0 + grep -qxF "$rule" <<<"$predicted_sorted" && in_pred=1 + grep -qxF "$rule" <<<"$oracle_dropped" && in_orc=1 + if [[ "$in_pred" == 1 && "$in_orc" == 1 ]]; then + echo "oracle AGREES $rule" + elif [[ "$in_pred" == 1 ]]; then + echo "oracle DIVERGES prediction=dropped oracle=kept $rule" + else + echo "oracle DIVERGES prediction=kept oracle=dropped $rule" + fi +done < <(printf '%s\n%s\n' "$predicted_sorted" "$oracle_dropped" | grep -v '^$' | LC_ALL=C sort -u) + +exit 0 diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.test.sh new file mode 100755 index 0000000000..92ac935d76 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.test.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +# Regression tests for automode-entry-diff.sh (self-contained — ships with the plugin). +# +# Classification cases feed hand-written merge records on stdin. Oracle cases +# use a fixture capture (ENTRY_DIFF_ORACLE_CAPTURE) or a recording stub claude +# on PATH — no case ever spawns a real session, and no test reads the +# operator's real ~/.claude. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/automode-entry-diff.sh" +STATE_SCRIPT="$SCRIPT_DIR/permission-state.sh" +MERGE_SCRIPT="$SCRIPT_DIR/permission-merge.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"; } + +diff_only() { printf '%s\n' "$1" | bash "$SCRIPT" --diff-only; } + +# --- Case 1: one rule of each documented drop class, plus a narrow survivor --- +# "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. Narrow rules +# like Bash(npm test) carry over." +FOUR_CLASSES=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +effective allow scopes=user precedence_basis=uncontested Bash(*) +effective allow scopes=user precedence_basis=uncontested Bash(python3 *) +effective allow scopes=user precedence_basis=uncontested Bash(npx *) +effective allow scopes=user precedence_basis=uncontested Agent +effective allow scopes=user precedence_basis=uncontested Bash(git status) +EOF +) +OUT=$(diff_only "$FOUR_CLASSES") +assert_contains "blanket Bash(*) is dropped" "$OUT" "entry-diff dropped class=blanket scopes=user Bash(*)" +assert_contains "a wildcarded interpreter is dropped" "$OUT" "entry-diff dropped class=interpreter-wildcard scopes=user Bash(python3 *)" +assert_contains "a package-manager run command is dropped" "$OUT" "entry-diff dropped class=package-manager-run scopes=user Bash(npx *)" +assert_contains "a bare Agent allow rule is dropped" "$OUT" "entry-diff dropped class=agent scopes=user Agent" +assert_contains "a narrow exact rule carries over" "$OUT" "entry-diff kept scopes=user Bash(git status)" +assert_eq "all four classes appear in the dropped set" 4 "$(count_matching "$OUT" '^entry-diff dropped ')" +assert_contains "the summary reconciles" "$OUT" "entry-diff summary allow_before=5 dropped=4 suspended=0 kept=1" + +# A SCOPED Agent rule is dropped too: auto mode drops all Agent allow rules +# categorically, unlike Bash where narrow rules survive. +SCOPED_AGENT=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +effective allow scopes=user precedence_basis=uncontested Agent(model:haiku) +EOF +) +OUT=$(diff_only "$SCOPED_AGENT") +assert_contains "a scoped Agent allow rule is dropped like a bare one" "$OUT" "entry-diff dropped class=agent scopes=user Agent(model:haiku)" + +# Deny and ask rules are evaluated before the classifier in every mode; the +# entry diff must not touch them. +DENY_ASK=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +effective deny scopes=user precedence_basis=uncontested Bash(*) +effective ask scopes=user precedence_basis=uncontested Agent +EOF +) +OUT=$(diff_only "$DENY_ASK") +assert_eq "deny and ask rules produce no diff records" 0 "$(count_matching "$OUT" '^entry-diff (dropped|suspended|kept) ')" +assert_contains "the summary shows an empty allow set, not a missing one" "$OUT" "entry-diff summary allow_before=0" + +# --- Case 2: classifyAllShell inverts the carry-over answer ------------------- +# "suspend[s] every Bash and PowerShell allow rule while auto mode is active." +CAS_ON=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +conf user settings classifyAllShell true +effective allow scopes=user precedence_basis=uncontested Bash(git status) +effective allow scopes=user precedence_basis=uncontested PowerShell(Get-ChildItem *) +effective allow scopes=user precedence_basis=uncontested WebFetch(domain:example.com) +effective allow scopes=user precedence_basis=uncontested Bash(*) +EOF +) +OUT=$(diff_only "$CAS_ON") +assert_contains "a narrow Bash rule is suspended, not kept" "$OUT" "entry-diff suspended reason=classifyAllShell scopes=user Bash(git status)" +assert_contains "PowerShell rules are suspended too" "$OUT" "entry-diff suspended reason=classifyAllShell scopes=user PowerShell(Get-ChildItem *)" +assert_contains "a non-shell rule is untouched by classifyAllShell" "$OUT" "entry-diff kept scopes=user WebFetch(domain:example.com)" +assert_contains "a blanket rule still reports its drop class, not suspension" "$OUT" "entry-diff dropped class=blanket scopes=user Bash(*)" +assert_contains "the inversion is announced with its version gate" "$OUT" "v2.1.193" + +# Managed is the highest scope: a managed false beats a user true. +CAS_MANAGED=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +user settings present /home/.claude/settings.json +conf managed file classifyAllShell false +conf user settings classifyAllShell true +effective allow scopes=user precedence_basis=uncontested Bash(git status) +EOF +) +OUT=$(diff_only "$CAS_MANAGED") +assert_eq "a managed false overrides a user true" 0 "$(count_matching "$OUT" '^entry-diff suspended ')" +assert_contains "the narrow rule stays kept" "$OUT" "entry-diff kept scopes=user Bash(git status)" + +# "The classifier doesn't read autoMode from project settings in +# .claude/settings.json or .claude/settings.local.json." +CAS_PROJECT=$( + cat <<'EOF' +project settings present /proj/.claude/settings.json +conf project settings classifyAllShell true +effective allow scopes=project precedence_basis=uncontested Bash(git status) +EOF +) +OUT=$(diff_only "$CAS_PROJECT") +assert_eq "a project-scope classifyAllShell suspends nothing" 0 "$(count_matching "$OUT" '^entry-diff suspended ')" +assert_contains "the ignored scope is named, not silently honored" "$OUT" "scope(s) the classifier does not read (project)" + +# A string "true" is not the documented boolean and must not activate. +CAS_STRING=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +conf user settings classifyAllShell "true" +effective allow scopes=user precedence_basis=uncontested Bash(git status) +EOF +) +OUT=$(diff_only "$CAS_STRING") +assert_eq "a string-typed value suspends nothing" 0 "$(count_matching "$OUT" '^entry-diff suspended ')" +assert_contains "the type mismatch is called out" "$OUT" "not the documented boolean true" + +# --- Case 3: pass-through default, --diff-only suppression -------------------- +PASS_IN=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +NOTE: something the operator must know +effective allow scopes=user precedence_basis=uncontested Bash(git status) +EOF +) +OUT=$(printf '%s\n' "$PASS_IN" | bash "$SCRIPT") +assert_contains "input records pass through by default" "$OUT" "NOTE: something the operator must know" +assert_contains "the diff section follows" "$OUT" "entry-diff kept scopes=user Bash(git status)" +OUT=$(diff_only "$PASS_IN") +assert_not_contains "--diff-only drops the input records" "$OUT" "NOTE: something the operator" + +# --- Case 4: no records is an error, never an empty diff ---------------------- +rc=0 +err_out=$(printf '' | bash "$SCRIPT" --diff-only 2>&1) || rc=$? +assert_exit "exit 2 on empty input" 2 "$rc" +assert_contains "the empty-input error says why" "$err_out" "no records on input" + +rc=0 +err_out=$(bash "$SCRIPT" --bogus &1) || rc=$? +assert_exit "exit 2 on an unknown argument" 2 "$rc" + +rc=0 +help_out=$(bash "$SCRIPT" --help &1) || rc=$? +assert_exit "--help exits 0" 0 "$rc" +assert_contains "--help prices the oracle" "$help_out" "real token cost" + +# --- Case 5: oracle OFF by default — no spawn, no scratch file ---------------- +# A recording stub claude sits FIRST on PATH; if anything invoked it, the +# marker file would exist. +STUB="$TEST_TMPDIR/stub" +mkdir -p "$STUB" +cat >"$STUB/claude" <>"$TEST_TMPDIR/claude-invocations.txt" +exit 0 +EOF +chmod +x "$STUB/claude" + +OUT=$(printf '%s\n' "$FOUR_CLASSES" | PATH="$STUB:$PATH" bash "$SCRIPT" --diff-only 2>&1) +if [[ -e "$TEST_TMPDIR/claude-invocations.txt" ]]; then + fail "no flag, no spawn" "stub claude was invoked: $(cat "$TEST_TMPDIR/claude-invocations.txt")" +else + pass "no flag, no spawn" +fi +assert_not_contains "no flag, no oracle output" "$OUT" "oracle" + +# --- Case 6: oracle ON spawns only after the cost notice ---------------------- +# The stub writes an empty capture, so this also proves a dead spawn is +# reported as unavailable rather than as an empty drop set. +OUT=$(printf '%s\n' "$FOUR_CLASSES" | PATH="$STUB:$PATH" bash "$SCRIPT" --diff-only --oracle 2>&1) +assert_contains "the cost notice printed" "$OUT" "ORACLE COST NOTICE" +if [[ -s "$TEST_TMPDIR/claude-invocations.txt" ]]; then + pass "with the flag, the spawn happened" +else + fail "with the flag, the spawn happened" "stub claude was never invoked" +fi +assert_contains "the stub session is in auto mode explicitly" "$(cat "$TEST_TMPDIR/claude-invocations.txt")" "--permission-mode auto" +assert_contains "an empty capture is unavailable, not an empty drop set" "$OUT" "oracle UNAVAILABLE" +assert_not_contains "no verdicts from a dead capture" "$OUT" "oracle AGREES" +rm -f "$TEST_TMPDIR/claude-invocations.txt" + +# claude missing entirely: the notice still precedes the (refused) spawn. +# Wrappers exec the real binaries by absolute path (a copied MSYS binary loses +# the msys-2.0.dll beside it); bash is invoked by absolute path so the stub +# PATH cannot hide the interpreter itself. +NOCLAUDE="$TEST_TMPDIR/noclaude" +mkdir -p "$NOCLAUDE" +real_bash="$(command -v bash)" +for tool in cat grep sed sort mktemp rm tr; do + src="$(command -v "$tool" 2>/dev/null)" || continue + printf '#!%s\nexec "%s" "$@"\n' "$real_bash" "$src" >"$NOCLAUDE/$tool" + chmod +x "$NOCLAUDE/$tool" +done +OUT=$(printf '%s\n' "$FOUR_CLASSES" | PATH="$NOCLAUDE" "$real_bash" "$SCRIPT" --diff-only --oracle 2>&1) +assert_contains "notice before any spawn attempt" "$OUT" "ORACLE COST NOTICE" +assert_contains "a missing claude degrades to unavailable" "$OUT" "'claude' is not on PATH" + +# --- Case 7: oracle comparison — one verdict per compared rule, exactly ------- +CAPTURE="$TEST_TMPDIR/capture.log" +cat >"$CAPTURE" <<'EOF' +[DEBUG] Applying permission update: Adding 3 allow rule(s) to destination 'userSettings': [...] +[DEBUG] Ignoring dangerous permission Bash(*) from C:\Users\x\.claude\settings.json (bypasses classifier) +[DEBUG] Ignoring dangerous permission Bash(python3 *) from C:\Users\x\.claude\settings.json (bypasses classifier) +[DEBUG] Ignoring dangerous permission Bash(uv run *) from C:\Users\x\.claude\settings.json (bypasses classifier) +[DEBUG] Applying permission update: Removing 3 allow rule(s) from source 'userSettings' +EOF +# Prediction: Bash(*), Bash(python3 *), Bash(npx *), Agent dropped. Oracle: +# Bash(*), Bash(python3 *), Bash(uv run *). Union: 5 rules, 2 agreements, +# 2 predicted-only, 1 oracle-only. +OUT=$(printf '%s\n' "$FOUR_CLASSES" | ENTRY_DIFF_ORACLE_CAPTURE="$CAPTURE" bash "$SCRIPT" --diff-only --oracle 2>&1) +assert_eq "one verdict line per rule in the union, exactly" 5 "$(count_matching "$OUT" '^oracle (AGREES|DIVERGES) ')" +assert_contains "agreement is stated per rule" "$OUT" "oracle AGREES Bash(*)" +assert_contains "a predicted drop the oracle kept diverges" "$OUT" "oracle DIVERGES prediction=dropped oracle=kept Bash(npx *)" +assert_contains "an oracle drop the prediction kept diverges too" "$OUT" "oracle DIVERGES prediction=kept oracle=dropped Bash(uv run *)" + +# A rule containing the word "from" must not truncate itself. +CAPTURE_FROM="$TEST_TMPDIR/capture-from.log" +cat >"$CAPTURE_FROM" <<'EOF' +[DEBUG] Applying permission update: Adding 1 allow rule(s) to destination 'userSettings': [...] +[DEBUG] Ignoring dangerous permission Bash(python3 import from x *) from C:\Users\x\.claude\settings.json (bypasses classifier) +EOF +FROM_IN=$( + cat <<'EOF' +user settings present /home/.claude/settings.json +effective allow scopes=user precedence_basis=uncontested Bash(python3 import from x *) +EOF +) +OUT=$(printf '%s\n' "$FROM_IN" | ENTRY_DIFF_ORACLE_CAPTURE="$CAPTURE_FROM" bash "$SCRIPT" --diff-only --oracle 2>&1) +assert_contains "a rule containing 'from' survives parsing intact" "$OUT" "oracle AGREES Bash(python3 import from x *)" + +# --- Case 8: capture without drop strings never becomes an empty drop set ----- +CAPTURE_NODROPS="$TEST_TMPDIR/capture-nodrops.log" +cat >"$CAPTURE_NODROPS" <<'EOF' +[DEBUG] Applying permission update: Adding 3 allow rule(s) to destination 'userSettings': [...] +EOF +OUT=$(printf '%s\n' "$FOUR_CLASSES" | ENTRY_DIFF_ORACLE_CAPTURE="$CAPTURE_NODROPS" bash "$SCRIPT" --diff-only --oracle 2>&1) +assert_contains "zero drop lines with predicted drops reads as unavailable" "$OUT" "treat the oracle as unavailable rather than as an empty drop set" +assert_eq "no verdict is emitted from it" 0 "$(count_matching "$OUT" '^oracle (AGREES|DIVERGES) ')" + +# A capture with no permission narration at all (the [DEBUG] strings changed, +# or the session died before the merge) is unavailable, with the reason named. +CAPTURE_ALIEN="$TEST_TMPDIR/capture-alien.log" +cat >"$CAPTURE_ALIEN" <<'EOF' +[DEBUG] something else entirely +EOF +OUT=$(printf '%s\n' "$FOUR_CLASSES" | ENTRY_DIFF_ORACLE_CAPTURE="$CAPTURE_ALIEN" bash "$SCRIPT" --diff-only --oracle 2>&1) +assert_contains "a capture without merge narration is unavailable" "$OUT" "no permission-merge narration" + +# --- Case 9: end to end — reader conf record reaches the diff ----------------- +if command -v jq >/dev/null 2>&1; then + FX="$TEST_TMPDIR/fx" + mkdir -p "$FX/proj/.claude" "$FX/home/.claude" + jq -n '{autoMode:{classifyAllShell:true},permissions:{allow:["Bash(npm test)","Bash(*)"]}}' >"$FX/home/.claude/settings.json" + jq -n '{permissions:{allow:["WebFetch(domain:example.com)"]}}' >"$FX/proj/.claude/settings.json" + + E2E=$(env -u CLAUDE_CONFIG_DIR \ + HOME="$FX/home" \ + PERMISSION_STATE_FIXTURE_DIR="$FX/proj" \ + PERMISSION_STATE_STARTDIR="$FX/proj" \ + PERMISSION_STATE_MANAGED_PATH="$FX/nonexistent/managed-settings.json" \ + PERMISSION_STATE_REGISTRY_KEYS="" \ + PERMISSION_STATE_PLIST_DOMAIN="" \ + bash "$STATE_SCRIPT" | bash "$MERGE_SCRIPT" | bash "$SCRIPT" --diff-only) + + assert_contains "the reader's conf record survives the merge pass-through" "$E2E" "entry-diff suspended reason=classifyAllShell scopes=user Bash(npm test)" + assert_contains "the blanket rule is dropped, not suspended" "$E2E" "entry-diff dropped class=blanket scopes=user Bash(*)" + assert_contains "the non-shell project rule is kept" "$E2E" "entry-diff kept scopes=project WebFetch(domain:example.com)" +else + pass "end-to-end conf record flow (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 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 index c228ab8e7b..211640fd01 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-merge.sh @@ -135,7 +135,9 @@ $1 == "rule" { next } -$1 == "NOTE:" { next } +# conf records are configuration inventory for downstream consumers (the entry +# diff), not rules and not surfaces — pass through, merge nothing. +$1 == "NOTE:" || $1 == "conf" { next } NF >= 3 { n_surfaces++ 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 index 7a9a428f72..1e482fdc2a --- 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 /fx/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) @@ -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 /fx/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) @@ -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 /fx/home/.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 /fx/home/.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 /fx/home/.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 /fx/home/.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 /fx/home/.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 /fx/home/.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 /fx/home/.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 /fx/home/.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 /fx/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 @@ -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 /fx/home/.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 old mode 100644 new mode 100755 index a6c30066b5..eb1d65c9d0 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh @@ -13,6 +13,10 @@ # Output (one record per line, stable field order): # one per settings surface # rule one per allow/ask/deny entry +# conf one per permission-plane config +# key found (today: autoMode.classifyAllShell, +# which the entry diff needs because it +# suspends every shell allow rule) # NOTE: anything the operator must know # # scope managed | user | project | local | startdir-local @@ -21,6 +25,9 @@ # status present | absent | unreadable | invalid-json | skipped | not-applicable # kind allow | ask | deny # +# The conf value is emitted as JSON (`tojson`), so a boolean true and the string +# "true" stay distinguishable downstream — the documented shape is the boolean. +# # 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 @@ -62,7 +69,9 @@ Usage: permission-state.sh [--scopes|--help] --scopes surface records only --help this message -Records: " " and "rule ". +Records: " ", "rule ", +and "conf " for permission-plane config keys +(today: autoMode.classifyAllShell). 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. @@ -135,7 +144,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 resolved to the wrong place. LOCAL_ROOT="$PROJECT_ROOT" local_basis="repository root" if [[ -z "${PERMISSION_STATE_FIXTURE_DIR:-}" ]]; then @@ -201,12 +210,28 @@ emit_file_rules() { done } +# The one permission-plane config key inventoried alongside the rules: +# autoMode.classifyAllShell suspends every Bash/PowerShell allow rule while auto +# mode is active, so an entry diff that cannot see it can be exactly wrong. +# Emitted as JSON (tojson) so boolean true and string "true" stay distinct. +emit_file_conf() { + # emit_file_conf + [[ "$mode" == "full" ]] || return 0 + local scope="$1" surface="$2" v + v="$(jq -r 'if (.autoMode | type) == "object" and (.autoMode | has("classifyAllShell")) then (.autoMode.classifyAllShell | tojson) else empty end' 2>/dev/null | tr -d '\r')" + [[ -n "$v" ]] && printf 'conf %s %s classifyAllShell %s\n' "$scope" "$surface" "$v" + return 0 +} + 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" + if [[ "$status" == "present" ]]; then + emit_file_rules "$scope" "$surface" "$path" + emit_file_conf "$scope" "$surface" < <(tr -d '\r' <"$path") + fi return 0 } @@ -286,6 +311,7 @@ else [[ -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 + emit_file_conf managed registry < <(printf '%s' "$reg_json") fi fi fi 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 index 436d90f4f2..b03945abb9 --- 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,11 +179,12 @@ for tool in jq git tr find sort sed head grep cat mktemp rm; do chmod +x "$STUB/$tool" done rc=0 +ADMIN_POLICY_KEY='HKLM\SOFTWARE\Policies\ClaudeCode' # portability-ok: a Windows registry key path, passed through as a literal; no regex engine sees it 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_REGISTRY_KEYS="$ADMIN_POLICY_KEY" \ PERMISSION_STATE_PLIST_DOMAIN="" \ "$real_bash" "$SCRIPT" 2>&1) || rc=$? assert_exit "missing optional tool does not fail the run" 0 "$rc" @@ -203,14 +204,23 @@ 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 + # Registry key paths, passed through as literals and compared as strings. + EXISTING_KEY='HKCU\SOFTWARE' # portability-ok: a Windows registry key path, not a regex + ABSENT_KEY='HKCU\SOFTWARE\ClaudeCodeNoSuchKeyExists' # portability-ok: a Windows registry key path, not a regex + SECOND_ABSENT_KEY='HKCU\SOFTWARE\ClaudeCodeAlsoAbsent' # portability-ok: a Windows registry key path, not a regex + ABSENT_THEN_PRESENT_KEYS="$ABSENT_KEY +$EXISTING_KEY" + BOTH_ABSENT_KEYS="$ABSENT_KEY +$SECOND_ABSENT_KEY" + 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_REGISTRY_KEYS="$ABSENT_THEN_PRESENT_KEYS" \ 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 $EXISTING_KEY" 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 ')" @@ -218,7 +228,7 @@ if command -v reg >/dev/null 2>&1; then 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_REGISTRY_KEYS="$BOTH_ABSENT_KEYS" \ PERMISSION_STATE_PLIST_DOMAIN="" \ bash "$SCRIPT") assert_contains "no policy keys at all reports absent" "$OUT_REG_NONE" "managed registry absent" From 6ff38cc6c2767c59eaff30add3168fd234795b12 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:28:06 -0400 Subject: [PATCH 11/32] feat(claude-config): report which allow rules survive entering auto mode Auto mode became the default for new sessions, and on entry it silently drops broad allow rules. Nothing told a consumer which of theirs survive. This classifies every effective allow rule as dropped -- with the documented reason named: blanket, wildcarded interpreter, package-manager run, or Agent -- or as carried over. The classes come from lib/permission-patterns.sh, the vocabulary audit-permission-grants check P1 already scans with. The library now exposes its four alternatives individually as well as the union, because a driver that must NAME the class a rule fell into has to test them one at a time; the union is composed from them rather than written twice. autoMode.classifyAllShell inverts the answer wholesale -- with it on, narrow rules do NOT carry over -- so the reader inventories it as a new `conf` record, emitted as JSON so a boolean true and the string "true" stay distinguishable. It is resolved only from the scopes the classifier actually reads: user, managed, and inline --settings/SDK JSON. A project- or local-scope copy is reported inert rather than obeyed. Both of the phase's open questions were measured rather than reasoned about, and one answer changed what ships. A -p oracle session does NOT modify any settings file, so criterion 9 holds -- but it DOES rewrite ~/.claude.json and add project, session-env, security, subagent and backup state under the config root. The cost notice said only "does not modify any settings file", which was true and incomplete in the way that matters for a flag whose whole job is being honest about what it costs. It now enumerates the measured writes. The second question is moot: a plain -p run with no mode flag emitted 216 drop lines, so the differential capture held in reserve was never needed. A third fact fell out of the probe -- an isolated run with CLAUDE_CONFIG_DIR pointed at scratch cannot authenticate, because credentials live in the real config root, so a probe of this shape necessarily touches it. Copying credentials into a scratch root to fake isolation was rejected rather than attempted. One defect caught by its own regression case: the oracle's rule-text parse claimed to cut at the LAST " from " but the sed was leftmost-matching, so `Bash(python3 import from x *)` parsed as `Bash(python3 import`. 49 checks, shellcheck clean, every sibling suite still green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/SKILL-CHEAT-SHEET.md | 2 +- docs/topics/permission-model/PLAN.md | 36 +++++++++++- .../permission-model/phase0-fresh-docs.md | 56 ++++++++++++++++++- plugins/claude-config/CHANGELOG.md | 13 ++++- .../claude-config/lib/permission-patterns.sh | 14 +++-- .../skills/audit-permission-state/SKILL.md | 46 +++++++++++++-- .../reference/criteria.md | 27 +++++++++ .../scripts/automode-entry-diff.sh | 17 ++++-- .../scripts/automode-entry-diff.test.sh | 24 +++++--- 9 files changed, 210 insertions(+), 25 deletions(-) diff --git a/docs/SKILL-CHEAT-SHEET.md b/docs/SKILL-CHEAT-SHEET.md index 1322d34ac1..db3d7b8d45 100644 --- a/docs/SKILL-CHEAT-SHEET.md +++ b/docs/SKILL-CHEAT-SHEET.md @@ -141,7 +141,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-permission-state`](../plugins/claude-config/skills/audit-permission-state/SKILL.md) | `claude-config` | Report the permission rules actually in effect and what auto mode drops | | [`/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 9e54c21009..84697ab84c 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -495,7 +495,41 @@ Acceptance criterion 1. 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] +### Phase 3: Auto-mode drop classification and entry diff [DONE] + +**Completed 2026-08-12.** `automode-entry-diff.test.sh` 49/49, `shellcheck -x` clean, every sibling +suite still green (`permission-state` 42/42, `permission-merge` 49/49, `managed-scope` 16/16). +`scripts/automode-entry-diff.sh` classifies every effective allow rule; `SKILL.md` gained the Phase 3 +stage and the two new flags; `reference/criteria.md` gained the entry-diff section. + +**Both of the phase's open questions were measured, not inferred** — recorded as the 2026-08-12 +addendum in `phase0-fresh-docs.md`: + +- **What a `-p` oracle session writes outside its scratch path.** Settings files are byte-identical + afterwards, so criterion 9 holds. But `~/.claude.json` **is** rewritten, and project, session-env, + security, subagent and backup state appear under the config root. The cost notice as first written + said only "does NOT modify any settings file" — true, and incomplete in a way that mattered. It now + enumerates the measured writes. +- **What governs whether a `-p` probe session is in auto mode.** Moot: a plain `-p` run with no mode + flag emitted 216 drop lines. The differential capture held in reserve was never needed. +- **A third fact fell out of the probe:** an isolated run with `CLAUDE_CONFIG_DIR` pointed at scratch + **cannot authenticate** (`Not logged in`), because credentials live in the real config root. + Copying credentials into a scratch root was rejected rather than attempted, so a probe of this shape + necessarily touches the operator's real config directory — which is exactly why the notice must + enumerate rather than imply isolation. + +**Deviations recorded rather than silent:** + +- **The oracle's rule-text parse was wrong on rules containing the word `from`.** The comment claimed + the rule text ran to the LAST ` from `, but the `sed` was leftmost-matching and truncated at the + first, so `Bash(python3 import from x *)` parsed as `Bash(python3 import`. Fixed by anchoring a + greedy prefix (`s/^\(.*\) from [^ ].*$/\1/`); the regression case that caught it ships. +- **`permission-state.sh` gained a `conf` record type** for `autoMode.classifyAllShell`, emitted as + JSON so a boolean `true` and the string `"true"` stay distinguishable. `permission-merge.sh` passes + `conf` records through untouched — they are configuration inventory, not rules. +- **`lib/permission-patterns.sh` now exposes its four alternatives individually** as well as the + union, so a driver that must NAME the class tests them one at a time. The union is composed from + them, never written twice. Acceptance criterion 3, plus brainstorm candidate 4. diff --git a/docs/topics/permission-model/phase0-fresh-docs.md b/docs/topics/permission-model/phase0-fresh-docs.md index 764665cc4d..6fcc663f85 100644 --- a/docs/topics/permission-model/phase0-fresh-docs.md +++ b/docs/topics/permission-model/phase0-fresh-docs.md @@ -155,7 +155,61 @@ Consequences carried into the plan: 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. +## Addendum — 2026-08-11, the two unstated constants and criterion 6's fourth item + +Probed before Phase 4, per the execution brief. Channel: the raw-markdown endpoints +(`https://code.claude.com/docs/en/.md`), fetched 2026-08-11 and grepped verbatim — not +WebFetch summaries. Copies in the session scratchpad. Both "Not stated" items above are +**superseded**: the pages now state them. + +| Fact | Source | Wording | +|---|---|---| +| The `v2.1.142` gate **is** documented | permission-modes §If auto mode is on but the session starts in default mode; settings §Available settings (`defaultMode` row) | "Claude Code v2.1.142 and later ignore `auto` from those files so a repository cannot grant itself auto mode." / "`auto` is ignored when set in project or local settings… Before v2.1.142, project settings could set `auto`." | +| `useAutoModeDuringPlan` scope restriction **is** documented | settings §Available settings | "**Default**: `true`. Whether plan mode uses auto mode semantics when auto mode is available. **Not read from shared project settings.**" | +| Parameter-form rules on a primary content field | permissions §Match by input parameter | "You can't match a tool's primary content field this way: `command` for Bash and PowerShell, `file_path` for Read, Edit, and Write, `path` for Grep and Glob, `notebook_path` for NotebookEdit, and `url` for WebFetch. A rule like `Bash(command:rm *)` would be bypassable by a compound command, so Claude Code ignores it and emits a startup warning." | +| Path rules on uncovered file tools | permissions §Read and Edit | "Claude Code checks file permissions against `Edit(path)` and `Read(path)` rules only. If you write a path rule for `Write`, `NotebookEdit`, `Glob`, or the legacy `MultiEdit` tool instead, Claude Code accepts the rule but never consults it, and warns at startup, except for a `Glob` rule passed in `--allowedTools`… Claude Code doesn't warn about a tool-name rule with no path, such as a deny rule for `Write`; it matches that rule at the tool level everywhere. Requires Claude Code v2.1.210 or later." | +| Windows rule paths are POSIX-form | permissions §Read and Edit | "On Windows, paths are normalized to POSIX form before matching. `C:\Users\alice` becomes `/c/Users/alice`, so use `//c/**/.env` to match `.env` files anywhere on that drive." | +| Single leading slash is not absolute | permissions §Read and Edit | "A pattern like `/Users/alice/file` isn't an absolute path. The single leading slash anchors at the settings source, not the filesystem root." | +| `disableAutoMode` lives at **two** key paths | settings §Available settings + §Permission settings | Top-level `disableAutoMode` and `permissions.disableAutoMode` — "Also accepted under `permissions` as `permissions.disableAutoMode`" — a scan of only one key path misses half the surface | + +Consequences: + +- **Criterion 2 ships whole and uncaveated.** The `defaultMode: "auto"` gate carries `v2.1.142`, + cited. The `useAutoModeDuringPlan` item fires on **shared project settings only** — the page's + restriction names exactly that scope, so a local-settings occurrence is not claimed dead. +- **Criterion 6's fourth item is re-derived into two separately-cited checks**: the parameter-form + content-field rule (`Write(file_path:…)`, `Bash(command:…)` — ignored, startup warning) and the + path-shaped rule on an uncovered file tool (`Write(docs/**)` — accepted but never consulted, + startup warning v2.1.210+, `Glob` via `--allowedTools` excepted). A **bare tool-name rule with no + path is legitimate at the tool level** and must never be flagged by either check. +- **Criterion 5's check reads both key paths**, top-level and under `permissions`. +- The startup warning exists upstream at session start; the lint's added value is a pre-session, + scriptable read over every scope at once — stated in the criteria file rather than implying the + harness is silent. + +## Addendum — 2026-08-12, what the oracle probe actually costs (measured, not inferred) + +Phase 3's plan required this before the `--oracle` flag shipped: a `-p` session's writes outside its +scratch path had never been measured, only guessed at. Measured on **Claude Code 2.1.225, Windows +11**, by checksumming the settings files and taking a file-mtime census of `~/.claude` either side of +one `claude --debug-file -p` run from a scratch working directory. + +| Question | Measured answer | +|---|---| +| Are settings files modified? | **No.** `~/.claude/settings.json` and `~/.claude/settings.local.json` were byte-identical afterwards. Criterion 9 holds. | +| Is anything under the config root rewritten? | **Yes — `~/.claude.json` changes.** It is the harness's own state file, not a settings file, and it carries no `permissions` key. The earlier notice's flat "does not modify any settings file" was true but incomplete; the flag now states this. | +| What new files appear? | A `projects/` entry for the session's working directory, a `session-env/` entry, per-session `security/` and `subagents/` state, a `backups/` entry. The probe's own working directory became a project-list entry — hence the notice's advice to run it from a directory you do not mind appearing there. | +| Does a plain `-p` session emit drop lines at all? | **Yes.** 216 `Ignoring dangerous permission … (bypasses classifier)` lines and 6 `Applying permission update` lines, with **no mode flag passed**. The differential capture the plan held in reserve was not needed. | + +Both of the phase's open questions are closed by this run. A third fact fell out of it: an isolated +probe with `CLAUDE_CONFIG_DIR` pointed at a scratch directory **cannot authenticate** — it exits with +`Not logged in · Please run /login`, because credentials live in the real config root. Copying +credentials into a scratch root to work around that was rejected rather than attempted. So a probe of +this shape necessarily touches the operator's real config directory, which is precisely why the cost +notice must enumerate what it leaves behind rather than implying isolation 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`, -`v2.1.212` — each appears verbatim on a page fetched above. **`v2.1.142` is not cleared.** +`v2.1.212` — each appears verbatim on a page fetched above. `v2.1.142` and `v2.1.210` were cleared +by the 2026-08-11 addendum. diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index 61d65b1ef9..fe012a1d88 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -48,7 +48,18 @@ All notable changes to the `claude-config` plugin are documented here. Format fo 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 + no scoped allow for that tool applies. `EndConversation` is exempt from removal, as documented. + A third pass answers what entering auto mode does to that set — which became urgent when auto mode + turned on by default for new sessions. Every effective allow rule is classified as dropped (with the + documented reason named: blanket, wildcarded interpreter, package-manager run, or `Agent`) or as + carried over, using the same shared pattern vocabulary `audit-permission-grants` check P1 scans + with. `autoMode.classifyAllShell` is read too, because when it is on it suspends every Bash and + PowerShell allow rule and a diff blind to it can be exactly wrong — and it is resolved only from the + scopes the classifier actually reads, so a project-scope copy is reported inert rather than obeyed. + An opt-in `--oracle` flag corroborates the prediction against the harness's own drop narration by + spawning a real `claude -p` session; it never fires without the flag, prints what it will leave + behind before spawning anything, and reports an empty capture as unavailable rather than as an + empty drop set. 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/lib/permission-patterns.sh b/plugins/claude-config/lib/permission-patterns.sh index 2b17b65c78..40df25ed0d 100644 --- a/plugins/claude-config/lib/permission-patterns.sh +++ b/plugins/claude-config/lib/permission-patterns.sh @@ -55,10 +55,16 @@ CCPERM_SCRIPT_BODY='py|sh|rb|js|ts|mjs|cjs|pl|php' # # 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})[^)]*\\)" +# +# The four alternatives are also exposed individually so a driver that must NAME +# the class a rule fell into (the entry diff's drop reason) tests them one at a +# time instead of re-deriving the wrapping; the union stays the P1 detector's +# single match target and is composed from them, never written twice. +CCPERM_P1_BLANKET_ERE="(Bash|PowerShell)\\(\\*\\)" +CCPERM_P1_INTERP_ERE="(Bash|PowerShell)\\([\"' ]*([^)\"' ]*[/\\\\])?(${CCPERM_INTERP_BODY})([\"' :][^)]*)?\\*[^)]*\\)" +CCPERM_P1_RUNNER_ERE="(Bash|PowerShell)\\([\"' ]*(${CCPERM_RUNNER_BODY})([\"' :][^)]*)?\\*[^)]*\\)" +CCPERM_P1_SCRIPTGLOB_ERE="(Bash|PowerShell)\\([\"' ]*\\*[^)]*\\.(${CCPERM_SCRIPT_BODY})[^)]*\\)" +CCPERM_P1_ERE="${CCPERM_P1_BLANKET_ERE}|${CCPERM_P1_INTERP_ERE}|${CCPERM_P1_RUNNER_ERE}|${CCPERM_P1_SCRIPTGLOB_ERE}" # Splits rule text into top-level `Tool` / `Tool(...)` tokens. The greedy # `(\(...\))?` consumes a tool's whole parenthesized payload as one token, so a diff --git a/plugins/claude-config/skills/audit-permission-state/SKILL.md b/plugins/claude-config/skills/audit-permission-state/SKILL.md index 11e4a7238f..50d22b8430 100644 --- a/plugins/claude-config/skills/audit-permission-state/SKILL.md +++ b/plugins/claude-config/skills/audit-permission-state/SKILL.md @@ -1,11 +1,11 @@ --- -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" +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), merges them into the effective allow/ask/deny set with each rule's source and precedence mechanic named, and classifies which allow rules auto mode drops on entry. 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', 'what does auto mode drop', 'which of my rules survive auto mode', '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] surfaces only | [--entry-diff] what auto mode drops" user-invocable: true disable-model-invocation: false metadata: workflow-stage: anytime - summary: Report which permission scopes exist and what rules each one holds + summary: Report the permission rules actually in effect and what auto mode drops --- ## Purpose @@ -40,7 +40,11 @@ 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. +- `--entry-diff` — run the full pipeline through to the auto-mode entry diff (Phase 3 below). +- `--oracle` — with `--entry-diff`, cross-check the prediction against the harness's own drop + narration. **Spawns a real `claude -p` session**; never fires without this flag. See its cost + notice, which the run prints before anything is spawned. +- (no argument) — surfaces plus one record per allow/ask/deny rule, then the merge. ## Phase 1: Discover and inventory @@ -103,6 +107,40 @@ states the two standing bounds the run prints — the command-line scope has no 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. +## Phase 3: What entering auto mode drops + +Auto mode became the default permission mode for new sessions on 2026-08-14, and on entry it +**silently drops** broad allow rules. This stage says which of yours survive: + +```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" | + bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-state/scripts/automode-entry-diff.sh" +``` + +```text +DIFF-NOTE: classifyAllShell state, bounds +entry-diff dropped class= scopes= dropped on entry +entry-diff suspended reason=classifyAllShell ... suspended while auto mode is active +entry-diff kept scopes= carries over +entry-diff summary allow_before= dropped= suspended= kept= +``` + +- **Only allow rules change on entry.** Deny and ask are evaluated before the classifier in every + mode, so they are not part of this diff — do not report them as "surviving". +- **`class` names the documented reason**: `blanket`, `interpreter-wildcard`, `package-manager-run`, + or `agent`. The classes come from `lib/permission-patterns.sh`, the same vocabulary + `audit-permission-grants` check P1 scans with — one definition, two consumers. +- **`autoMode.classifyAllShell` inverts the answer wholesale.** When true it suspends *every* Bash and + PowerShell allow rule, so narrow rules do **not** carry over. The classifier reads `autoMode` from + user settings, managed settings, and inline `--settings`/SDK JSON only, so a project- or + local-scope `classifyAllShell` changes nothing and the run says so rather than silently obeying it. +- **`--oracle` is opt-in and priced.** It spawns a real `claude -p` session to capture the harness's + own drop narration. Measured cost: it does not touch your settings files, but it does rewrite + `~/.claude.json` and add project, session-env, security and subagent state under your config + directory. The prediction is the read path; the oracle only corroborates it, and a capture that + yields nothing is reported as **unavailable**, never as an empty drop set. + ## Reading the output honestly 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 464939acaa..76f6764db7 100644 --- a/plugins/claude-config/skills/audit-permission-state/reference/criteria.md +++ b/plugins/claude-config/skills/audit-permission-state/reference/criteria.md @@ -95,6 +95,33 @@ Neither is a limitation to apologise for; both change what a finding means. 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. +## The auto-mode entry diff + +> "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… Narrow rules like `Bash(npm test)` carry over. Dropped rules are +> restored when you leave auto mode." + +Four documented classes, and every dropped rule is reported as exactly one of them: `blanket`, +`interpreter-wildcard`, `package-manager-run`, `agent`. The patterns are not defined here — they live +in `lib/permission-patterns.sh`, shared with `audit-permission-grants` check P1, so a class change +lands once. + +- **Only allow rules are in scope.** Deny and ask are evaluated before the classifier in every mode. +- **`autoMode.classifyAllShell` (v2.1.193+) inverts the carry-over answer.** When true it "suspend[s] + every Bash and PowerShell allow rule while auto mode is active", so a narrow `Bash(npm test)` does + **not** carry over. A diff that cannot see this key can be exactly wrong, which is why the reader + inventories it as a `conf` record. +- **The key is resolved only from scopes the classifier reads** — user settings, managed settings, and + inline `--settings`/SDK JSON. "The classifier doesn't read `autoMode` from project settings in + `.claude/settings.json` or `.claude/settings.local.json`." A project- or local-scope occurrence is + reported as having no effect, never obeyed. +- **The oracle is corroboration, not the read path.** `--oracle` spawns a real session to capture the + harness's own `Ignoring dangerous permission … (bypasses classifier)` narration. Those are + undocumented `[DEBUG]` strings with no stability contract, so a capture that yields nothing is + **unavailable** and the prediction stands — an empty capture is never an empty drop set. Its + measured cost is stated at the flag rather than discovered afterwards. + ## Managed policy, and what it does not buy > "no other level, including command line arguments, can override a managed permission rule." diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.sh b/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.sh index aba413e7a3..46a9ffc51e 100755 --- a/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.sh @@ -209,11 +209,18 @@ if [[ -z "$capture" ]]; then cat >&2 <<'EOF' ORACLE COST NOTICE — nothing has been spawned yet. --oracle starts a real `claude -p` session on this machine to capture the - harness's own drop narration. That costs API tokens, and the session leaves - state behind like any -p session: a transcript and project entry under your - Claude config directory (~/.claude by default) and updated internal state - files there. It does NOT modify any settings file. The debug capture itself - goes to a scratch path, never to ~/.claude/debug/. + harness's own drop narration. That costs API tokens, and the session writes + outside the scratch capture path. Measured on 2.1.225 / Windows 11 by + checksumming before and after a probe run: + - Your settings files are NOT modified: ~/.claude/settings.json and + ~/.claude/settings.local.json were byte-identical afterwards. + - ~/.claude.json IS rewritten. It is the harness's own state file, not a + settings file, and it carries no permission rules -- but it does change. + - New files appear under your config directory: a project entry for the + session's working directory, a session-env entry, per-session security + and subagent state, and a backup entry. + The debug capture itself goes to a scratch path, never to ~/.claude/debug/. + Run it from a directory you do not mind appearing in your project list. EOF if ! command -v claude >/dev/null 2>&1; then echo "oracle UNAVAILABLE: 'claude' is not on PATH — the prediction above stands, uncorroborated." diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.test.sh index 92ac935d76..ed56b8a181 100755 --- a/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.test.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.test.sh @@ -5,6 +5,14 @@ # use a fixture capture (ENTRY_DIFF_ORACLE_CAPTURE) or a recording stub claude # on PATH — no case ever spawns a real session, and no test reads the # operator's real ~/.claude. +# +# portability-scope: the oracle fixture reproduces Claude Code's own [DEBUG] +# narration byte for byte, and that narration prints Windows settings paths in +# native form (C:\Users\...\.claude\settings.json — see the recorded capture in +# the permission-model research corpus). The backslash runs the gate reads as +# regex escapes are those paths inside heredoc DATA, not shell code. Rewriting +# them to POSIX form would make the fixture stop matching what the parser must +# survive on the plugin's primary platform, which is the whole point of the case. set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -55,7 +63,7 @@ diff_only() { printf '%s\n' "$1" | bash "$SCRIPT" --diff-only; } # like Bash(npm test) carry over." FOUR_CLASSES=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /fx/home/.claude/settings.json effective allow scopes=user precedence_basis=uncontested Bash(*) effective allow scopes=user precedence_basis=uncontested Bash(python3 *) effective allow scopes=user precedence_basis=uncontested Bash(npx *) @@ -76,7 +84,7 @@ assert_contains "the summary reconciles" "$OUT" "entry-diff summary allow_before # categorically, unlike Bash where narrow rules survive. SCOPED_AGENT=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /fx/home/.claude/settings.json effective allow scopes=user precedence_basis=uncontested Agent(model:haiku) EOF ) @@ -87,7 +95,7 @@ assert_contains "a scoped Agent allow rule is dropped like a bare one" "$OUT" "e # entry diff must not touch them. DENY_ASK=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /fx/home/.claude/settings.json effective deny scopes=user precedence_basis=uncontested Bash(*) effective ask scopes=user precedence_basis=uncontested Agent EOF @@ -100,7 +108,7 @@ assert_contains "the summary shows an empty allow set, not a missing one" "$OUT" # "suspend[s] every Bash and PowerShell allow rule while auto mode is active." CAS_ON=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /fx/home/.claude/settings.json conf user settings classifyAllShell true effective allow scopes=user precedence_basis=uncontested Bash(git status) effective allow scopes=user precedence_basis=uncontested PowerShell(Get-ChildItem *) @@ -119,7 +127,7 @@ assert_contains "the inversion is announced with its version gate" "$OUT" "v2.1. CAS_MANAGED=$( cat <<'EOF' managed file present /policy/managed-settings.json -user settings present /home/.claude/settings.json +user settings present /fx/home/.claude/settings.json conf managed file classifyAllShell false conf user settings classifyAllShell true effective allow scopes=user precedence_basis=uncontested Bash(git status) @@ -145,7 +153,7 @@ assert_contains "the ignored scope is named, not silently honored" "$OUT" "scope # A string "true" is not the documented boolean and must not activate. CAS_STRING=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /fx/home/.claude/settings.json conf user settings classifyAllShell "true" effective allow scopes=user precedence_basis=uncontested Bash(git status) EOF @@ -157,7 +165,7 @@ assert_contains "the type mismatch is called out" "$OUT" "not the documented boo # --- Case 3: pass-through default, --diff-only suppression -------------------- PASS_IN=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /fx/home/.claude/settings.json NOTE: something the operator must know effective allow scopes=user precedence_basis=uncontested Bash(git status) EOF @@ -260,7 +268,7 @@ cat >"$CAPTURE_FROM" <<'EOF' EOF FROM_IN=$( cat <<'EOF' -user settings present /home/.claude/settings.json +user settings present /fx/home/.claude/settings.json effective allow scopes=user precedence_basis=uncontested Bash(python3 import from x *) EOF ) From 1c259c376d47d59c017aa5bfb26100f2de142115 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:10:37 -0400 Subject: [PATCH 12/32] feat(claude-config): find permission config that is written but never read The permission plane accepts things it silently ignores, and the operator who wrote them has no way to know. Eight checks over every scope at once, before a session starts. The highest-consequence one is disableAutoMode typed as a boolean. The documented value is the STRING "disable"; a boolean is valid JSON, is accepted, and does nothing -- so an operator who set it believes auto mode is locked out when it is not. It is read at BOTH documented key paths, top-level and under `permissions`, in every scope, because it is not managed-only and a scan of one path would miss half the surface. The three dead-config gates stay three findings. They cover different scope sets and carry different version histories, so a merged count would let someone fix one, watch the number drop, and believe they had fixed all three. Criterion 6's fourth item shipped as two checks rather than one. A parameter-form rule on a primary content field (`Bash(command:rm *)`) is ignored with a startup warning; a path rule on Write/NotebookEdit/Glob/MultiEdit is accepted and never consulted. Different mechanics with different observables -- one merged check would have been wrong about at least one of them. Both were re-derived against the pages rather than shipped as the plan wrote them. The reader owns the config reads, not the lint: permission-state.sh's conf record grew from one key to six, emitted as JSON so a boolean true and the string "true" stay distinguishable. For the disableAutoMode check that distinction IS the finding. A lint that re-opened settings files itself would have duplicated scope resolution the reader already does correctly. False positives were designed against explicitly and each has a regression case asserting it does NOT fire: a bare tool-name rule (legitimate at the tool level, and the most common rule shape there is), `:*` at pattern end, a parameter rule on a non-content field, and a POSIX-form Windows path. SKILL.md was over its soft line target, so the scope tables and the per-check mechanic table moved to reference/criteria.md. 43 checks, shellcheck clean, siblings still green, check-skill PASS. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/topics/permission-model/PLAN.md | 34 ++- plugins/claude-config/CHANGELOG.md | 16 +- .../skills/audit-permission-state/SKILL.md | 85 +++--- .../reference/criteria.md | 59 ++++ .../scripts/permission-plane-lint.sh | 228 +++++++++++++++ .../scripts/permission-plane-lint.test.sh | 272 ++++++++++++++++++ .../scripts/permission-state.sh | 39 ++- 7 files changed, 685 insertions(+), 48 deletions(-) create mode 100755 plugins/claude-config/skills/audit-permission-state/scripts/permission-plane-lint.sh create mode 100755 plugins/claude-config/skills/audit-permission-state/scripts/permission-plane-lint.test.sh diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index 84697ab84c..ec1db78bdd 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -569,7 +569,39 @@ Acceptance criterion 3, plus brainstorm candidate 4. - 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] +### Phase 4: Permission-plane lints [DONE] + +**Completed 2026-08-12.** `permission-plane-lint.test.sh` 43/43, `shellcheck -x` clean, siblings still +green (`permission-state` 42/42, `automode-entry-diff` 49/49). `check-skill.sh` PASS. +The sanity check holds: `grep -c '\[C2-autoMode\]'` = 1 and `grep -c '\[C2-defaultMode\]'` = 1 on the +three-gate fixture, and `C2-planMode` is a third separately-labelled finding. + +**Criterion 6's fourth item shipped as TWO checks, not one, and criterion 2 shipped uncaveated** — +both because the 2026-08-11 addendum found the pages state what the plan called unstated: + +- `C6-contentField` — a parameter-form rule on a tool's primary content field, ignored with a startup + warning. `C6-uncoveredPath` — a path rule on `Write`/`NotebookEdit`/`Glob`/`MultiEdit`, accepted but + never consulted. Different mechanics, different observables, so one merged check would have been + wrong about at least one of them. +- The `v2.1.142` gate and the `useAutoModeDuringPlan` scope restriction are both cited now. The + plan-mode check fires on **shared project settings only**, because that is the scope the page names; + claiming a local occurrence dead would assert a restriction no page states. +- **`disableAutoMode` is read at BOTH documented key paths** — top-level and under `permissions`. A + scan of one path would have missed half the surface of the highest-consequence check in the file. + +**Deviations recorded rather than silent:** + +- **The reader owns the config reads, not the lint.** `permission-state.sh`'s `conf` record grew from + one key to six (`classifyAllShell`, `autoModePresent`, `defaultMode`, `useAutoModeDuringPlan`, and + `disableAutoMode` at both paths), emitted as JSON so a boolean `true` and the string `"true"` stay + distinguishable — for `C5-disableType` that distinction **is** the finding. The alternative, a lint + that re-opens settings files itself, would have duplicated scope resolution the reader already does. +- **False positives were designed against explicitly and are regression-tested**: a bare tool-name + rule (legitimate at the tool level), `:*` at pattern end, a parameter rule on a non-content field, + and a POSIX-form Windows path (`//c/**`) each have a case asserting they do **not** fire. +- **SKILL.md was over the 200-line soft target**, so the scope tables and the per-check mechanic table + moved to `reference/criteria.md` — the spoke the progressive-disclosure convention calls for — and + the hub keeps the pipeline and the reading-it-honestly rules. Acceptance criteria 2, 5, 6. diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index fe012a1d88..22610d6272 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -59,7 +59,21 @@ All notable changes to the `claude-config` plugin are documented here. Format fo An opt-in `--oracle` flag corroborates the prediction against the harness's own drop narration by spawning a real `claude -p` session; it never fires without the flag, prints what it will leave behind before spawning anything, and reports an empty capture as unavailable rather than as an - empty drop set. Every run states the two bounds on the claim: the command-line scope + empty drop set. + A fourth pass lints the permission plane for configuration that is written but never read. Eight + checks: an `autoMode` section in a scope the classifier does not read, `defaultMode: "auto"` in + project or local settings, `useAutoModeDuringPlan` in shared project settings, `disableAutoMode` + typed as a boolean instead of the string `"disable"`, and four rule shapes that cannot match — + doubled-backslash Windows paths, parameter-form rules on a tool's primary content field, path rules + on a tool whose path rules are never consulted, and `:*` used anywhere but at the end of a pattern. + The three dead-config gates stay separate findings because they cover different scope sets and carry + different version histories; merging them would let an operator fix one and believe they had fixed + all three. The `disableAutoMode` check is the highest-consequence one — a boolean is valid JSON, is + accepted, and does nothing, so the operator believes auto mode is locked out when it is not — and it + is read at both documented key paths in every scope, since it is not managed-only. Several of these + also emit a startup warning upstream; the added value is reading every scope at once, before a + session, and naming the file. Advisory: it exits 0 whenever it ran, and exit 2 means it could not + run at all rather than that it found nothing. 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 50d22b8430..8913e18a74 100644 --- a/plugins/claude-config/skills/audit-permission-state/SKILL.md +++ b/plugins/claude-config/skills/audit-permission-state/SKILL.md @@ -97,15 +97,12 @@ Two mechanics decide those records, and conflating them produces confident wrong 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. + tool from context entirely, so every other rule naming it is inert — other denies included; + `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 -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. +states the two standing bounds the run prints. ## Phase 3: What entering auto mode drops @@ -129,17 +126,44 @@ entry-diff summary allow_before= dropped= suspended= kept= - **Only allow rules change on entry.** Deny and ask are evaluated before the classifier in every mode, so they are not part of this diff — do not report them as "surviving". - **`class` names the documented reason**: `blanket`, `interpreter-wildcard`, `package-manager-run`, - or `agent`. The classes come from `lib/permission-patterns.sh`, the same vocabulary - `audit-permission-grants` check P1 scans with — one definition, two consumers. + or `agent`, from `lib/permission-patterns.sh` — the vocabulary `audit-permission-grants` check P1 + also scans with. - **`autoMode.classifyAllShell` inverts the answer wholesale.** When true it suspends *every* Bash and - PowerShell allow rule, so narrow rules do **not** carry over. The classifier reads `autoMode` from - user settings, managed settings, and inline `--settings`/SDK JSON only, so a project- or - local-scope `classifyAllShell` changes nothing and the run says so rather than silently obeying it. -- **`--oracle` is opt-in and priced.** It spawns a real `claude -p` session to capture the harness's - own drop narration. Measured cost: it does not touch your settings files, but it does rewrite - `~/.claude.json` and add project, session-env, security and subagent state under your config - directory. The prediction is the read path; the oracle only corroborates it, and a capture that - yields nothing is reported as **unavailable**, never as an empty drop set. + PowerShell allow rule, so narrow rules do **not** carry over. It is resolved only from the scopes + the classifier reads, so a project- or local-scope copy is reported inert rather than obeyed. +- **`--oracle` is opt-in and priced.** It spawns a real `claude -p` session to corroborate the + prediction. Measured cost: your settings files are untouched, but `~/.claude.json` is rewritten and + project, session-env, security and subagent state appear under your config directory. A capture + that yields nothing is **unavailable**, never an empty drop set. + +## Phase 4: Configuration that is written but never read + +The permission plane accepts things it silently ignores. This finds them across every scope at once, +before a session starts: + +```shell +bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-state/scripts/permission-state.sh" | + bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-state/scripts/permission-plane-lint.sh" +``` + +```text +finding [] +lint summary findings= checks_run= +``` + +Eight checks: three `C2-*` dead-config gates, `C5-disableType`, and four `C6-*` rules-that-cannot-match. +`reference/criteria.md` maps each to the sentence it follows from and lists the legitimate rule shapes +the checks are written NOT to flag. + +- **`C5-disableType` is the one to read first.** `disableAutoMode` must be the **string** `"disable"`; + a boolean is valid JSON, is accepted, and does nothing — so the operator believes auto mode is + locked out when it is not. +- **The three `C2` gates stay separate findings.** Different scope sets, different version histories: + an operator who fixed one and saw the count drop would reasonably believe they had fixed all three. +- **Several of these also produce a startup warning.** The added value here is reading every scope at + once, before a session, and naming the file — not that the harness is silent. +- **Advisory: the lint always exits 0 when it ran.** Exit 2 means it could not run at all, never + "nothing found". ## Reading the output honestly @@ -162,29 +186,12 @@ collapse it in the report: 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. +## Scopes -`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. +Five, and the two easy to get wrong: `local` resolves **through worktrees to the main checkout**, so +a reader anchored on the worktree root looks where the file is not; `startdir-local` is a +pre-v2.1.211 copy that is **not** a fallback — permission rules from both files stay in effect. +`managed` is four surfaces per OS, not one file. `reference/criteria.md` §Scopes has the full table. ## Prerequisites 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 76f6764db7..81e43dd137 100644 --- a/plugins/claude-config/skills/audit-permission-state/reference/criteria.md +++ b/plugins/claude-config/skills/audit-permission-state/reference/criteria.md @@ -13,6 +13,28 @@ Sources, both fetched 2026-08-11: §H --- +## Scopes + +| 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. 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 | +| `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. + +`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 **exists** ends the search and the rest are not +consulted — an existing key that yields nothing readable is reported unread, never as permission to +fall through. + ## The one thing that is not a contest > "Permission rules behave differently because they merge across scopes rather than override." @@ -122,6 +144,43 @@ lands once. **unavailable** and the prediction stands — an empty capture is never an empty drop set. Its measured cost is stated at the flag rather than discovered afterwards. +## The permission-plane lint + +Eight checks over one question: the operator wrote something believing it takes effect, and it does +not. Several also emit a startup warning upstream; the added value here is reading every scope at +once, before a session, and naming the file the dead entry is in. + +**The three `C2` gates never merge into one finding.** They cover different scope sets and carry +different version histories, so a merged count would let an operator fix one and believe they had +fixed all three. + +| Check | Mechanic it follows from | +| --- | --- | +| `C2-autoMode` | "The classifier doesn't read `autoMode` from project settings in `.claude/settings.json` or `.claude/settings.local.json`." Before v2.1.207 it also read local settings, so a local-scope finding says so rather than implying it never worked | +| `C2-defaultMode` | "Claude Code v2.1.142 and later ignore `auto` from those files so a repository cannot grant itself auto mode." Only the value `auto` is dead — other modes are read in project scope | +| `C2-planMode` | `useAutoModeDuringPlan` is "**Not read from shared project settings**". That names `.claude/settings.json` specifically, so a local-settings occurrence is **not** claimed dead — doing so would assert a restriction no page states | +| `C5-disableType` | "set `permissions.disableBypassPermissionsMode` or `permissions.disableAutoMode` to `\"disable\"` in any settings file" — the **string**. Checked at both documented key paths, in every scope; it is not managed-only | +| `C6-winPath` | "On Windows, paths are normalized to POSIX form before matching. `C:\Users\alice` becomes `/c/Users/alice`" | +| `C6-contentField` | "You can't match a tool's primary content field this way: `command` for Bash and PowerShell, `file_path` for Read, Edit, and Write, `path` for Grep and Glob, `notebook_path` for NotebookEdit, and `url` for WebFetch… Claude Code ignores it and emits a startup warning" | +| `C6-uncoveredPath` | "Claude Code checks file permissions against `Edit(path)` and `Read(path)` rules only. If you write a path rule for `Write`, `NotebookEdit`, `Glob`, or the legacy `MultiEdit` tool instead, Claude Code accepts the rule but never consults it, and warns at startup" (v2.1.210+; a `Glob` rule passed in `--allowedTools` is the stated exception) | +| `C6-colonStar` | "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" | + +**`C5-disableType` is the highest-consequence check here.** A boolean is valid JSON, is accepted, and +does nothing — so the operator believes auto mode is locked out and it is not. + +**False positives these checks are written to avoid**, each a legitimate documented shape: + +- A **bare tool-name rule** (`deny: ["Write"]`) matches at the tool level everywhere; `C6-uncoveredPath` + fires only on a *path* rule. +- `:*` **at the end** (`Bash(npm:*)`) is the working form; only mid-pattern use is dead. +- A parameter rule on a **non**-content field (`WebFetch(domain:example.com)`) is the working form. +- A POSIX-form absolute path (`//c/**/.env`) is the documented Windows spelling and must not trip + `C6-winPath`. + +**Advisory by contract: exit 0 whenever the lint ran.** Exit 2 means it could not run at all — never +"nothing found". A findings count of zero is printed as a summary line, so a clean plane is stated +rather than inferred from silence. + ## Managed policy, and what it does not buy > "no other level, including command line arguments, can override a managed permission rule." diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-plane-lint.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-plane-lint.sh new file mode 100755 index 0000000000..64bdf8131a --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-plane-lint.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# permission-plane-lint.sh — findings over the permission plane: configuration +# that is written but never read, and allow rules that cannot match. +# +# Every check here answers the same shape of question: the operator wrote +# something believing it takes effect, and it does not. The harness is not +# silent about all of these — several emit a startup warning — but a warning +# fires at session start, in one session, for one machine's scopes. This reads +# every scope at once, before a session, and says which file the dead entry is +# in. +# +# Input: permission-state.sh records on stdin (rule + conf + surface records). +# With no piped input the reader is run directly and its status propagated. +# +# Output: +# finding [] +# lint summary findings= checks_run= +# +# severity error | warning (review plugin's severity vocabulary) +# +# Checks, each citing the mechanic it follows from — see reference/criteria.md: +# C2-autoMode autoMode.* in a scope the classifier does not read +# C2-defaultMode defaultMode:"auto" in project or local settings +# C2-planMode useAutoModeDuringPlan in shared project settings +# C5-disableType disableAutoMode typed as a boolean, not the string "disable" +# C6-winPath a doubled-backslash Windows path in a rule +# C6-contentField a parameter-form rule on a tool's primary content field +# C6-uncoveredPath a path rule on a tool whose path rules are never consulted +# C6-colonStar `:*` used anywhere but at the end of a pattern +# +# Prerequisites: jq (required for correctness — the conf/settings reads are JSON). +# +# Usage: +# permission-state.sh | permission-plane-lint.sh +# permission-plane-lint.sh [--help] + +set -uo pipefail + +usage() { + cat <<'EOF' +permission-plane-lint.sh — find permission configuration that is written but never read. + +Usage: permission-state.sh | permission-plane-lint.sh + permission-plane-lint.sh [--help] + +Emits "finding [] " and a summary line. +Advisory: always exits 0 when it ran. Exit 2 means it could not run at all +(no scope records on input), never "nothing found". + +Reads only. Never writes any settings file. +EOF +} + +case "${1:-}" in +-h | --help) + usage + exit 0 + ;; +"") ;; +*) + echo "ERROR: unknown argument '$1'" >&2 + exit 2 + ;; +esac + +if ! command -v jq >/dev/null 2>&1; then + echo "ERROR: jq required" >&2 + exit 2 +fi + +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 lint" >&2 + exit 2 + fi + records="$(bash "$STATE_SCRIPT")" || exit $? +else + records="$(cat)" +fi + +# Settings paths per scope come from the surface records, so this script never +# re-derives a location the reader already resolved. A scope the reader could +# not open contributes no findings and is not claimed clean. +lint_out="$(printf '%s\n' "$records" | awk ' +function text_of(start, i, s) { + s = $start + for (i = start + 1; i <= NF; i++) s = s " " $i + return s +} +function finding(sev, check, scope, detail) { + print "finding " sev " [" check "] " scope " " detail + n_findings++ +} + +$1 == "rule" { + rules[++n_rules] = $2 SUBSEP $4 SUBSEP text_of(5) + next +} +$1 == "conf" { conf[$2 SUBSEP $4] = text_of(5); next } +$1 == "NOTE:" { next } +NF >= 3 { + n_surfaces++ + path_of[$1] = $4 + status_of[$1] = $3 + next +} + +END { + if (n_surfaces == 0) exit 2 + + # --- C2: configuration written where nothing reads it ---------------------- + # + # Three separate gates, reported separately. Merging them would hide that they + # have different scope sets and different version histories, and an operator + # fixing one would believe they had fixed all three. + # + # "The classifier does not read autoMode from project settings in + # .claude/settings.json or .claude/settings.local.json." Before v2.1.207 it + # also read local settings, so a local-scope section was live on an older + # harness -- the finding says so rather than implying it never worked. + split("project local startdir-local", dead_automode, " ") + for (i in dead_automode) { + s = dead_automode[i] + if ((s SUBSEP "autoModePresent") in conf) { + extra = (s == "project") ? "" : " (read from local settings before v2.1.207, so this may have been live on an older harness)" + finding("error", "C2-autoMode", s, "an autoMode section here is never read: the classifier reads autoMode from user settings, managed settings, and inline --settings/SDK JSON only" extra) + } + } + + # "Claude Code ignores defaultMode: auto in project and local settings… + # v2.1.142 and later ignore auto from those files so a repository cannot grant + # itself auto mode." Only the value `auto` is dead; other modes are read here. + for (i in dead_automode) { + s = dead_automode[i] + k = s SUBSEP "defaultMode" + if (k in conf && conf[k] == "\"auto\"") + finding("error", "C2-defaultMode", s, "defaultMode:\"auto\" is ignored in project and local settings so a repository cannot grant itself auto mode (v2.1.142 and later; before that, project settings could set it) — set it in user or managed settings instead") + } + + # "Not read from shared project settings." That names .claude/settings.json + # specifically, so a local-settings occurrence is NOT claimed dead here -- + # claiming it would be asserting a restriction the page does not state. + if ((("project") SUBSEP "useAutoModeDuringPlan") in conf) + finding("error", "C2-planMode", "project", "useAutoModeDuringPlan is not read from shared project settings — move it to user settings, where it takes effect") + + # --- C5: disableAutoMode typed as a boolean -------------------------------- + # + # "set permissions.disableBypassPermissionsMode or permissions.disableAutoMode + # to \"disable\" in any settings file" -- the STRING. A boolean is accepted by + # JSON and does nothing, which is the worst possible outcome for a lock-out + # switch: the operator believes auto mode is disabled and it is not. Checked + # at BOTH documented key paths, in EVERY scope: it is not managed-only. + split("managed user project local startdir-local", all_scopes, " ") + split("disableAutoMode permissions.disableAutoMode", disable_keys, " ") + for (i in all_scopes) { + for (j in disable_keys) { + k = all_scopes[i] SUBSEP disable_keys[j] + if (!(k in conf)) continue + if (conf[k] != "\"disable\"") + finding("error", "C5-disableType", all_scopes[i], disable_keys[j] " is " conf[k] ", but the documented value is the STRING \"disable\" — any other value is accepted and silently does nothing, so auto mode is NOT disabled here") + } + } + + # --- C6: allow rules that cannot match ------------------------------------- + # + # Primary content fields, verbatim from the permissions page: command for Bash + # and PowerShell, file_path for Read/Edit/Write, path for Grep and Glob, + # notebook_path for NotebookEdit, url for WebFetch. A parameter-form rule on + # one of these is ignored outright. + content_field["Bash"] = "command"; content_field["PowerShell"] = "command" + content_field["Read"] = "file_path"; content_field["Edit"] = "file_path" + content_field["Write"] = "file_path"; content_field["Grep"] = "path" + content_field["Glob"] = "path"; content_field["NotebookEdit"] = "notebook_path" + content_field["WebFetch"] = "url" + + # File permissions are checked against Edit(path) and Read(path) rules ONLY. + # A path rule for one of these is accepted and never consulted. + split("Write NotebookEdit Glob MultiEdit", uncovered_list, " ") + for (i in uncovered_list) uncovered[uncovered_list[i]] = 1 + + for (r = 1; r <= n_rules; r++) { + split(rules[r], f, SUBSEP) + scope = f[1]; kind = f[2]; text = f[3] + p = index(text, "(") + tool = p ? substr(text, 1, p - 1) : text + body = p ? substr(text, p + 1, length(text) - p - 1) : "" + + # A bare tool-name rule with no path is legitimate and matches at the tool + # level everywhere -- the page says so explicitly, and flagging it would be + # a false positive on the most ordinary rule shape there is. + if (body == "") continue + + # Doubled backslashes: on Windows a rule path is normalized to POSIX form + # before matching, so a JSON-escaped Windows path never matches anything. + if (index(body, "\\\\") > 0) + finding("error", "C6-winPath", scope, text " — a Windows-style path in a rule cannot match: rule paths are normalized to POSIX form (C:\\Users\\alice becomes /c/Users/alice), so use //c/** form instead") + + # `:*` is recognized only at the end of a pattern; elsewhere the colon is a + # literal, so the rule silently matches nothing it was meant to. + cs = index(body, ":*") + if (cs > 0 && cs + 1 < length(body)) + finding("error", "C6-colonStar", scope, text " — the :* form is only recognized at the END of a pattern; here the colon is treated as a literal character and the rule will not match what it looks like it matches") + + # Parameter form is `Tool(param:value)`. It is only a content-field defect + # when the named parameter IS the primary content field for that tool. + colon = index(body, ":") + if (colon > 1) { + param = substr(body, 1, colon - 1) + if (tool in content_field && param == content_field[tool]) + finding("error", "C6-contentField", scope, text " — a rule cannot match a tool primary content field by parameter (" tool " uses " content_field[tool] "); Claude Code ignores this rule and warns at startup") + } + + # A path-shaped rule on a tool whose path rules are never consulted. Only + # allow/deny path shapes are relevant, and only when the body actually looks + # like a path rather than a parameter form. + if (tool in uncovered && colon == 0) + finding("warning", "C6-uncoveredPath", scope, text " — file permissions are checked against Edit(path) and Read(path) rules only, so a path rule for " tool " is accepted but never consulted (warns at startup, v2.1.210+; a Glob rule passed in --allowedTools is the documented exception)") + } + + print "lint summary findings=" n_findings + 0 " checks_run=8" +} +')" || { + echo "ERROR: no scope records on input — permission-plane-lint.sh will not report a clean plane it never read" >&2 + exit 2 +} + +printf '%s\n' "$lint_out" diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/permission-plane-lint.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/permission-plane-lint.test.sh new file mode 100755 index 0000000000..0995dec2ea --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-plane-lint.test.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash +# Regression tests for permission-plane-lint.sh (self-contained — ships with the plugin). +# +# Every case feeds hand-written reader records on stdin, so no test reads or +# writes the operator's real ~/.claude. The one end-to-end case drives the real +# reader over a fully fixtured tree with HOME redirected and CLAUDE_CONFIG_DIR +# unset. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/permission-plane-lint.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"; } + +lint() { printf '%s\n' "$1" | bash "$SCRIPT"; } + +SURFACES='managed file present /policy/managed-settings.json +user settings present /fx/home/.claude/settings.json +project settings present /proj/.claude/settings.json +local settings present /proj/.claude/settings.local.json +startdir-local settings present /start/.claude/settings.local.json' + +# --- C2: three dead-config gates, reported SEPARATELY ------------------------- +# The plan requires these never merge into one finding: they have different +# scope sets and different version histories, so an operator who fixed one +# would believe they had fixed all three. +C2=$( + printf '%s\n' "$SURFACES" + cat <<'EOF' +conf project settings autoModePresent true +conf project settings defaultMode "auto" +conf project settings useAutoModeDuringPlan false +EOF +) +OUT=$(lint "$C2") +assert_eq "the autoMode gate fires exactly once" 1 "$(count_matching "$OUT" '\[C2-autoMode\]')" +assert_eq "the defaultMode gate fires exactly once" 1 "$(count_matching "$OUT" '\[C2-defaultMode\]')" +assert_eq "the plan-mode gate fires exactly once" 1 "$(count_matching "$OUT" '\[C2-planMode\]')" +assert_contains "the defaultMode finding cites its version gate" "$OUT" "v2.1.142" +assert_contains "the plan-mode finding names the scope restriction" "$OUT" "not read from shared project settings" + +# autoMode in LOCAL settings says it was live before v2.1.207 rather than +# implying it never worked. +C2_LOCAL=$( + printf '%s\n' "$SURFACES" + printf 'conf local settings autoModePresent true\n' +) +OUT=$(lint "$C2_LOCAL") +assert_contains "a local autoMode section fires" "$OUT" "[C2-autoMode] local" +assert_contains "and carries its version history" "$OUT" "v2.1.207" + +# The scopes the classifier DOES read are silent. +C2_LIVE=$( + printf '%s\n' "$SURFACES" + cat <<'EOF' +conf user settings autoModePresent true +conf managed file autoModePresent true +conf user settings defaultMode "auto" +conf user settings useAutoModeDuringPlan false +EOF +) +OUT=$(lint "$C2_LIVE") +assert_eq "no C2 finding on a scope the classifier reads" 0 "$(count_matching "$OUT" '\[C2-')" + +# Only the value `auto` is dead in project scope; other modes are read there. +C2_OTHER=$( + printf '%s\n' "$SURFACES" + printf 'conf project settings defaultMode "acceptEdits"\n' +) +OUT=$(lint "$C2_OTHER") +assert_eq "a non-auto defaultMode in project scope is legitimate" 0 "$(count_matching "$OUT" '\[C2-defaultMode\]')" + +# The page restricts useAutoModeDuringPlan to SHARED PROJECT settings by name. +# Claiming a local occurrence is dead would assert a restriction no page states. +C2_PLAN_LOCAL=$( + printf '%s\n' "$SURFACES" + printf 'conf local settings useAutoModeDuringPlan false\n' +) +OUT=$(lint "$C2_PLAN_LOCAL") +assert_eq "a local useAutoModeDuringPlan is not claimed dead" 0 "$(count_matching "$OUT" '\[C2-planMode\]')" + +# --- C5: disableAutoMode must be the STRING "disable" ------------------------- +# The worst failure mode in this file: an operator believes auto mode is locked +# out and it is not. +C5=$( + printf '%s\n' "$SURFACES" + cat <<'EOF' +conf user settings disableAutoMode true +conf project settings permissions.disableAutoMode false +conf managed file disableAutoMode "disable" +conf local settings permissions.disableAutoMode "disable" +EOF +) +OUT=$(lint "$C5") +assert_eq "both wrongly-typed entries fire, and only those" 2 "$(count_matching "$OUT" '\[C5-disableType\]')" +assert_contains "the boolean at the top-level key fires" "$OUT" "[C5-disableType] user disableAutoMode is true" +assert_contains "the boolean under permissions fires too" "$OUT" "[C5-disableType] project permissions.disableAutoMode is false" +assert_contains "the finding states the consequence, not just the type" "$OUT" "auto mode is NOT disabled here" + +# It is not managed-only: every scope is checked. +C5_MANAGED=$( + printf '%s\n' "$SURFACES" + printf 'conf managed file permissions.disableAutoMode true\n' +) +OUT=$(lint "$C5_MANAGED") +assert_contains "managed scope is checked like any other" "$OUT" "[C5-disableType] managed" + +# --- C6: rules that cannot match --------------------------------------------- +C6=$( + printf '%s\n' "$SURFACES" + cat <<'EOF' +rule user settings allow Read(C:\\Users\\x\\.env) +rule user settings allow Bash(git:* push) +rule project settings allow Bash(command:rm *) +rule project settings allow Write(file_path:/etc/**) +rule project settings allow Write(docs/**) +EOF +) +OUT=$(lint "$C6") +assert_eq "the doubled-backslash path fires once" 1 "$(count_matching "$OUT" '\[C6-winPath\]')" +assert_eq "the mid-pattern :* fires once" 1 "$(count_matching "$OUT" '\[C6-colonStar\]')" +assert_eq "both content-field rules fire" 2 "$(count_matching "$OUT" '\[C6-contentField\]')" +assert_eq "the uncovered path rule fires once" 1 "$(count_matching "$OUT" '\[C6-uncoveredPath\]')" +assert_contains "the winPath finding gives the working form" "$OUT" "//c/**" + +# Legitimate rule shapes that MUST NOT fire. Each of these is a false positive +# the checks were specifically written to avoid. +C6_CLEAN=$( + printf '%s\n' "$SURFACES" + cat <<'EOF' +rule user settings allow Bash(npm test) +rule user settings allow Bash(git:*) +rule user settings allow Read(//c/**/.env) +rule user settings allow Read(/c/Users/x/.env) +rule user settings deny Write +rule user settings deny Glob +rule user settings allow Edit(src/**) +rule user settings allow Read(docs/**) +rule user settings allow Bash(timeout:30) +EOF +) +OUT=$(lint "$C6_CLEAN") +assert_eq "no finding on any legitimate rule shape" 0 "$(count_matching "$OUT" '^finding ')" + +# A bare tool-name rule is legitimate AT THE TOOL LEVEL and is the single most +# common rule shape there is; flagging it would drown every real finding. +C6_BARE=$( + printf '%s\n' "$SURFACES" + printf 'rule user settings deny Write\nrule user settings deny NotebookEdit\nrule user settings deny MultiEdit\n' +) +OUT=$(lint "$C6_BARE") +assert_eq "bare tool-name rules never fire the uncovered-path check" 0 "$(count_matching "$OUT" '\[C6-uncoveredPath\]')" + +# `:*` AT the end is the documented working form; only mid-pattern is dead. +C6_COLON=$( + printf '%s\n' "$SURFACES" + printf 'rule user settings allow Bash(npm:*)\nrule user settings allow Bash(git:* push)\n' +) +OUT=$(lint "$C6_COLON") +assert_eq "only the mid-pattern colon-star fires" 1 "$(count_matching "$OUT" '\[C6-colonStar\]')" +assert_contains "and it is the mid-pattern one" "$OUT" "Bash(git:* push)" + +# A parameter rule on a NON-content field is the documented working form. +C6_PARAM=$( + printf '%s\n' "$SURFACES" + printf 'rule user settings allow WebFetch(domain:example.com)\nrule user settings allow WebFetch(url:https://x)\n' +) +OUT=$(lint "$C6_PARAM") +assert_eq "only the content-field parameter rule fires" 1 "$(count_matching "$OUT" '\[C6-contentField\]')" +assert_contains "and it is the url one" "$OUT" "WebFetch(url:https://x)" + +# --- Reporting shape ---------------------------------------------------------- +OUT=$(lint "$C6") +assert_contains "every finding carries a severity" "$OUT" "finding error [" +assert_eq "every finding line has a bracketed check id" \ + "$(count_matching "$OUT" '^finding ')" "$(count_matching "$OUT" '^finding [a-z]+ \[C[0-9]')" +assert_contains "the summary states how many checks ran" "$OUT" "checks_run=8" + +# A clean plane is reported as clean, with the check count, not as silence. +OUT=$(lint "$SURFACES") +assert_contains "a clean run still reports its summary" "$OUT" "lint summary findings=0" + +# --- Fail-loud: no input is never a clean bill -------------------------------- +rc=0 +err=$(printf '' | bash "$SCRIPT" 2>&1) || rc=$? +assert_exit "exit 2 on empty input" 2 "$rc" +assert_contains "the error says it will not claim a clean plane" "$err" "will not report a clean plane" + +rc=0 +printf 'NOTE: only a note\n' | bash "$SCRIPT" >/dev/null 2>&1 || rc=$? +assert_exit "notes alone are not scope records" 2 "$rc" + +rc=0 +err=$(bash "$SCRIPT" --bogus &1) || rc=$? +assert_exit "exit 2 on an unknown argument" 2 "$rc" +assert_contains "the unknown argument is named" "$err" "unknown argument" + +rc=0 +help=$(bash "$SCRIPT" --help &1) || rc=$? +assert_exit "--help exits 0" 0 "$rc" +assert_contains "--help states the advisory contract" "$help" "never \"nothing found\"" + +# --- End to end: real reader into the lint ------------------------------------ +if command -v jq >/dev/null 2>&1; then + FX="$TEST_TMPDIR/fx" + mkdir -p "$FX/proj/.claude" "$FX/home/.claude" "$FX/policy" "$FX/startdir/.claude" + jq -n '{ + autoMode: {classifyAllShell: true}, + permissions: {defaultMode: "auto", allow: ["Write(docs/**)"]}, + useAutoModeDuringPlan: false + }' >"$FX/proj/.claude/settings.json" + jq -n '{permissions: {disableAutoMode: true, allow: ["Bash(npm test)"]}}' >"$FX/home/.claude/settings.json" + jq -n '{}' >"$FX/policy/managed-settings.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") + + assert_contains "end to end: the dead autoMode section is found" "$E2E" "[C2-autoMode] project" + assert_contains "end to end: the dead defaultMode is found" "$E2E" "[C2-defaultMode] project" + assert_contains "end to end: the dead plan-mode key is found" "$E2E" "[C2-planMode] project" + assert_contains "end to end: the mistyped lock-out switch is found" "$E2E" "[C5-disableType] user" + assert_contains "end to end: the never-consulted path rule is found" "$E2E" "[C6-uncoveredPath] project" + assert_not_contains "end to end: the narrow allow rule is not flagged" "$E2E" "Bash(npm test)" +else + pass "end-to-end reader lint (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 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 eb1d65c9d0..f886c1f70d 100755 --- a/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/permission-state.sh @@ -210,16 +210,41 @@ emit_file_rules() { done } -# The one permission-plane config key inventoried alongside the rules: -# autoMode.classifyAllShell suspends every Bash/PowerShell allow rule while auto -# mode is active, so an entry diff that cannot see it can be exactly wrong. -# Emitted as JSON (tojson) so boolean true and string "true" stay distinct. +# The permission-plane config keys inventoried alongside the rules. Each is here +# because some downstream consumer is wrong without it: +# classifyAllShell suspends every Bash/PowerShell allow rule while auto mode +# is active, so an entry diff blind to it can be exactly wrong +# autoModePresent whether an autoMode section exists at all — the classifier +# does not read it from project or local settings, so its +# mere presence there is a dead-config finding +# defaultMode `auto` is ignored in project and local settings +# useAutoModeDuringPlan not read from shared project settings +# disableAutoMode / permissions.disableAutoMode +# accepted at BOTH key paths, and must be the STRING +# "disable" — a boolean is a silent no-op +# +# Values are emitted as JSON (tojson) so a boolean true and the string "true" +# stay distinguishable downstream; for the type-confusion check that distinction +# IS the finding. emit_file_conf() { # emit_file_conf [[ "$mode" == "full" ]] || return 0 - local scope="$1" surface="$2" v - v="$(jq -r 'if (.autoMode | type) == "object" and (.autoMode | has("classifyAllShell")) then (.autoMode.classifyAllShell | tojson) else empty end' 2>/dev/null | tr -d '\r')" - [[ -n "$v" ]] && printf 'conf %s %s classifyAllShell %s\n' "$scope" "$surface" "$v" + local scope="$1" surface="$2" json + json="$(cat)" + local key expr + while IFS='=' read -r key expr; do + [[ -n "$key" ]] || continue + local v + v="$(printf '%s' "$json" | jq -r "$expr" 2>/dev/null | tr -d '\r')" + [[ -n "$v" ]] && printf 'conf %s %s %s %s\n' "$scope" "$surface" "$key" "$v" + done <<'CONF_KEYS' +classifyAllShell=if (.autoMode | type) == "object" and (.autoMode | has("classifyAllShell")) then (.autoMode.classifyAllShell | tojson) else empty end +autoModePresent=if has("autoMode") then "true" else empty end +defaultMode=if (.permissions | type) == "object" and (.permissions | has("defaultMode")) then (.permissions.defaultMode | tojson) elif has("defaultMode") then (.defaultMode | tojson) else empty end +useAutoModeDuringPlan=if has("useAutoModeDuringPlan") then (.useAutoModeDuringPlan | tojson) else empty end +disableAutoMode=if has("disableAutoMode") then (.disableAutoMode | tojson) else empty end +permissions.disableAutoMode=if (.permissions | type) == "object" and (.permissions | has("disableAutoMode")) then (.permissions.disableAutoMode | tojson) else empty end +CONF_KEYS return 0 } From 46e23d90ada5c4f98295937d963b737cc099d60e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:28:27 -0400 Subject: [PATCH 13/32] feat(claude-config): lint the autoMode classifier block, and price reading it A different surface from everything else in this skill: four natural-language sections an LLM classifier reads, not permission rules the harness matches. So only the mechanical checks live here. `claude auto-mode critique` owns the semantic judgment and is surfaced rather than reimplemented. The headline finding is a section that omits "$defaults". Customizing a section REPLACES the built-in list rather than adding to it, so the finding names how many built-in entries are gone and the first one by label -- "you dropped 65 soft_deny rules" is actionable where "missing $defaults" is not. It fires only when entries are actually missing, never merely when the token is absent: the CLI expands "$defaults" in its own output, so an expanded section and an omitted one differ only in what is gone, and the first draft fired on three sections the fixture had never customized. Contradiction and shadowing compare label SUBJECTS, never bodies. The bodies are prose written for an LLM; no mechanical comparison of prose is defensible, and attempting one would be exactly the judgment reserved for critique. Reading this block at all required the whole measured defensive contract, every item of it a way to have reported a confident wrong answer: - `auto-mode config` emits raw control characters inside JSON string values. jq rejects it outright, exit status still 0. The offending byte is a raw line feed inside a string, so no line-oriented POSIX filter can tell it from the pretty-printer's structural newlines -- which is why this lane needs a real parser, and why pure POSIX was tested and rejected rather than assumed inadequate. - `defaults --label` OMITS a non-matching key rather than returning an empty list, so a missing key is tolerated as "no entries", not raised. - Labels carry a bracketed annotation before the colon (`Git Destructive [named+specifics ...]: ...`), so the split is at the first `[`, verified against the real captured payload rather than a description. - Exit status is never consulted. A run that produced nothing is reported status=unavailable with an explicit "NOT a clean bill", because "your block is clean" and "the block was never read" are different claims and only one of them is true. The raw-control-character fixture is ASSERTED invalid, not assumed: the suite runs `jq -e .` on it and fails if jq parses cleanly. That caught a real defect during construction -- the fixture had been regenerated through json.dumps, which escaped the line feed, so the headline regression case was testing nothing. It is now authored as explicit bytes and pinned `-text` in .gitattributes, because line-ending normalization would rewrite that byte and silently make it valid JSON again. The lane is optional by declaration: absent python3 or claude it prints a visible skip notice and exits 0, and every other stage of the skill still runs. 44 checks, shellcheck clean, portability gate clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- .gitattributes | 8 + docs/topics/permission-model/PLAN.md | 30 +- plugins/claude-config/CHANGELOG.md | 15 +- .../skills/audit-permission-state/SKILL.md | 29 ++ .../fixtures/automode-config-rawctl.json | 19 ++ .../evals/fixtures/automode-defaults.json | 17 + .../reference/criteria.md | 41 +++ .../scripts/automode-block-lint.sh | 323 ++++++++++++++++++ .../scripts/automode-block-lint.test.sh | 213 ++++++++++++ 9 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 plugins/claude-config/skills/audit-permission-state/evals/fixtures/automode-config-rawctl.json create mode 100644 plugins/claude-config/skills/audit-permission-state/evals/fixtures/automode-defaults.json create mode 100755 plugins/claude-config/skills/audit-permission-state/scripts/automode-block-lint.sh create mode 100755 plugins/claude-config/skills/audit-permission-state/scripts/automode-block-lint.test.sh diff --git a/.gitattributes b/.gitattributes index 905610707a..15f2c81aa1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -59,3 +59,11 @@ packages.lock.json -diff *.woff2 binary *.ttf binary *.otf binary + +############################################################################### +# Byte-exact fixtures. This one reproduces a measured Claude Code defect: a RAW +# control character inside a JSON string value, which `jq` rejects outright. Any +# line-ending normalization would rewrite that byte and the fixture would become +# valid JSON, silently turning its regression case into a test of nothing. +############################################################################### +plugins/claude-config/skills/audit-permission-state/evals/fixtures/automode-config-rawctl.json -text diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index ec1db78bdd..c7f5f10fa3 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -619,7 +619,35 @@ Acceptance criteria 2, 5, 6. 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] +### Phase 5: `autoMode`-block lane [DONE] + +**Completed 2026-08-12.** `automode-block-lint.test.sh` 44/44, `shellcheck -x` clean, portability +gate clean. Every sanity check the phase names is covered, each against a **checked-in fixture** and +never against the operator's CLI. + +**The raw-control-character fixture is asserted to be invalid, not assumed to be.** The suite runs +`jq -e .` on it and FAILS if jq parses cleanly — which caught a real defect during construction: the +fixture had been regenerated through `json.dumps`, which escaped the line feed, so it was valid JSON +and the headline case was testing nothing. It is now authored as explicit bytes with one raw `\x0a` +inside a string value; jq exits 5, strict `json.loads` raises, and the shipped reader returns all four +sections. + +**Deviations recorded rather than silent:** + +- **`C4-defaults` fires only when built-in entries are actually MISSING**, not merely when + `"$defaults"` is absent. The CLI expands the token in its own output, so an expanded section and an + omitted one are indistinguishable except by what is gone — the first draft fired on three sections + the fixture had never customized. The finding now names how many entries are discarded and the first + one by label. +- **Contradiction and shadowing compare label SUBJECTS, not bodies.** The bodies are prose written for + an LLM; no mechanical comparison of prose is defensible, and claiming one would be exactly the + judgment the plan reserves for `critique`. +- **Truncation is detected from the text, not the exit status** — a complete critique ends on + sentence-final punctuation. Exit status cannot carry it: measured 0 on all three runs, including the + empty one. +- **The label split at `[` is verified against the real captured shape**, not the plan's description: + the measured `defaults` payload carries `Git Destructive [named+specifics **must name:** …]: …`, + where a split at the first `:` lands inside the annotation. Acceptance criteria 4 and 8, plus brainstorm candidates 1, 2, 3. diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index 22610d6272..ec2297d9a3 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -73,7 +73,20 @@ All notable changes to the `claude-config` plugin are documented here. Format fo is read at both documented key paths in every scope, since it is not managed-only. Several of these also emit a startup warning upstream; the added value is reading every scope at once, before a session, and naming the file. Advisory: it exits 0 whenever it ran, and exit 2 means it could not - run at all rather than that it found nothing. Every run states the two bounds on the claim: the command-line scope + run at all rather than that it found nothing. + A fifth lane reads the `autoMode` classifier block — a different surface again, four + natural-language sections rather than permission rules. It reports a customized section that omits + `"$defaults"` (which **replaces** the built-in list rather than adding to it, so the finding names + how many entries are discarded), the same subject appearing in both `allow` and a deny section, and + an entry an earlier `hard_deny` already forecloses. `claude auto-mode critique` is surfaced with + `--critique` rather than reimplemented — it owns the semantic judgment — but it is wrapped in + truncation and empty-output detection, because across three consecutive runs on one unchanged config + its output was truncated mid-sentence twice and empty once while exiting 0 every time. This lane + needs `python3`, because `claude auto-mode config` emits raw control characters inside JSON string + values that `jq` rejects outright and no line-oriented filter can repair; absent it, the lane prints + a visible skip notice and exits 0 while every other stage still runs. A capture that produced + nothing is reported as unavailable with an explicit "this is NOT a clean bill" — exit status is + never consulted, since it is 0 even when nothing came back. 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 8913e18a74..95ee272ac5 100644 --- a/plugins/claude-config/skills/audit-permission-state/SKILL.md +++ b/plugins/claude-config/skills/audit-permission-state/SKILL.md @@ -165,6 +165,35 @@ the checks are written NOT to flag. - **Advisory: the lint always exits 0 when it ran.** Exit 2 means it could not run at all, never "nothing found". +## Phase 5: The `autoMode` classifier block + +A different surface from everything above: four natural-language sections an LLM classifier reads, +not permission rules the harness matches. Independent of the pipeline — it reads the CLI, not stdin: + +```shell +bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-state/scripts/automode-block-lint.sh" [--critique] +``` + +- **`C4-defaults`** — a customized section that omits `"$defaults"`. Customizing **replaces** the + built-in list rather than adding to it, so the finding names how many built-in entries are gone. +- **`C2b-contradiction`** — the same subject in `allow` and in a deny section. +- **`C3-shadowed`** — an entry an earlier `hard_deny` already forecloses, so it can never fire. +- **`--critique` surfaces `claude auto-mode critique`, wrapped — never replaced.** It owns the + semantic judgment. What this adds is honesty about it: measured across three consecutive runs on one + unchanged config, output was truncated mid-sentence twice and empty once, **exiting 0 every time**. + A mid-sentence cut is reported as truncated; an empty result says "critique returned nothing; run it + yourself" rather than implying your rules are clean. + +**This lane is optional, and its prerequisite is nobody else's problem.** It needs `python3` because +`claude auto-mode config` emits raw control characters inside string values — `jq` rejects the output +outright, and no line-oriented POSIX filter can repair it, since the offending byte is a raw line feed +inside a string. Absent `python3` or `claude`, the lane prints a visible skip notice and exits 0; every +other stage still runs. + +**Exit status is never trusted here.** A run that exits 0 having produced nothing is reported +`status=unavailable` with an explicit "this is NOT a clean bill". The distinction between "your block +is clean" and "the block was never read" is the whole point. + ## Reading the output honestly diff --git a/plugins/claude-config/skills/audit-permission-state/evals/fixtures/automode-config-rawctl.json b/plugins/claude-config/skills/audit-permission-state/evals/fixtures/automode-config-rawctl.json new file mode 100644 index 0000000000..e5957933c7 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/evals/fixtures/automode-config-rawctl.json @@ -0,0 +1,19 @@ +{ + "allow": [ + "$defaults", + "Data Exfiltration: uploading build artifacts to the release bucket.", + "Custom Deploy: running the deploy +script." + ], + "soft_deny": [ + "Credential Read: reading files that hold secrets." + ], + "hard_deny": [ + "Data Exfiltration: sensitive data crossing the trust boundary." + ], + "environment": [ + "**Organization**: None configured", + "**Repos**: none", + "**Extra**: melodic-software/*" + ] +} diff --git a/plugins/claude-config/skills/audit-permission-state/evals/fixtures/automode-defaults.json b/plugins/claude-config/skills/audit-permission-state/evals/fixtures/automode-defaults.json new file mode 100644 index 0000000000..d181e45e0c --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/evals/fixtures/automode-defaults.json @@ -0,0 +1,17 @@ +{ + "allow": [ + "Security Discussion: reading or reviewing security-related code is not exploitation.", + "Test Execution: running a repository's own test suite." + ], + "soft_deny": [ + "Git Destructive [named+specifics **must name:** the operation and its target]: force pushing, deleting remote branches.", + "Credential Read: reading files that hold secrets." + ], + "hard_deny": [ + "Data Exfiltration: sensitive data crossing the trust boundary." + ], + "environment": [ + "**Organization**: None configured", + "**Repos**: none" + ] +} 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 81e43dd137..bb6b4d573b 100644 --- a/plugins/claude-config/skills/audit-permission-state/reference/criteria.md +++ b/plugins/claude-config/skills/audit-permission-state/reference/criteria.md @@ -181,6 +181,47 @@ does nothing — so the operator believes auto mode is locked out and it is not. "nothing found". A findings count of zero is printed as a summary line, so a clean plane is stated rather than inferred from silence. +## The `autoMode` block lane + +A different surface from the permission plane: four natural-language sections (`environment`, +`allow`, `soft_deny`, `hard_deny`) that an LLM classifier reads. Only mechanical checks live here — +prose judgment belongs to `claude auto-mode critique`, which is surfaced rather than reimplemented. + +| Check | What it means | +| --- | --- | +| `C4-defaults` | a customized section omits `"$defaults"`, which **replaces** the built-in list rather than adding to it. The finding names how many built-in entries are gone, because "you dropped 65 soft_deny rules" is actionable where "missing $defaults" is not | +| `C2b-contradiction` | the same label subject appears in `allow` and in a deny section | +| `C3-shadowed` | an entry whose subject is already in `hard_deny`, which cannot be overridden, so the entry can never change an outcome | + +Comparison is by **label subject**, never by body text. The bodies are prose written for an LLM, and +no mechanical comparison of prose is defensible; a shared label across two sections is a mechanical +signal, and the finding says which two sections to reconcile rather than which one is right. + +A section still carrying every built-in entry was **never customized** — the CLI expands `"$defaults"` +in its own output, so an expanded section and an omitted one differ only in what is missing. Firing on +the expanded case would report a discard that did not happen. + +### The measured defensive contract + +Every item below was measured on 2.1.225, not assumed. Each is a way this lane could have reported a +confident wrong answer. + +| Defect | What the reader does | +| --- | --- | +| `claude auto-mode config` emits **raw control characters inside JSON string values** — `jq` and strict `json.loads` both reject it, exit status still 0 | parses non-strictly. The offending byte is a raw line feed inside a string, so no line-oriented POSIX filter can distinguish it from the pretty-printer's structural newlines — which is why this lane needs a real parser and why pure POSIX was tested and rejected | +| `defaults --label ` **omits** a non-matching key entirely rather than returning an empty list | tolerates a missing key as "no entries", never as an error | +| Entry labels carry a bracketed annotation **before** the colon — `Git Destructive [named+specifics …]: …` | splits at the first `[` when one precedes the colon, so the label is not truncated mid-annotation | +| **Exit status is never trustworthy** — `critique` returned 0 on a run producing no output at all | judges every capture by whether it yielded usable content. A run that produced nothing is `status=unavailable` with an explicit "NOT a clean bill", never success | + +### Optional by declaration + +`python3` is **required for an optional feature** — this lane only. Absent, the lane prints a visible +skip notice and exits 0, and every other stage of the skill is unaffected. Node is an equally capable +host and is deliberately not adopted: a second optional runtime doubles the declaration surface for +one feature. + +`claude auto-mode reset` is never run. It strips the `autoMode` section from user settings. + ## Managed policy, and what it does not buy > "no other level, including command line arguments, can override a managed permission rule." diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/automode-block-lint.sh b/plugins/claude-config/skills/audit-permission-state/scripts/automode-block-lint.sh new file mode 100755 index 0000000000..d02a8ff99c --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/automode-block-lint.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env bash +# automode-block-lint.sh — findings over the `autoMode` block: what a customized +# section discards, what contradicts itself, and what can never fire. +# +# This lane is about the CLASSIFIER's rule lists, not the permission-rule plane. +# Its four sections (environment, allow, soft_deny, hard_deny) are natural-language +# entries an LLM reads, so the checks here are the mechanical ones only: what a +# section omits, what two entries say about the same subject, and what an earlier +# hard_deny already forecloses. `claude auto-mode critique` owns the semantic +# judgment and is surfaced by --critique rather than reimplemented. +# +# Reading the CLI is where this gets hard, and every defect below was MEASURED on +# 2.1.225 rather than guessed at: +# +# 1. `claude auto-mode config` emits raw control characters inside JSON string +# values. jq and Python json.loads both reject it; exit status is still 0. +# The offending byte is a raw line feed inside a string, so no line-oriented +# POSIX filter can tell it from the pretty-printer's structural newlines -- +# that is why this lane needs a non-strict parser and why pure POSIX was +# tested and rejected. +# 2. `defaults --label ` OMITS a non-matching key entirely rather than +# returning an empty list. A reader that expects an empty array sees a +# KeyError, not a clean "no entries". +# 3. Entry labels carry a bracketed annotation BEFORE the colon +# (`Git Destructive [named+specifics ...]: ...`), so a label split at the +# first `:` truncates. Split at the first `[` when one precedes the colon. +# 4. Exit status is never trustworthy: `critique` returned 0 on a run that +# produced no output at all. A run that produced nothing usable says so and +# never reports success. +# +# Prerequisites: +# python3 REQUIRED FOR AN OPTIONAL FEATURE -- this lane only. Absent: warn +# visibly, skip the lane, exit 0. Every other stage of the skill is +# unaffected. Node is an equally capable host and is deliberately not +# adopted: a second optional runtime doubles the declaration surface +# for one feature. +# claude the same, for reading the live config. +# +# Test seams (no test may invoke the real CLI or read the operator's config): +# AUTOMODE_CONFIG_FIXTURE parse this file instead of `claude auto-mode config` +# AUTOMODE_DEFAULTS_FIXTURE parse this file instead of `claude auto-mode defaults` +# AUTOMODE_CRITIQUE_FIXTURE read this file instead of running `critique` +# +# Output: +# finding []
+# BLOCK-NOTE: +# automode summary findings= sections= +# +# C4-defaults a customized section that omits "$defaults" +# C2b-contradiction the same subject allowed and denied +# C3-shadowed an entry an earlier hard_deny already forecloses +# +# Usage: +# automode-block-lint.sh [--critique] [--help] + +set -uo pipefail + +usage() { + cat <<'EOF' +automode-block-lint.sh — lint the autoMode classifier block. + +Usage: automode-block-lint.sh [--critique|--help] + + (no arg) the mechanical checks: $defaults omissions, contradictions, shadowing + --critique additionally surface `claude auto-mode critique`, wrapped in + truncation and empty-output detection + --help this message + +Requires python3 and claude for this lane only; absent, it prints a visible skip +notice and exits 0 so the rest of the skill still runs. Reads only — it never +runs 'claude auto-mode reset' and never writes any settings file. +EOF +} + +critique=0 +case "${1:-}" in +-h | --help) + usage + exit 0 + ;; +--critique) critique=1 ;; +"") ;; +*) + echo "ERROR: unknown argument '$1'" >&2 + exit 2 + ;; +esac + +note() { printf 'BLOCK-NOTE: %s\n' "$1"; } + +# The lane is optional as a whole: a missing runtime degrades it visibly and +# leaves every other stage of the skill intact. That is the documented contract +# for an optional feature, and the alternative -- failing the run -- would make +# one lane's prerequisite everyone's problem. +PY="" +for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1; then + PY="$candidate" + break + fi +done +if [[ -z "$PY" ]]; then + note "autoMode block lane SKIPPED: python3 is not on PATH. This lane needs a non-strict JSON parser because 'claude auto-mode config' emits raw control characters inside string values, which no line-oriented POSIX filter can repair. Every other stage of this skill is unaffected." + echo "automode summary findings=0 sections=0 status=skipped" + exit 0 +fi + +CONFIG_FIXTURE="${AUTOMODE_CONFIG_FIXTURE:-}" +DEFAULTS_FIXTURE="${AUTOMODE_DEFAULTS_FIXTURE:-}" + +if [[ -z "$CONFIG_FIXTURE" || -z "$DEFAULTS_FIXTURE" ]] && ! command -v claude >/dev/null 2>&1; then + note "autoMode block lane SKIPPED: 'claude' is not on PATH, so the classifier's own rule lists cannot be read. Every other stage of this skill is unaffected." + echo "automode summary findings=0 sections=0 status=skipped" + exit 0 +fi + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/automode-lint.XXXXXX")" || { + echo "ERROR: cannot create a scratch directory" >&2 + exit 2 +} +trap 'rm -rf "$tmp"' EXIT + +# Exit status is never trusted (measured: critique returned 0 producing nothing), +# so each capture is judged by whether it yielded usable content. +capture() { + # capture + local out="$1" fixture="$2" sub="$3" + if [[ -n "$fixture" ]]; then + [[ -r "$fixture" ]] || return 1 + cat "$fixture" >"$out" 2>/dev/null + else + claude auto-mode "$sub" >"$out" 2>/dev/null + fi + [[ -s "$out" ]] +} + +if ! capture "$tmp/config.json" "$CONFIG_FIXTURE" config; then + note "autoMode block lane produced no usable output: 'claude auto-mode config' returned nothing readable. This is NOT a clean bill -- the block was not read. Exit status from that command is known to be 0 even when it produces nothing, so it is deliberately not consulted here." + echo "automode summary findings=0 sections=0 status=unavailable" + exit 0 +fi +if ! capture "$tmp/defaults.json" "$DEFAULTS_FIXTURE" defaults; then + note "autoMode block lane produced no usable output: 'claude auto-mode defaults' returned nothing readable, so a \$defaults omission cannot be judged." + echo "automode summary findings=0 sections=0 status=unavailable" + exit 0 +fi + +"$PY" - "$tmp/config.json" "$tmp/defaults.json" <<'PYEOF' +import json +import re +import sys + +# strict=False is the whole reason this lane needs a real parser: the measured +# defect is a raw line feed INSIDE a JSON string value, spliced in from +# user-authored entries without re-escaping. jq rejects it outright. +def load(path): + with open(path, encoding="utf-8", errors="replace") as fh: + raw = fh.read() + try: + return json.loads(raw, strict=False), None + except json.JSONDecodeError as exc: + return None, str(exc) + + +config, config_err = load(sys.argv[1]) +defaults, defaults_err = load(sys.argv[2]) + +if config is None or defaults is None: + print( + "BLOCK-NOTE: autoMode block lane could not parse the classifier output even " + "with a non-strict parser (%s). Reporting it as unread rather than as an " + "empty block." % (config_err or defaults_err) + ) + print("automode summary findings=0 sections=0 status=unparseable") + raise SystemExit(0) + +SECTIONS = ("environment", "allow", "soft_deny", "hard_deny") +findings = 0 + + +def finding(severity, check, section, detail): + global findings + print("finding %s [%s] %s %s" % (severity, check, section, detail)) + findings += 1 + + +def entries(block, name): + # A non-matching key is OMITTED, not returned empty -- measured on + # `defaults --label`. Treating a missing key as an error would report a + # defect that is the documented shape of a section with no entries. + value = block.get(name) + return value if isinstance(value, list) else [] + + +def label_of(entry): + # Labels carry a bracketed annotation BEFORE the colon: + # "Git Destructive [named+specifics ...]: Force pushing ..." + # Splitting at the first ':' truncates the label mid-annotation, so cut at + # the first '[' when one precedes the colon. + if not isinstance(entry, str): + return "" + colon = entry.find(":") + bracket = entry.find("[") + cut = colon if colon != -1 else len(entry) + if bracket != -1 and (colon == -1 or bracket < colon): + cut = bracket + return entry[:cut].strip() + + +def subject_of(entry): + return re.sub(r"[^a-z0-9]+", " ", label_of(entry).lower()).strip() + + +present = [s for s in SECTIONS if s in config] + +# --- C4: a customized section that discards the built-in list ---------------- +# +# "$defaults" is the token that keeps the shipped rule list in a section the +# operator has added to. Omitting it does not merge -- it REPLACES, so the +# built-in entries are gone. Naming how many are discarded is the finding: "you +# dropped 65 soft_deny rules" is actionable where "missing $defaults" is not. +for section in SECTIONS: + custom = entries(config, section) + if not custom: + continue + if any(isinstance(e, str) and e.strip() == "$defaults" for e in custom): + continue + built_in = entries(defaults, section) + if not built_in: + continue + # A section that still carries every built-in entry verbatim was never + # customized -- the CLI expands "$defaults" in its own output, so an + # expanded section and an omitted one look identical from here except for + # what is missing. Firing on the expanded case would report a discard that + # did not happen, on a section the operator never touched. + missing = [e for e in built_in if e not in custom] + if not missing: + continue + finding( + "error", + "C4-defaults", + section, + '"$defaults" is absent and %d of the %d built-in %s entries are gone with it: ' + "a customized section REPLACES the built-in list rather than adding to it. " + 'Add "$defaults" to keep them. First missing: %s' + % (len(missing), len(built_in), section, label_of(missing[0]) or "(unlabelled)"), + ) + +# --- C2b: the same subject allowed and denied -------------------------------- +# +# Compared by LABEL SUBJECT, not by body text: the bodies are prose an LLM +# reads, and no mechanical comparison of prose is defensible. A shared label +# across an allow and a deny section is a mechanical signal, and the finding +# says which two sections to reconcile rather than which one is right. +by_subject = {} +for section in ("allow", "soft_deny", "hard_deny"): + for entry in entries(config, section): + subject = subject_of(entry) + if not subject: + continue + by_subject.setdefault(subject, set()).add(section) + +for subject, sections in sorted(by_subject.items()): + if "allow" in sections and (sections & {"soft_deny", "hard_deny"}): + other = sorted(sections - {"allow"}) + finding( + "warning", + "C2b-contradiction", + "allow", + 'the subject "%s" appears in both allow and %s -- the classifier reads ' + "both, so decide which one governs rather than leaving it to resolve them" + % (subject, "/".join(other)), + ) + +# --- C3: an entry an earlier hard_deny already forecloses -------------------- +# +# hard_deny is the section that cannot be overridden, so an allow or soft_deny +# on the same subject can never fire. This is SEMANTIC shadowing inside the +# classifier block -- a different surface from the syntactic non-matching the +# permission-plane lint reports, with different inputs. +hard_subjects = {subject_of(e) for e in entries(config, "hard_deny") if subject_of(e)} +for section in ("allow", "soft_deny"): + for entry in entries(config, section): + subject = subject_of(entry) + if subject and subject in hard_subjects: + finding( + "warning", + "C3-shadowed", + section, + 'the subject "%s" is already in hard_deny, which cannot be overridden, ' + "so this entry can never change an outcome" % subject, + ) + +print("automode summary findings=%d sections=%d status=read" % (findings, len(present))) +PYEOF + +[[ "$critique" == 1 ]] || exit 0 + +# --- critique: surfaced, wrapped, never replaced ------------------------------ +# +# It owns the semantic judgment (clarity, completeness, conflicts, actionability) +# and this skill does not attempt it. What is added is honesty about the wrapper: +# measured across three consecutive runs on one unchanged config, the output was +# truncated mid-sentence twice and empty once, exiting 0 every time. +crit="$tmp/critique.txt" +if ! capture "$crit" "${AUTOMODE_CRITIQUE_FIXTURE:-}" critique; then + note "critique returned nothing; run it yourself. Measured on 2.1.225: three consecutive runs on one unchanged config produced 3032 bytes, 991 bytes, and no output at all, each exiting 0 -- so an empty result here means nothing about your rules." + exit 0 +fi + +echo "--- claude auto-mode critique ---" +cat "$crit" +echo "--- end critique ---" + +# Truncation cannot be detected from exit status, so it is detected from the +# text: a complete critique ends on sentence-final punctuation. +last_char="$(tr -d '[:space:]' <"$crit" | tail -c 1)" +case "$last_char" in +. | '!' | '?' | ')' | ']' | '`') ;; +*) + note "The critique above appears TRUNCATED -- it does not end on sentence-final punctuation, and truncation is not detectable from exit status (measured: 0 on every run, including an empty one). Treat it as partial and run 'claude auto-mode critique' yourself for the full text." + ;; +esac diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/automode-block-lint.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/automode-block-lint.test.sh new file mode 100755 index 0000000000..c779157922 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/automode-block-lint.test.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# Regression tests for automode-block-lint.sh (self-contained — ships with the plugin). +# +# Every case drives the shipped reader against a CHECKED-IN FIXTURE. No test +# invokes `claude auto-mode` and no test reads the operator's config: the +# invalid-JSON defect is machine-conditional, so a check run against the real CLI +# would pass or fail for reasons unrelated to this code. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/automode-block-lint.sh" +FIXTURES="$SCRIPT_DIR/../evals/fixtures" + +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 python3 >/dev/null 2>&1 && ! command -v python >/dev/null 2>&1; then + echo "SKIP: python3 not installed — this lane is optional by design" >&2 + exit 0 +fi + +CONFIG="$FIXTURES/automode-config-rawctl.json" +DEFAULTS="$FIXTURES/automode-defaults.json" + +run() { + # Always fixtured, never the real CLI. Cases needing different fixtures build + # their own `env` invocation rather than threading overrides through here. + env AUTOMODE_CONFIG_FIXTURE="$CONFIG" AUTOMODE_DEFAULTS_FIXTURE="$DEFAULTS" bash "$SCRIPT" +} + +# --- Criterion 8, defect 1: a raw control character inside a string value ----- +# The headline defensive-contract case. The shipped reader must return all four +# sections from a payload a strict parser rejects — and the assertion proves the +# fixture really is invalid rather than trusting that it was built correctly. +if command -v jq >/dev/null 2>&1; then + jq_rc=0 + jq -e . "$CONFIG" >/dev/null 2>&1 || jq_rc=$? + if [[ "$jq_rc" -ne 0 ]]; then + pass "the fixture really does carry a raw control character (jq rejects it)" + else + fail "the fixture really does carry a raw control character (jq rejects it)" \ + "jq parsed it cleanly, so this suite is not testing the measured defect" + fi +else + pass "strict-parser rejection (skipped — jq not installed)" +fi + +OUT=$(run) +assert_contains "the non-strict reader parses what jq cannot" "$OUT" "status=read" +assert_contains "all four sections are seen" "$OUT" "sections=4" + +# --- Criterion 8, defect 3: a label carrying a bracketed annotation ----------- +# "Git Destructive [named+specifics …]: …" splits at the first '[', not the +# first ':'. Splitting at the colon would carry the annotation into the label. +assert_contains "a bracketed label is cut at the bracket" "$OUT" "First missing: Git Destructive" +assert_not_contains "the annotation is not carried into the label" "$OUT" "named+specifics" + +# --- Criterion 4: a customized section discards the built-in list ------------- +assert_eq "exactly one section is reported as discarding its defaults" 1 "$(count_matching "$OUT" '\[C4-defaults\]')" +assert_contains "the finding names the section" "$OUT" "[C4-defaults] soft_deny" +assert_contains "and states how many entries are gone" "$OUT" "1 of the 2 built-in" +assert_contains "and states the mechanic rather than just the symptom" "$OUT" "REPLACES the built-in list" + +# A section that still carries every built-in entry was never customized: the +# CLI expands "$defaults" in its own output, so firing here would report a +# discard that did not happen. +assert_not_contains "an expanded-but-untouched section does not fire" "$OUT" "[C4-defaults] environment" +assert_not_contains "nor does one identical to the built-in list" "$OUT" "[C4-defaults] hard_deny" + +# A section carrying "$defaults" explicitly is never a finding. +assert_not_contains "a section carrying \$defaults does not fire" "$OUT" "[C4-defaults] allow" + +# --- Candidate 2: the same subject allowed and denied ------------------------- +assert_eq "the contradiction fires once" 1 "$(count_matching "$OUT" '\[C2b-contradiction\]')" +assert_contains "it names both sections to reconcile" "$OUT" "appears in both allow and hard_deny" + +# --- Candidate 3: an entry an earlier hard_deny forecloses -------------------- +assert_eq "the shadowed entry fires once" 1 "$(count_matching "$OUT" '\[C3-shadowed\]')" +assert_contains "it says why the entry can never fire" "$OUT" "cannot be overridden" + +# --- Criterion 8, defect 2: a MISSING key, not an empty array ---------------- +# `defaults --label ` omits a non-matching key entirely. A reader that +# expects an empty array raises instead of reporting "no entries". +MISSING_KEY="$TEST_TMPDIR/defaults-missing-key.json" +printf '{"allow": ["Only Section: one entry."]}\n' >"$MISSING_KEY" +OUT_MISSING=$(env AUTOMODE_CONFIG_FIXTURE="$CONFIG" AUTOMODE_DEFAULTS_FIXTURE="$MISSING_KEY" bash "$SCRIPT") +rc=$? +assert_exit "a defaults payload missing three keys does not crash the reader" 0 "$rc" +assert_contains "and the run still reports its status" "$OUT_MISSING" "status=read" +assert_not_contains "a missing key raises no defaults finding for that section" "$OUT_MISSING" "[C4-defaults] soft_deny" + +# The same tolerance on the config side. +EMPTY_CONFIG="$TEST_TMPDIR/config-empty.json" +printf '{}\n' >"$EMPTY_CONFIG" +OUT_EMPTY=$(env AUTOMODE_CONFIG_FIXTURE="$EMPTY_CONFIG" AUTOMODE_DEFAULTS_FIXTURE="$DEFAULTS" bash "$SCRIPT") +assert_contains "a config with no sections is read, not crashed on" "$OUT_EMPTY" "sections=0" +assert_contains "and reports no findings rather than failing" "$OUT_EMPTY" "findings=0" + +# --- Criterion 8, defect 4: exit status is never trusted ---------------------- +# A command that exits 0 having produced nothing must be reported as unavailable. +# This is the difference between "your block is clean" and "the block was never +# read", and only one of those is true. +EMPTY_OUT="$TEST_TMPDIR/empty.json" +: >"$EMPTY_OUT" +OUT_NOUSE=$(env AUTOMODE_CONFIG_FIXTURE="$EMPTY_OUT" AUTOMODE_DEFAULTS_FIXTURE="$DEFAULTS" bash "$SCRIPT") +rc=$? +assert_exit "an empty capture still exits 0" 0 "$rc" +assert_contains "an empty capture is reported unavailable, not clean" "$OUT_NOUSE" "status=unavailable" +assert_contains "and says explicitly that it is not a clean bill" "$OUT_NOUSE" "NOT a clean bill" +assert_eq "no findings are invented from an unread block" 0 "$(count_matching "$OUT_NOUSE" '^finding ')" + +# Unparseable even non-strictly is reported as unread rather than as empty. +GARBAGE="$TEST_TMPDIR/garbage.json" +printf 'this is not json at all\n' >"$GARBAGE" +OUT_GARBAGE=$(env AUTOMODE_CONFIG_FIXTURE="$GARBAGE" AUTOMODE_DEFAULTS_FIXTURE="$DEFAULTS" bash "$SCRIPT") +assert_contains "unparseable output is reported unparseable" "$OUT_GARBAGE" "status=unparseable" +assert_eq "and yields no findings" 0 "$(count_matching "$OUT_GARBAGE" '^finding ')" + +# --- The lane is optional: no python, no failure ------------------------------ +# With python unreachable the lane must skip visibly and exit 0, so one lane's +# prerequisite never becomes the whole skill's problem. +STUB="$TEST_TMPDIR/stub-path" +mkdir -p "$STUB" +real_bash="$(command -v bash)" +for tool in mktemp rm cat tr tail printf; do + src="$(command -v "$tool" 2>/dev/null)" || continue + printf '#!%s\nexec "%s" "$@"\n' "$real_bash" "$src" >"$STUB/$tool" + chmod +x "$STUB/$tool" +done +rc=0 +OUT_NOPY=$(env AUTOMODE_CONFIG_FIXTURE="$CONFIG" AUTOMODE_DEFAULTS_FIXTURE="$DEFAULTS" \ + PATH="$STUB" "$real_bash" "$SCRIPT" 2>&1) || rc=$? +assert_exit "a missing python does not fail the run" 0 "$rc" +assert_contains "the skip notice names the lane" "$OUT_NOPY" "autoMode block lane SKIPPED" +assert_contains "and says why a POSIX fallback is not possible" "$OUT_NOPY" "non-strict JSON parser" +assert_contains "and states the blast radius" "$OUT_NOPY" "Every other stage of this skill is unaffected" +assert_contains "the summary records the skip rather than reporting zero findings" "$OUT_NOPY" "status=skipped" + +# --- critique: surfaced, wrapped, never replaced ------------------------------ +TRUNCATED="$TEST_TMPDIR/critique-truncated.txt" +printf 'Your rules are mostly fine, but entry four is covered only when the resolving output is visible earlier' >"$TRUNCATED" +OUT_TRUNC=$(env AUTOMODE_CONFIG_FIXTURE="$CONFIG" AUTOMODE_DEFAULTS_FIXTURE="$DEFAULTS" \ + AUTOMODE_CRITIQUE_FIXTURE="$TRUNCATED" bash "$SCRIPT" --critique) +assert_contains "the critique text is surfaced verbatim" "$OUT_TRUNC" "entry four is covered only when" +assert_contains "a mid-sentence cut is called out as truncated" "$OUT_TRUNC" "appears TRUNCATED" +assert_contains "and says truncation is undetectable from exit status" "$OUT_TRUNC" "not detectable from exit status" + +COMPLETE="$TEST_TMPDIR/critique-complete.txt" +printf 'Your rules look consistent and every entry states a checkable condition.\n' >"$COMPLETE" +OUT_COMPLETE=$(env AUTOMODE_CONFIG_FIXTURE="$CONFIG" AUTOMODE_DEFAULTS_FIXTURE="$DEFAULTS" \ + AUTOMODE_CRITIQUE_FIXTURE="$COMPLETE" bash "$SCRIPT" --critique) +assert_not_contains "a complete critique is not called truncated" "$OUT_COMPLETE" "appears TRUNCATED" + +EMPTY_CRIT="$TEST_TMPDIR/critique-empty.txt" +: >"$EMPTY_CRIT" +OUT_NOCRIT=$(env AUTOMODE_CONFIG_FIXTURE="$CONFIG" AUTOMODE_DEFAULTS_FIXTURE="$DEFAULTS" \ + AUTOMODE_CRITIQUE_FIXTURE="$EMPTY_CRIT" bash "$SCRIPT" --critique) +assert_contains "an empty critique says so plainly" "$OUT_NOCRIT" "critique returned nothing; run it yourself" +assert_contains "and prices the claim with the measurement behind it" "$OUT_NOCRIT" "three consecutive runs" + +# Without the flag, critique is never run at all. +assert_not_contains "critique is not surfaced without the flag" "$OUT" "end critique" + +# --- Argument handling -------------------------------------------------------- +rc=0 +err=$(bash "$SCRIPT" --bogus &1) || rc=$? +assert_exit "exit 2 on an unknown argument" 2 "$rc" +assert_contains "the unknown argument is named" "$err" "unknown argument" + +rc=0 +help=$(bash "$SCRIPT" --help &1) || rc=$? +assert_exit "--help exits 0" 0 "$rc" +assert_contains "--help states the optional-runtime contract" "$help" "exits 0 so the rest of the skill still runs" +assert_contains "--help states that reset is never run" "$help" "it never +runs 'claude auto-mode reset'" + +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 510234aa7d6ba5842a3d8edba0ab888460bfdf3e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:35:24 -0400 Subject: [PATCH 14/32] feat(claude-config): report which managed intents are enforced and which are not An administrator deploys managed policy believing it is policy. Some of it is, some of it is not, and nothing in the product surfaces which. A managed `permissions.deny` is the strongest thing an administrator can write: no level, command line included, can override it, and a tool denied at any level cannot be allowed at another. A managed `autoMode` section is not that at all -- it is additive. A developer cannot remove entries it provides, but a developer-added allow can override an organization soft_deny, because permissions, hooks, MCP, sandbox-filesystem and sandbox-network each got an exclusivity lock and auto mode did not. The finding quotes that wording and names the page's own remedy rather than inventing one. That claim was supposed to ship caveated, on the grounds that it came from a research slice nobody had independently verified. It no longer needs the caveat: the governing page states it affirmatively, and Phase 0 recorded the wording. The caveat is discharged rather than narrowed, and nothing here rests on the slice. A second loosenable class fell out of the merge mechanics and was not in the plan. "Managed settings are highest" and "deny before ask before allow, from any scope" are both true, so a lower-scope deny beats a managed allow WITHOUT overriding it. An administrator reading only the precedence table would not expect that, which makes it precisely this report's remit. The report prescribes nothing and ships no security floor of its own. That property is asserted positively -- the suite walks every rule the report printed and fails if any string is absent from the input -- rather than by checking that some recommendation marker never appears, which would pass unconditionally. Completeness bounds every claim. Server-managed settings have no local path, and a managed surface that could not be read gets a note saying it is not evidence that no policy is deployed there. "No local policy" is a stated status, not an empty report: an administrator reading silence as "nothing to worry about" is the exact failure this exists to prevent. 28 checks, shellcheck clean, portability gate clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/topics/permission-model/PLAN.md | 29 ++- plugins/claude-config/CHANGELOG.md | 14 +- .../skills/audit-permission-state/SKILL.md | 30 +++ .../reference/criteria.md | 31 ++- .../scripts/managed-conformance.sh | 196 +++++++++++++++ .../scripts/managed-conformance.test.sh | 225 ++++++++++++++++++ 6 files changed, 521 insertions(+), 4 deletions(-) create mode 100755 plugins/claude-config/skills/audit-permission-state/scripts/managed-conformance.sh create mode 100755 plugins/claude-config/skills/audit-permission-state/scripts/managed-conformance.test.sh diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index c7f5f10fa3..837d8bacfe 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -690,7 +690,34 @@ Acceptance criteria 4 and 8, plus brainstorm candidates 1, 2, 3. - 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] +### Phase 6: Managed-policy conformance report [DONE] + +**Completed 2026-08-12.** `managed-conformance.test.sh` 28/28, `shellcheck -x` clean, portability +gate clean. The phase's sanity check holds on its own fixture: the `permissions.deny` rule is reported +enforced and the `autoMode` rule loosenable. + +**The caveat this phase was told to carry has been discharged, not merely narrowed.** The plan +required the "no `allowManagedAutoModeRulesOnly`" claim to ship caveated because it originated in an +unverified research slice. Phase 0 re-confirmed it from the governing page — and affirmatively, not +just by absence: the page states the additive combination and names `permissions.deny` in managed +settings as the thing that cannot be overridden. The finding now quotes that wording and names the +page's own remedy, so nothing here rests on the slice. + +**Lane neutrality is asserted as a positive property**, exactly as the phase requires: the suite walks +every rule the report printed and fails if any string is absent from the input. The plan explicitly +warned against `grep -c 'RECOMMEND ADD' = 0`, which passes unconditionally. + +**Deviations recorded rather than silent:** + +- **A second loosenable class emerged from the merge mechanics and is reported.** "Managed is highest" + and "deny before ask before allow, from any scope" are both true, so a **lower-scope deny beats a + managed allow without overriding it**. An administrator reading only the precedence table would not + expect that, and it is exactly the report's remit. Not in the phase as written. +- **`disableAutoMode` at managed scope is classified here too**, because it is the one auto-mode lever + that IS a lock — and only when it carries the documented string. A mistyped one is reported + loosenable rather than enforced. +- **No local policy is a stated status (`status=no-local-policy`), not an empty report.** Silence + would read as "nothing to worry about" on a machine whose policy simply could not be reached. Brainstorm candidate 6 — the highest-value residue of the ecosystem sweep. diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index ec2297d9a3..e9808dfc6d 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -86,7 +86,19 @@ All notable changes to the `claude-config` plugin are documented here. Format fo values that `jq` rejects outright and no line-oriented filter can repair; absent it, the lane prints a visible skip notice and exits 0 while every other stage still runs. A capture that produced nothing is reported as unavailable with an explicit "this is NOT a clean bill" — exit status is - never consulted, since it is 0 even when nothing came back. Every run states the two bounds on the claim: the command-line scope + never consulted, since it is 0 even when nothing came back. + A sixth lane reports which managed intents are actually enforced and which a developer can loosen. + A managed `permissions.deny` is the strongest thing an administrator can write and is reported + enforced; a managed `autoMode` section is **additive, not a policy boundary** — a developer cannot + remove entries it provides, but a developer-added `allow` can override an organization `soft_deny`, + because permissions, hooks, MCP, sandbox-filesystem and sandbox-network each have an exclusivity + lock and auto mode has none. The report also surfaces an interaction the precedence table alone does + not suggest: managed settings are the highest scope, but evaluation order applies from any scope, so + a lower-scope deny beats a managed allow without ever overriding it. It prescribes nothing — every + rule string it prints came from a file it read, and it ships no security floor of its own. Every run + bounds its own completeness: server-managed settings have no local path, and a managed surface that + could not be read is reported as such rather than left silent, since an administrator reading + silence as "no policy deployed" is the failure the report exists to prevent. 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 95ee272ac5..c8ebeaeb13 100644 --- a/plugins/claude-config/skills/audit-permission-state/SKILL.md +++ b/plugins/claude-config/skills/audit-permission-state/SKILL.md @@ -194,6 +194,36 @@ other stage still runs. `status=unavailable` with an explicit "this is NOT a clean bill". The distinction between "your block is clean" and "the block was never read" is the whole point. +## Phase 6: What managed policy actually enforces + +An administrator deploys managed policy believing it is policy. Some of it is; some is not, and +nothing surfaces which: + +```shell +bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-state/scripts/permission-state.sh" | + bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-state/scripts/managed-conformance.sh" +``` + +- **`managed enforced deny `** — the strongest thing an administrator can write. No level, + command line included, can override a managed permission rule, and a tool denied at any level + cannot be allowed at another. +- **`managed loosenable rule …`** — the interaction that surprises people. "Managed is highest" and + "deny before ask before allow, **from any scope**" are both true: a lower-scope deny beats a managed + allow without ever overriding it. +- **`managed loosenable autoMode`** — a managed `autoMode` section is **additive, not a policy + boundary**. A developer cannot remove entries it provides, but a developer-added `allow` can + override an organization `soft_deny`. Permissions, hooks, MCP, sandbox-filesystem and + sandbox-network each got an exclusivity lock; auto mode did not. +- **`managed loosenable lockout`** — `disableAutoMode` set to anything but the string `"disable"`. + +**This report never prescribes.** It says what the consumer's own policy does and does not achieve; +every rule string it prints came from a file it read. It ships no security floor of its own. + +**Completeness is bounded on every run.** Server-managed settings are delivered at sign-in and have no +local path, so "managed" means the local surfaces only; a surface that could not be read gets its own +note saying so, because an administrator reading silence as "no policy deployed" is the failure this +report exists to prevent. + ## Reading the output honestly 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 bb6b4d573b..31e93a2284 100644 --- a/plugins/claude-config/skills/audit-permission-state/reference/criteria.md +++ b/plugins/claude-config/skills/audit-permission-state/reference/criteria.md @@ -228,5 +228,32 @@ one feature. 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. +everywhere. + +### The conformance report + +Two claims are both true and their interaction is what an administrator does not expect: managed +settings are the highest **scope**, and evaluation order (deny, then ask, then allow) applies **from +any scope**. So a lower-scope deny changes the outcome of a managed allow without overriding it. + +| Verdict | What it rests on | +| --- | --- | +| `enforced deny` | "If a tool is denied at any level, no other level can allow it." The strongest thing an administrator can write | +| `enforced allow` / `enforced ask` | the managed rule is highest and nothing beneath it outranks its kind | +| `loosenable rule` | a lower scope carries an earlier-evaluated kind for the same rule text | +| `loosenable autoMode` | "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." Permissions, hooks, MCP, sandbox-filesystem and sandbox-network each have an exclusivity lock; auto mode has none | +| `enforced` / `loosenable lockout` | `disableAutoMode` is a real lock only when it carries the documented string `"disable"` | + +The remedy the `autoMode` finding names is the page's own: "For actions that must never run regardless +of user intent or classifier configuration, use `permissions.deny` in managed settings, which… can't +be overridden." + +**The report prescribes nothing.** It says what the consumer's policy does and does not achieve, and +every rule string it prints came from a file it read — a property the suite asserts positively rather +than by checking that some recommendation marker is absent. It ships no security floor of its own, +which keeps it neutral by construction rather than by restraint. + +**Completeness bounds every claim.** Server-managed settings have no local path, so `managed` means +the local surfaces; a `skipped` or `unreadable` surface gets its own note stating that it is not +evidence no policy is deployed there. An administrator reading silence as "no policy" is the failure +this report exists to prevent. diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/managed-conformance.sh b/plugins/claude-config/skills/audit-permission-state/scripts/managed-conformance.sh new file mode 100755 index 0000000000..2914ae30b6 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/managed-conformance.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# managed-conformance.sh — which managed intents are actually enforced, and +# which a developer can loosen. +# +# An administrator deploys managed policy believing it is policy. Some of it is: +# "no other level, including command line arguments, can override a managed +# permission rule", and "if a tool is denied at any level, no other level can +# allow it". Some of it is not: a managed `autoMode` section is additive, so a +# developer can extend it with a personal `allow` entry that overrides an +# organization `soft_deny` -- "the combination is additive, not a hard policy +# boundary". Nothing surfaces which is which. +# +# This reports that split. It reports ONLY what the consumer's own policy does +# and does not achieve; it never prescribes what a policy should contain. That +# keeps it neutral by construction rather than by restraint -- every rule string +# it prints comes from a file it read. +# +# Input: permission-state.sh records on stdin (surface + rule + conf records). +# With no piped input the reader is run directly and its status propagated. +# +# Output: +# MANAGED-NOTE: +# managed enforced cannot be overridden from below +# managed loosenable a lower scope can weaken this +# managed conformance summary enforced= loosenable= status= +# +# Prerequisites: POSIX text tools only. +# +# Usage: +# permission-state.sh | managed-conformance.sh +# managed-conformance.sh [--help] + +set -uo pipefail + +usage() { + cat <<'EOF' +managed-conformance.sh — report which managed intents are enforced vs loosenable. + +Usage: permission-state.sh | managed-conformance.sh + managed-conformance.sh [--help] + +Emits "managed enforced ", "managed loosenable ", +and a summary. Every rule string printed comes from a file that was read -- this +report never prescribes rules a policy should contain. + +Reads only. 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. +EOF +} + +case "${1:-}" in +-h | --help) + usage + exit 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 report on" >&2 + exit 2 + fi + records="$(bash "$STATE_SCRIPT")" || exit $? +else + records="$(cat)" +fi + +report="$(printf '%s\n' "$records" | awk ' +function text_of(start, i, s) { + s = $start + for (i = start + 1; i <= NF; i++) s = s " " $i + return s +} + +$1 == "rule" { + if ($2 == "managed") { + key = $4 SUBSEP text_of(5) + if (!(key in managed_seen)) { + managed_seen[key] = 1 + managed_order[++n_managed] = key + } + } else { + lower[$4 SUBSEP text_of(5)] = ($4 SUBSEP text_of(5)) in lower ? lower[$4 SUBSEP text_of(5)] "," $2 : $2 + } + next +} +$1 == "conf" { + conf[$2 SUBSEP $4] = text_of(5) + next +} +$1 == "NOTE:" { next } +NF >= 3 { + n_surfaces++ + if ($1 == "managed") { + n_managed_surfaces++ + status = $3 + if (status == "present") n_managed_present++ + else if (status == "skipped" || status == "unreadable") unread[++n_unread] = $2 " (" status ")" + } + next +} + +END { + if (n_surfaces == 0) exit 2 + + # A managed report that cannot say whether it read the managed scope is worse + # than no report: an administrator would read silence as "no policy deployed". + for (i = 1; i <= n_unread; i++) + print "MANAGED-NOTE: the managed surface " unread[i] " was NOT read, so this report is incomplete by that surface — it is not evidence that no policy is deployed there." + + print "MANAGED-NOTE: server-managed settings are delivered remotely at sign-in and have no local path, so no local reader can see them. \"Managed\" here means the LOCAL managed surfaces only; an intent enforced remotely will not appear below." + + if (n_managed_present == 0) { + print "MANAGED-NOTE: no local managed policy surface was readable with content, so there is nothing to report conformance against." + print "managed conformance summary enforced=0 loosenable=0 status=no-local-policy" + exit 0 + } + + # --- Enforced: what no lower scope can undo -------------------------------- + # + # "no other level, including command line arguments, can override a managed + # permission rule", and separately "if a tool is denied at any level, no other + # level can allow it". A managed deny is therefore the strongest thing an + # administrator can write, and it is the only category this report calls + # enforced without qualification. + for (i = 1; i <= n_managed; i++) { + split(managed_order[i], f, SUBSEP) + kind = f[1]; text = f[2] + if (kind == "deny") { + print "managed enforced deny " text + n_enforced++ + } else { + # ask and allow at managed scope still cannot be OVERRIDDEN, but they can + # be OUTRANKED: deny is evaluated before ask and ask before allow, from any + # scope. So a lower-scope deny changes the outcome without overriding the + # managed rule at all -- a distinction an administrator reading "managed is + # highest" would not expect. + beaten = "" + if (kind == "allow") { + if ((("deny") SUBSEP text) in lower) beaten = "deny" + else if ((("ask") SUBSEP text) in lower) beaten = "ask" + } else if (kind == "ask") { + if ((("deny") SUBSEP text) in lower) beaten = "deny" + } + if (beaten != "") { + print "managed loosenable rule the managed " kind " rule " text " is outranked by a " beaten " rule in scope(s) " lower[beaten SUBSEP text] " — evaluation order (deny, then ask, then allow) applies from any scope, so a lower scope changes the outcome without overriding the managed rule" + n_loosenable++ + } else { + print "managed enforced " kind " " text + n_enforced++ + } + } + } + + # --- Loosenable: the managed autoMode block -------------------------------- + # + # permissions, hooks, MCP, sandbox-filesystem and sandbox-network each have an + # exclusivity lock; auto mode has none. "A developer can extend environment, + # allow, soft_deny, and hard_deny with personal entries but cannot 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." + if ((("managed") SUBSEP "autoModePresent") in conf) { + print "managed loosenable autoMode a managed autoMode section is ADDITIVE, not a policy boundary: a developer cannot remove entries it provides, but a developer-added allow entry can override an organization soft_deny entry. For an action that must never run regardless of classifier configuration, use permissions.deny in managed settings, which cannot be overridden" + n_loosenable++ + } + + # disableAutoMode is the one auto-mode-adjacent lever that IS a lock -- and + # only when it carries the documented string. + k = ("managed") SUBSEP "disableAutoMode" + k2 = ("managed") SUBSEP "permissions.disableAutoMode" + for (kk in conf) { + split(kk, kf, SUBSEP) + if (kf[1] != "managed") continue + if (kf[2] != "disableAutoMode" && kf[2] != "permissions.disableAutoMode") continue + if (conf[kk] == "\"disable\"") + print "managed enforced lockout " kf[2] " is set to \"disable\" in managed settings, which prevents auto mode from being used at all" + else + print "managed loosenable lockout " kf[2] " is " conf[kk] " in managed settings, but the documented value is the STRING \"disable\" — any other value is accepted and silently does nothing, so auto mode is NOT disabled" + if (conf[kk] == "\"disable\"") n_enforced++; else n_loosenable++ + } + + print "managed conformance summary enforced=" n_enforced + 0 " loosenable=" n_loosenable + 0 " status=read" +} +')" || { + echo "ERROR: no scope records on input — managed-conformance.sh will not report on a managed policy it never read" >&2 + exit 2 +} + +printf '%s\n' "$report" diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/managed-conformance.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/managed-conformance.test.sh new file mode 100755 index 0000000000..95e5d98156 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/managed-conformance.test.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# Regression tests for managed-conformance.sh (self-contained — ships with the plugin). +# +# Every case feeds hand-written reader records on stdin. No test reads the +# operator's real ~/.claude, and none touches the machine's real managed policy +# — deploying policy to a developer's machine to test a report about policy +# would change every session's permission behavior on that machine. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/managed-conformance.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"; } + +report() { printf '%s\n' "$1" | bash "$SCRIPT"; } + +# --- The phase's own sanity check: one deny, one autoMode rule --------------- +# The deny is enforced; the autoMode section is loosenable. Getting this pair +# backwards is the failure an administrator would act on. +FIXTURE=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +user settings present /fx/home/.claude/settings.json +project settings present /proj/.claude/settings.json +conf managed file autoModePresent true +rule managed file deny Read(./.env) +rule user settings allow Bash(npm test) +EOF +) +OUT=$(report "$FIXTURE") +assert_contains "a managed deny is reported enforced" "$OUT" "managed enforced deny Read(./.env)" +assert_contains "a managed autoMode section is reported loosenable" "$OUT" "managed loosenable autoMode" +assert_contains "and the loosenable finding names the remedy" "$OUT" "use permissions.deny in managed settings" +assert_contains "the summary counts both sides" "$OUT" "enforced=1 loosenable=1" + +# --- Lane neutrality, asserted as a POSITIVE property ------------------------- +# Every rule string in the report must appear in the input. Asserting the +# absence of some marker string would pass unconditionally and prove nothing. +NEUTRAL=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +user settings present /fx/home/.claude/settings.json +rule managed file deny Read(./.env) +rule managed file deny WebFetch +rule managed file allow Bash(git status) +rule user settings allow Bash(npm test) +EOF +) +OUT=$(report "$NEUTRAL") +leaked=0 +while IFS= read -r line; do + case "$line" in + "managed enforced deny "*) + rule="${line#managed enforced deny }" + printf '%s\n' "$NEUTRAL" | grep -qF -- "$rule" || { + leaked=1 + printf ' leaked rule not present in input: %s\n' "$rule" >&2 + } + ;; + *) ;; + esac +done < <(printf '%s\n' "$OUT") +assert_eq "every rule the report prints came from a file it read" 0 "$leaked" +assert_not_contains "the report never prescribes a rule to add" "$OUT" "should contain" +assert_not_contains "nor recommends one" "$OUT" "recommend" + +# --- Managed is highest, but it is not evaluated first ------------------------ +# "Managed settings highest" and "deny before ask before allow, from any scope" +# are both true, and their interaction is the thing an administrator would not +# expect: a lower-scope deny beats a managed allow without overriding it. +OUTRANKED=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +user settings present /fx/home/.claude/settings.json +rule managed file allow Bash(npm test) +rule user settings deny Bash(npm test) +EOF +) +OUT=$(report "$OUTRANKED") +assert_contains "a managed allow beaten by a lower deny is loosenable" "$OUT" "managed loosenable rule the managed allow rule Bash(npm test)" +assert_contains "the finding names the scope that beat it" "$OUT" "in scope(s) user" +assert_contains "and cites the mechanic rather than asserting it" "$OUT" "evaluation order (deny, then ask, then allow)" +assert_not_contains "it is not also reported enforced" "$OUT" "managed enforced allow Bash(npm test)" + +# A managed ask beaten by a lower deny is the same shape. +ASK_BEATEN=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +project settings present /proj/.claude/settings.json +rule managed file ask WebFetch +rule project settings deny WebFetch +EOF +) +OUT=$(report "$ASK_BEATEN") +assert_contains "a managed ask beaten by a lower deny is loosenable" "$OUT" "managed loosenable rule the managed ask rule WebFetch" + +# An unbeaten managed allow IS enforced — nothing below it changes the outcome. +UNBEATEN=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +user settings present /fx/home/.claude/settings.json +rule managed file allow Bash(git status) +rule user settings allow Bash(npm test) +EOF +) +OUT=$(report "$UNBEATEN") +assert_contains "an unbeaten managed allow is enforced" "$OUT" "managed enforced allow Bash(git status)" + +# A managed deny is never loosenable, whatever a lower scope says. +DENY_CHALLENGED=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +user settings present /fx/home/.claude/settings.json +rule managed file deny Read(./.env) +rule user settings allow Read(./.env) +EOF +) +OUT=$(report "$DENY_CHALLENGED") +assert_contains "a managed deny stays enforced against a lower allow" "$OUT" "managed enforced deny Read(./.env)" +assert_eq "and nothing about it is called loosenable" 0 "$(count_matching "$OUT" 'loosenable rule.*Read')" + +# --- The lock-out switch, at both key paths ---------------------------------- +LOCKED=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +conf managed file disableAutoMode "disable" +EOF +) +OUT=$(report "$LOCKED") +assert_contains "a correctly-typed lock-out is enforced" "$OUT" "managed enforced lockout disableAutoMode" + +MISTYPED=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +conf managed file permissions.disableAutoMode true +EOF +) +OUT=$(report "$MISTYPED") +assert_contains "a mistyped lock-out is loosenable, not enforced" "$OUT" "managed loosenable lockout permissions.disableAutoMode" +assert_contains "and says plainly that auto mode is not disabled" "$OUT" "auto mode is NOT disabled" + +# --- Completeness bounds every claim ----------------------------------------- +# An administrator reading silence as "no policy deployed" is the failure this +# report exists to prevent, so it never stays quiet about what it could not read. +UNREAD=$( + cat <<'EOF' +managed file present /policy/managed-settings.json +managed registry skipped - +managed plist unreadable com.anthropic.claudecode +rule managed file deny Read(./.env) +EOF +) +OUT=$(report "$UNREAD") +assert_eq "one note per surface that could not be read" 2 "$(count_matching "$OUT" 'MANAGED-NOTE: the managed surface')" +assert_contains "an unread surface is not evidence of absence" "$OUT" "not evidence that no policy is deployed" +assert_contains "server-managed settings are disclosed on every run" "$OUT" "no local path" + +# No local policy at all is stated, not implied by an empty report. +NO_POLICY=$( + cat <<'EOF' +managed file absent /policy/managed-settings.json +managed dropin-dir absent /policy/managed-settings.d +managed registry not-applicable - +managed plist not-applicable - +user settings present /fx/home/.claude/settings.json +rule user settings allow Bash(npm test) +EOF +) +OUT=$(report "$NO_POLICY") +assert_contains "no local policy is stated explicitly" "$OUT" "status=no-local-policy" +assert_eq "and no conformance claim is made" 0 "$(count_matching "$OUT" '^managed enforced')" + +# --- Fail-loud ---------------------------------------------------------------- +rc=0 +err=$(printf '' | bash "$SCRIPT" 2>&1) || rc=$? +assert_exit "exit 2 on empty input" 2 "$rc" +assert_contains "the error refuses to report on an unread policy" "$err" "never read" + +rc=0 +err=$(bash "$SCRIPT" --bogus &1) || rc=$? +assert_exit "exit 2 on an unknown argument" 2 "$rc" + +rc=0 +help=$(bash "$SCRIPT" --help &1) || rc=$? +assert_exit "--help exits 0" 0 "$rc" +assert_contains "--help states the read-only construction" "$help" "read-only by construction" + +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 ea3cec6f1aa94af618b71061bd5d4a3a6906816d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:52:27 -0400 Subject: [PATCH 15/32] feat(claude-config): add draft-auto-mode-rules, the authoring counterpart The autoMode block is a natural-language prompt for a classifier, not a rule list the harness matches, so it fails in ways a config file does not. This skill interviews for one entry at a time and prints a paste-ready block. The entry shape is not invented here. `claude auto-mode critique`, run against a real 66 KB hand-authored block, found the classifier is "an LLM doing a single pass under a 'default is ALLOW' instruction", so "buried conditions in paragraph position 40 will be missed at a materially higher rate than conditions in a bullet list", and recommended exactly this: a label, bulleted COVERED / NOT COVERED, one line of rationale, provenance stripped. The finding is acted on at authoring time rather than reported after the fact. The interview's fourth question is the load-bearing one. The same critique named uncheckable conditions the biggest weakness -- entries state preconditions the classifier cannot evaluate from command text, so it either allows blindly (the condition is decorative) or blocks (the grant is inoperable), with no stated disposition. The skill pushes back and asks for the observable form: "only when the deploy is approved" is not checkable; "only when the command names the staging endpoint" is. Every emitted section opens with "$defaults", by construction. Customizing a section REPLACES the built-in rule list rather than adding to it, so a drafter that omitted the token would bake in the exact defect the audit sibling reports. jq composes the JSON rather than shell string-building, because hand-rolled escaping is how a draft would end up shipping the raw-control-character defect this plugin reports elsewhere. A case feeds quotes, backslashes and a tab and asserts the output is strict-valid. It writes nothing, in any scope, under any flag. That contract is asserted by RUNNING the script and diffing the filesystem, including a fixture HOME whose settings file is checked byte-identical afterwards. Grepping the source for redirect syntax was tried and rejected: the script is mostly an embedded jq program where `>` is a comparison operator, so a text scan cannot tell a redirect from a comparison. Measuring the effect is sound where scanning is not. The history input the earlier design called for stays dropped: it named no actual location, and an unnamed read surface in a shipped skill is unreviewable. SKILL.md says so, so it is not silently reintroduced. 33 checks, shellcheck clean, check-skill PASS, catalog and cheat sheet regenerated for the ninth 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 | 35 ++- .../claude-config/.claude-plugin/plugin.json | 2 +- plugins/claude-config/CHANGELOG.md | 13 + .../skills/draft-auto-mode-rules/SKILL.md | 129 ++++++++++ .../draft-auto-mode-rules/evals/evals.json | 64 +++++ .../scripts/draft-automode-block.sh | 143 +++++++++++ .../scripts/draft-automode-block.test.sh | 237 ++++++++++++++++++ 9 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 plugins/claude-config/skills/draft-auto-mode-rules/SKILL.md create mode 100644 plugins/claude-config/skills/draft-auto-mode-rules/evals/evals.json create mode 100755 plugins/claude-config/skills/draft-auto-mode-rules/scripts/draft-automode-block.sh create mode 100755 plugins/claude-config/skills/draft-auto-mode-rules/scripts/draft-automode-block.test.sh diff --git a/docs/CATALOG.md b/docs/CATALOG.md index 27066d58e8..46a405d46a 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -74,7 +74,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) — 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-config`](../plugins/claude-config) — Nine 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 (the permission rules actually in effect — every settings scope merged with per-rule provenance, what auto mode drops on entry, config written where nothing reads it, and which managed intents are enforced versus loosenable), draft-auto-mode-rules (interview and draft a paste-ready autoMode classifier block; prints only, never writes), 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. Eight skills: audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json — full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), 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 db3d7b8d45..d03da95ac8 100644 --- a/docs/SKILL-CHEAT-SHEET.md +++ b/docs/SKILL-CHEAT-SHEET.md @@ -143,6 +143,7 @@ owned by [docs/CATALOG-TAXONOMY.md](CATALOG-TAXONOMY.md). | [`/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 the permission rules actually in effect and what auto mode drops | | [`/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:draft-auto-mode-rules`](../plugins/claude-config/skills/draft-auto-mode-rules/SKILL.md) | `claude-config` | Interview and draft a paste-ready autoMode block, never writing settings | | [`/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 | | [`/claude-memory:stateless`](../plugins/claude-memory/skills/stateless/SKILL.md) | `claude-memory` | Inspect, disable, or purge Claude Code's per-repo auto memory | diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index 837d8bacfe..58c062b482 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -748,7 +748,40 @@ appears in the fixture policy or in a scope file beneath it (set difference is e `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] +### Phase 7: Authoring lane — `claude-config:draft-auto-mode-rules` [DONE] + +**Completed 2026-08-12.** `draft-automode-block.test.sh` 33/33, `shellcheck -x` clean. The phase's +sanity check holds: the drafter's stdout passes `jq -e .` — **strict**, correctly, because we author +this output and the non-strict allowance exists only for the CLI's malformed emission. No Python +dependency reaches this phase. + +**The entry template is `critique`'s own recommendation, applied at authoring time.** Run against a +real 66 KB hand-authored block, `claude auto-mode critique` found that the classifier is "an LLM doing +a single pass under a 'default is ALLOW' instruction", so "buried conditions in paragraph position 40 +will be missed at a materially higher rate than conditions in a bullet list", and recommended bulleted +COVERED / NOT COVERED plus a one-line rationale with provenance stripped. That is the shape the +drafter emits — the finding is acted on rather than restated. + +**The interview's fourth question is the one that matters.** The same critique named uncheckable +conditions as the biggest weakness: entries state preconditions the classifier cannot evaluate from +command text, so it either allows blindly or blocks, with no stated disposition. `SKILL.md` requires +pushing back and asking for the observable form, and an eval covers it. + +**Deviations recorded rather than silent:** + +- **Every emitted section opens with `"$defaults"`, by construction rather than by instruction.** A + customized section replaces the built-in list, so a drafter that omitted the token would bake in the + exact defect the audit sibling reports. Asserted per section in the suite. +- **jq composes the JSON, not shell string-building.** Hand-rolled escaping is how a draft would ship + the raw-control-character defect this plugin reports elsewhere; a case feeds quotes, backslashes and + a tab and asserts strict-valid output. +- **The no-write contract is asserted by RUNNING the script and diffing the filesystem**, including a + fixture `HOME` whose settings file is checked byte-identical afterwards. A grep for redirect syntax + was tried first and rejected: the script is mostly an embedded jq program where `>` is a comparison + operator, so a text scan cannot tell a redirect from a comparison. Measuring the effect is sound + where scanning the source is not. +- **`plugin.json` moved from eight skills to nine**, and the catalog and cheat sheet were regenerated + in the same change. The version bump stays the single one already taken. 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 diff --git a/plugins/claude-config/.claude-plugin/plugin.json b/plugins/claude-config/.claude-plugin/plugin.json index 8181340ae9..12abc05b0e 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.32.0", - "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).", + "description": "Nine 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 (the permission rules actually in effect — every settings scope merged with per-rule provenance, what auto mode drops on entry, config written where nothing reads it, and which managed intents are enforced versus loosenable), draft-auto-mode-rules (interview and draft a paste-ready autoMode classifier block; prints only, never writes), 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 e9808dfc6d..8237750db2 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -102,6 +102,19 @@ All notable changes to the `claude-config` plugin are documented here. Format fo (`--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. +- **`draft-auto-mode-rules`** — a second new skill, the authoring counterpart. It interviews you about + what should and should not be auto-approved, then prints a paste-ready `autoMode` block to stdout. + The entry shape is `claude auto-mode critique`'s own recommendation applied at authoring time rather + than reported afterwards: run against a real 66 KB hand-authored block, it found the classifier is + "an LLM doing a single pass under a 'default is ALLOW' instruction", so conditions buried in a + paragraph are missed at a materially higher rate than conditions in a bullet list. Entries are + therefore a label, bulleted COVERED / NOT COVERED, and one line of rationale. The interview pushes + back on conditions the classifier cannot evaluate from the command text — the same critique named + those the biggest weakness, since the classifier either allows blindly or blocks entirely with no + stated disposition. Every emitted section opens with `"$defaults"`, because customizing a section + replaces the built-in rule list rather than adding to it. It **writes nothing, in any scope, under + any flag** — editing a consumer's settings file would be making a permission decision on their + behalf, which is the one thing this plugin exists not to do. - **`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/draft-auto-mode-rules/SKILL.md b/plugins/claude-config/skills/draft-auto-mode-rules/SKILL.md new file mode 100644 index 0000000000..40a6d9a98d --- /dev/null +++ b/plugins/claude-config/skills/draft-auto-mode-rules/SKILL.md @@ -0,0 +1,129 @@ +--- +description: "Draft an `autoMode` classifier block for Claude Code by interviewing you about what should and should not be auto-approved, then printing a paste-ready JSON block to stdout. Entries follow the shape the classifier actually reads well — a label, bulleted COVERED / NOT COVERED, one line of rationale — and every section keeps `\"$defaults\"` so customizing does not discard the built-in rules. Use when: 'help me write auto mode rules', 'draft an autoMode block', 'set up auto mode', 'add an auto-mode rule for X', 'my auto mode rules are too vague', 'rewrite this classifier entry', or after an audit shows rules being dropped or ignored. Prints only — never writes any settings file." +argument-hint: "[section] — environment | allow | soft_deny | hard_deny to focus the interview" +user-invocable: true +disable-model-invocation: false +metadata: + workflow-stage: anytime + summary: Interview and draft a paste-ready autoMode block, never writing settings +--- + +## Purpose + +The `autoMode` block is a natural-language prompt for a classifier, not a rule list the harness +matches — so it fails in ways a config file does not. `claude auto-mode critique`, run against a real +66 KB hand-authored block, found the pattern: the classifier is "an LLM doing a single pass under a +'default is ALLOW' instruction", so "buried conditions in paragraph position 40 will be missed at a +materially higher rate than conditions in a bullet list." + +This skill applies that finding at authoring time instead of reporting it afterwards. + +## Report-only, permanently + +**This skill writes nothing, in any scope, under any flag.** It prints a block; you paste it. Editing +a consumer's settings file would be making a permission decision on their behalf, which is the one +thing this plugin exists not to do. + +## Scope boundary (route out) + +- What is in effect now, what auto mode drops, what a section discards → + `claude-config:audit-permission-state`. **Run it first** — drafting against rules you have not read + is how a block ends up contradicting itself. +- Grant portability and auto-mode durability → `claude-config:audit-permission-grants`. +- Whether an existing block is any good → `claude auto-mode critique`, surfaced by + `audit-permission-state --critique`. It owns the semantic judgment; this skill owns composition. + +## Inputs, and one deliberate omission + +The draft is built from **the interview plus the effective merge** (`audit-permission-state`), and +nothing else. + +An earlier design read "the repo's observed prompt and denial history". That was dropped: it named no +actual location, and an unnamed read surface in a skill shipped to consumers is unreviewable. Do not +reintroduce a history input without re-opening that decision. + +## Phase 1: Read what already exists + +Before asking anything, run the audit sibling and read its output: + +```shell +bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-state/scripts/automode-block-lint.sh" +``` + +Three things there change what you should ask: + +- A section missing `"$defaults"` means built-in rules are **already** discarded. Say so before adding + to that section. +- A `C2b-contradiction` or `C3-shadowed` finding means the block already disagrees with itself. + Resolving that comes before adding an entry that would deepen it. +- A `status=skipped` or `status=unavailable` result means you could **not** read the block. Draft + anyway if asked, but say plainly that you are drafting blind. + +## Phase 2: Interview + + + +Ask about one entry at a time. For each, you need four things, and the fourth is where drafts fail: + +1. **Which section.** `allow` (auto-approve), `soft_deny` (block, overridable), `hard_deny` (never), + `environment` (facts the classifier needs — organization, repositories, trusted domains). +2. **A short label** — the subject, two or three words. It is what a reader scans for. +3. **What is covered, and what is explicitly not.** Both. An entry with no stated exclusions is one + the classifier will over-apply. +4. **A condition visible in the transcript.** This is the one that matters. The critique's finding was + that "uncheckable conditions are the biggest weakness" — entries state preconditions the classifier + cannot evaluate from the command text, so it either allows blindly (the condition is decorative) or + blocks (the grant is inoperable), with no stated disposition. + + When an answer names something the classifier cannot see — a person's intent, a fact about the + build server, whether a change was reviewed — **say so and ask for the observable form instead**. + "Only when the deploy is approved" is not checkable; "only when the command names the staging + endpoint" is. + +Stop when they say stop. A short correct block beats a long vague one. + +## Phase 3: Compose + +Feed the answers to the drafter, one record per line: + +```text +section allow +label Test Execution +covered running the repository's own test suite via its documented runner +not installing new dependencies as a side effect +why the runner is named in the command text +``` + +```shell +bash "${CLAUDE_PLUGIN_ROOT}/skills/draft-auto-mode-rules/scripts/draft-automode-block.sh" < answers +``` + +It prints a JSON object carrying only the sections that got entries. **Every section opens with +`"$defaults"`** — customizing a section replaces the built-in list rather than adding to it, so +omitting the token silently discards every shipped rule in that section. + +## Phase 4: Hand it over + +Print the block, say exactly where it goes — the `autoMode` key of +`${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json` — and stop. + +Two things to say while handing it over, because both are load-bearing: + +- **Project and local settings will not work.** The classifier reads `autoMode` from user settings, + managed settings, and inline `--settings`/SDK JSON only. A block pasted into `.claude/settings.json` + is silently inert. +- **Merging into an existing block is theirs to do.** If they already have an `autoMode` section, the + drafted sections replace what they paste over. Point them at the audit sibling to confirm the result + rather than assuming it merged. + +## Gotchas + +- **Do not offer to write the file.** Not with `--fix`, not "shall I apply this", not as a follow-up. + The no-write posture is the skill's contract, not a default someone can opt out of. +- **A drafted block is not a reviewed block.** After they paste it, `claude auto-mode critique` is what + judges it. Say that rather than implying the draft is validated. +- **`claude auto-mode reset` is never run.** It strips the `autoMode` section from user settings, and + on a chezmoi-managed home that loss is not recoverable from the settings file alone. +- **Prose provenance does not belong in an entry.** Dates, issue numbers, and prior-draft archaeology + cost classifier attention and buy nothing; the critique names stripping them explicitly. Version + control owns that history. diff --git a/plugins/claude-config/skills/draft-auto-mode-rules/evals/evals.json b/plugins/claude-config/skills/draft-auto-mode-rules/evals/evals.json new file mode 100644 index 0000000000..cac0e56713 --- /dev/null +++ b/plugins/claude-config/skills/draft-auto-mode-rules/evals/evals.json @@ -0,0 +1,64 @@ +{ + "skill_name": "draft-auto-mode-rules", + "evals": [ + { + "id": 1, + "name": "drafts-and-hands-over", + "prompt": "Help me write auto mode rules so Claude can run our test suite without asking every time.", + "expected_output": "Reads the existing autoMode block first, interviews for the section, a short label, what is and is not covered, and a condition visible in the transcript, then prints a paste-ready JSON block and says where it goes. Never offers to write the settings file.", + "files": [], + "expectations": [ + "Output runs the audit sibling's block lint before drafting anything", + "Output asks what is NOT covered, not only what is", + "Output prints the block and states the target key and file rather than editing it", + "Output does not offer to apply, write, or patch the settings file" + ] + }, + { + "id": 2, + "name": "uncheckable-condition-is-pushed-back", + "prompt": "Add an auto-mode allow rule for deploying, but only when the deploy has been approved by the team.", + "expected_output": "Names the condition as one the classifier cannot evaluate from the command text, explains that it will either be ignored or block the grant entirely, and asks for an observable restatement before drafting.", + "files": [], + "expectations": [ + "Output identifies 'approved by the team' as not visible in the transcript", + "Output explains the consequence: allowed blindly, or inoperable", + "Output asks for an observable form instead of drafting the uncheckable one" + ] + }, + { + "id": 3, + "name": "defaults-token-is-preserved", + "prompt": "Draft me a soft_deny entry for publishing packages to a public registry.", + "expected_output": "Emits a soft_deny section that opens with \"$defaults\", and says why: customizing a section replaces the built-in rule list rather than adding to it, so omitting the token would discard every shipped soft_deny rule.", + "files": [], + "expectations": [ + "Output's soft_deny section begins with the $defaults token", + "Output explains that a customized section replaces rather than merges" + ] + }, + { + "id": 4, + "name": "wrong-scope-is-called-out", + "prompt": "Put these auto mode rules in the project's .claude/settings.json so the whole team gets them.", + "expected_output": "States that the classifier does not read autoMode from project or local settings, so a block placed there is silently inert, and names user settings, managed settings, or inline --settings as the scopes that are read. Offers managed settings as the route for a team-wide block.", + "files": [], + "expectations": [ + "Output says a project-scope autoMode block is not read", + "Output names the scopes the classifier does read", + "Output does not draft into a scope it just said is inert without saying so" + ] + }, + { + "id": 5, + "name": "routes-out-to-the-auditor", + "prompt": "Are my current auto mode rules any good?", + "expected_output": "Routes to claude-config:audit-permission-state and `claude auto-mode critique` rather than judging the existing block itself, since this skill composes rules and does not evaluate them.", + "files": [], + "expectations": [ + "Output routes the evaluation question to the audit sibling or to critique", + "Output does not claim to have judged the quality of existing rules itself" + ] + } + ] +} diff --git a/plugins/claude-config/skills/draft-auto-mode-rules/scripts/draft-automode-block.sh b/plugins/claude-config/skills/draft-auto-mode-rules/scripts/draft-automode-block.sh new file mode 100755 index 0000000000..a98b2eecb1 --- /dev/null +++ b/plugins/claude-config/skills/draft-auto-mode-rules/scripts/draft-automode-block.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# draft-automode-block.sh — compose an `autoMode` block from interview answers, +# print it to stdout, and stop. The human reads it and pastes it. +# +# It writes nothing, anywhere, under any flag. That is not caution: a skill that +# edited a consumer's settings file would be making a permission decision on +# their behalf, which is the one thing this whole plugin exists NOT to do. +# +# Input: answer records on stdin, one per line, in the shape +# +# section +# label +# covered +# not +# why +# +# A blank line, or the next `section` record, closes the current entry. The +# record shape mirrors what the interview asks, so the skill never has to +# transform answers into some other vocabulary on the way here. +# +# Why this shape, rather than free prose: `claude auto-mode critique` on a real +# 66 KB hand-authored block found that the classifier is "an LLM doing a single +# pass under a 'default is ALLOW' instruction", so "buried conditions in +# paragraph position 40 will be missed at a materially higher rate than +# conditions in a bullet list", and recommended exactly this — bulleted COVERED / +# NOT COVERED plus a one-line rationale, with provenance stripped. The entry +# template here IS that recommendation, applied at authoring time instead of +# reported after the fact. +# +# Output: a JSON object carrying only the sections that received entries, each +# opening with "$defaults" so the built-in rules are kept. Strict-parseable by +# construction — we author it, so `jq -e .` must accept it. (The non-strict +# allowance elsewhere in this plugin exists for the CLI's malformed emission, +# not for anything written here.) +# +# Prerequisites: jq (required for correctness — it does the JSON escaping, which +# is the one part of this that must not be hand-rolled). +# +# Usage: +# draft-automode-block.sh < answers +# draft-automode-block.sh [--help] + +set -uo pipefail + +usage() { + cat <<'EOF' +draft-automode-block.sh — compose an autoMode block from interview answers. + +Usage: draft-automode-block.sh < answers + draft-automode-block.sh [--help] + +Answer records, one per line: + section + label + covered repeatable + not repeatable + why + +Prints a JSON object to stdout. Writes nothing, anywhere, under any flag — +paste the output into your own settings file yourself. + +Every emitted section opens with "$defaults": a customized section REPLACES the +built-in rule list rather than adding to it, so omitting the token would discard +every shipped rule in that section. +EOF +} + +case "${1:-}" in +-h | --help) + usage + exit 0 + ;; +"") ;; +*) + echo "ERROR: unknown argument '$1'" >&2 + exit 2 + ;; +esac + +if ! command -v jq >/dev/null 2>&1; then + echo "ERROR: jq required" >&2 + exit 2 +fi + +answers="$(cat)" +if [[ -z "${answers//[[:space:]]/}" ]]; then + echo "ERROR: no answer records on input — nothing to draft" >&2 + exit 2 +fi + +# jq builds the whole document: it owns the escaping, and hand-rolling JSON +# string escaping in awk is how a draft ends up carrying the very defect this +# plugin reports elsewhere (a raw control character inside a string value). +printf '%s\n' "$answers" | jq -Rn ' + def trim: sub("^\\s+"; "") | sub("\\s+$"; ""); + + # Entry text follows the shape `claude auto-mode critique` recommends: a + # label, then bulleted COVERED / NOT COVERED, then one line of rationale. + # Conditions the classifier cannot evaluate from a transcript are the + # documented weakness, so the interview is what keeps them out -- this only + # renders what it was given. + def render($e): + ($e.label // "" | trim) as $label + | ($e.covered // []) as $covered + | ($e.not // []) as $not + | ($e.why // "" | trim) as $why + | [ "\($label):" ] + + (if ($covered | length) > 0 then ["COVERED:"] + ($covered | map("- " + trim)) else [] end) + + (if ($not | length) > 0 then ["NOT COVERED:"] + ($not | map("- " + trim)) else [] end) + + (if $why != "" then ["Why: " + $why] else [] end) + | join("\n"); + + [inputs] + | map(select(test("\\S"))) + | reduce .[] as $line ( + {section: null, current: null, entries: []}; + ($line | capture("^\\s*(?section|label|covered|not|why)\\s+(?.*)$") // null) as $rec + | if $rec == null then . + elif $rec.key == "section" then + (if .current != null then .entries += [.current] else . end) + | .section = ($rec.value | trim) + | .current = null + elif $rec.key == "label" then + (if .current != null then .entries += [.current] else . end) + | .current = {section: .section, label: $rec.value, covered: [], not: [], why: ""} + elif .current == null then . + elif $rec.key == "covered" then .current.covered += [$rec.value] + elif $rec.key == "not" then .current.not += [$rec.value] + else .current.why = $rec.value + end + ) + | (if .current != null then .entries + [.current] else .entries end) + | map(select(.section != null and (.label | test("\\S")))) + | group_by(.section) + | map({ + key: .[0].section, + # "$defaults" leads every section: customizing REPLACES the built-in list + # rather than adding to it, so a section without it silently discards + # every shipped rule -- the exact defect the audit sibling reports. + value: (["$defaults"] + map(render(.))) + }) + | from_entries +' diff --git a/plugins/claude-config/skills/draft-auto-mode-rules/scripts/draft-automode-block.test.sh b/plugins/claude-config/skills/draft-auto-mode-rules/scripts/draft-automode-block.test.sh new file mode 100755 index 0000000000..6e58fbc7af --- /dev/null +++ b/plugins/claude-config/skills/draft-auto-mode-rules/scripts/draft-automode-block.test.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# Regression tests for draft-automode-block.sh (self-contained — ships with the plugin). +# +# Every case feeds hand-written answer records on stdin. Nothing here reads or +# writes any settings file, in any scope: the script under test has no write +# path at all, and one case asserts exactly that. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/draft-automode-block.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 +} + +if ! command -v jq >/dev/null 2>&1; then + echo "SKIP: jq not installed" >&2 + exit 0 +fi + +draft() { printf '%s\n' "$1" | bash "$SCRIPT"; } + +ANSWERS=$( + cat <<'EOF' +section allow +label Test Execution +covered running the repository's own test suite via its documented runner +not installing new dependencies as a side effect +why the runner is named in the command text +label Formatting +covered running the repo formatter in check mode +why check mode cannot modify files +section soft_deny +label Release Publish +covered publishing a package to a public registry +why the target registry is named in the command +EOF +) + +# --- The phase's own sanity check: strict-parseable output -------------------- +# Strict is correct here BECAUSE we author this output. The non-strict allowance +# elsewhere in the plugin exists for the CLI's malformed emission, and adopting +# it here would hide a defect of our own making. +OUT=$(draft "$ANSWERS") +rc=0 +printf '%s' "$OUT" | jq -e . >/dev/null 2>&1 || rc=$? +assert_exit "the drafted block parses under a STRICT parser" 0 "$rc" + +# --- Every section keeps the built-in rules ---------------------------------- +# Omitting "$defaults" discards the shipped rule list for that section, which is +# the single highest-consequence mistake this skill could bake into a draft. +assert_eq "every emitted section opens with \$defaults" 0 \ + "$(printf '%s' "$OUT" | jq '[to_entries[] | select(.value[0] != "$defaults")] | length')" + +# --- Only sections that received entries are emitted -------------------------- +# A section printed empty-but-for-$defaults would still REPLACE the built-in +# list on paste, so emitting an untouched section is not harmless. +assert_eq "only the two sections with entries are present" "allow soft_deny" \ + "$(printf '%s' "$OUT" | jq -r 'keys | join(" ")')" +assert_eq "no empty section is emitted" 0 \ + "$(printf '%s' "$OUT" | jq '[to_entries[] | select((.value | length) < 2)] | length')" + +# --- Entry shape follows the critique's recommendation ------------------------ +entry=$(printf '%s' "$OUT" | jq -r '.allow[1]') +assert_contains "the entry opens with its label" "$entry" "Test Execution:" +assert_contains "COVERED is its own header line" "$entry" "COVERED:" +assert_contains "and the covered item is a bullet under it" "$entry" "- running the repository's own test suite" +assert_contains "NOT COVERED is stated explicitly" "$entry" "NOT COVERED:" +assert_contains "and the exclusion is a bullet under it" "$entry" "- installing new dependencies" +assert_contains "and the rationale is one line at the end" "$entry" "Why: the runner is named in the command text" + +# An entry with no exclusions still renders, without an empty NOT COVERED header +# that would read as "nothing is excluded" rather than "none were stated". +entry2=$(printf '%s' "$OUT" | jq -r '.allow[2]') +assert_contains "an entry without exclusions still renders" "$entry2" "Formatting:" +assert_not_contains "and carries no empty NOT COVERED header" "$entry2" "NOT COVERED:" + +# --- Multiple covered/not lines accumulate ------------------------------------ +MULTI=$( + cat <<'EOF' +section allow +label Multi +covered first covered thing +covered second covered thing +not first excluded thing +not second excluded thing +why one line +EOF +) +OUT_MULTI=$(draft "$MULTI") +entry=$(printf '%s' "$OUT_MULTI" | jq -r '.allow[1]') +assert_contains "the first covered line appears" "$entry" "- first covered thing" +assert_contains "the second covered line appears too" "$entry" "- second covered thing" +assert_contains "the first excluded line appears" "$entry" "- first excluded thing" +assert_contains "the second excluded line appears too" "$entry" "- second excluded thing" + +# --- jq owns the escaping ----------------------------------------------------- +# Hand-rolled JSON escaping is how a draft would end up carrying the very defect +# this plugin reports elsewhere: a raw control character inside a string value. +QUOTED=$( + cat <<'EOF' +section allow +label Quoted "Label" And \Backslash +covered a value with "double quotes" and a \backslash and a tab character +why it must survive intact +EOF +) +OUT_QUOTED=$(draft "$QUOTED") +rc=0 +printf '%s' "$OUT_QUOTED" | jq -e . >/dev/null 2>&1 || rc=$? +assert_exit "quotes, backslashes and tabs still yield strict-valid JSON" 0 "$rc" +assert_contains "and the text survives the round trip" \ + "$(printf '%s' "$OUT_QUOTED" | jq -r '.allow[1]')" 'a value with "double quotes" and a \backslash' + +# --- Malformed and partial input --------------------------------------------- +# A line that is not a record is ignored rather than fatal: the interview feeds +# this, and a stray blank or comment must not cost the operator their answers. +NOISY=$( + cat <<'EOF' + +this line is not a record at all +section allow +label Real Entry +covered something real + +EOF +) +OUT_NOISY=$(draft "$NOISY") +assert_contains "an unrecognized line does not lose the entry" "$OUT_NOISY" "Real Entry:" + +# A covered line before any label has no entry to attach to and is dropped +# rather than silently starting one with an empty label. +ORPHAN=$( + cat <<'EOF' +section allow +covered orphaned line with no label +label Real +covered attached correctly +EOF +) +OUT_ORPHAN=$(draft "$ORPHAN") +assert_eq "an orphaned covered line creates no entry" 1 \ + "$(printf '%s' "$OUT_ORPHAN" | jq '.allow | length - 1')" +assert_not_contains "and its text is not smuggled into the real entry" \ + "$(printf '%s' "$OUT_ORPHAN" | jq -r '.allow[1]')" "orphaned line" + +# An entry with no section is dropped: there is nowhere correct to put it. +NOSECTION=$( + cat <<'EOF' +label Sectionless +covered something +EOF +) +rc=0 +OUT_NOSECTION=$(draft "$NOSECTION") || rc=$? +assert_exit "a sectionless entry does not crash the drafter" 0 "$rc" +assert_eq "and yields an empty object rather than a guessed section" "{}" \ + "$(printf '%s' "$OUT_NOSECTION" | jq -c .)" + +# --- Fail-loud ---------------------------------------------------------------- +rc=0 +err=$(printf '' | bash "$SCRIPT" 2>&1) || rc=$? +assert_exit "exit 2 on empty input" 2 "$rc" +assert_contains "the empty-input error says why" "$err" "nothing to draft" + +rc=0 +err=$(printf ' \n\n' | bash "$SCRIPT" 2>&1) || rc=$? +assert_exit "whitespace-only input is empty input" 2 "$rc" + +rc=0 +err=$(bash "$SCRIPT" --bogus &1) || rc=$? +assert_exit "exit 2 on an unknown argument" 2 "$rc" +assert_contains "the unknown argument is named" "$err" "unknown argument" + +rc=0 +help=$(bash "$SCRIPT" --help &1) || rc=$? +assert_exit "--help exits 0" 0 "$rc" +assert_contains "--help states the no-write contract" "$help" "Writes nothing, anywhere, under any flag" +assert_contains "--help explains why \$defaults leads every section" "$help" "REPLACES the" + +# --- The no-write contract, asserted rather than assumed ---------------------- +# Checked by RUNNING the script and looking at the filesystem, not by grepping +# it for redirect syntax: this file is mostly an embedded jq program, where `>` +# is a comparison operator and appears in prose, so a text scan cannot tell a +# redirect from either. Measuring the effect is both simpler and sound. +WATCH="$TEST_TMPDIR/watch" +mkdir -p "$WATCH/nested" +before="$(find "$WATCH" | LC_ALL=C sort)" +(cd "$WATCH" && printf '%s\n' "$ANSWERS" | bash "$SCRIPT" >/dev/null) +after="$(find "$WATCH" | LC_ALL=C sort)" +assert_eq "running the drafter changes nothing in its working directory" "$before" "$after" + +# And nothing under a fixture HOME either — the settings file this skill drafts +# for lives there, and never touching it is the whole posture. +FAKE_HOME="$TEST_TMPDIR/fake-home" +mkdir -p "$FAKE_HOME/.claude" +printf '{"permissions":{"allow":[]}}\n' >"$FAKE_HOME/.claude/settings.json" +home_before="$(cat "$FAKE_HOME/.claude/settings.json")" +# shellcheck disable=SC2016 # $1/$2 are the inner shell's positional parameters, passed after the _ placeholder; expanding them here would defeat the point +env -u CLAUDE_CONFIG_DIR HOME="$FAKE_HOME" bash -c 'printf "%s\n" "$1" | bash "$2" >/dev/null' _ "$ANSWERS" "$SCRIPT" +assert_eq "the user settings file is byte-identical afterwards" "$home_before" "$(cat "$FAKE_HOME/.claude/settings.json")" + +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 61c2666255db702ccf642bab65d6ee904f1331c1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:59:11 -0400 Subject: [PATCH 16/32] docs(migration-playbook): gate skill allowed-tools grants in the security review Criterion 1 reviewed hook commands as the code-execution surface and said nothing about a skill's frontmatter `allowed-tools`, which is also one. Measured on 2.1.225: a marketplace-installed skill's allowed-tools entry takes effect at USER scope in a NEVER-TRUSTED workspace, under -p where no trust dialog can appear. The covered command ran without a prompt, the uncovered one blocked with "This command requires approval", and a no-grant baseline confirmed that shape blocks. So the install-time plugin trust prompt is the ONLY gate in front of such a grant -- there is no second, per-workspace one. The criterion now says so, and denies by default anything broader than the specific command a skill's own scripts invoke. A wildcard interpreter grant is a deny outright: it is arbitrary code execution in a workspace the consumer never trusted. audit-permission-grants check P1 already detects those shapes, so the criterion names it as its mechanical half rather than leaving the check to memory. The measurement's bounds are stated with it: a local-directory marketplace, user scope only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/MIGRATION-PLAYBOOK.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/MIGRATION-PLAYBOOK.md b/docs/MIGRATION-PLAYBOOK.md index f152e89a83..6f113ab874 100644 --- a/docs/MIGRATION-PLAYBOOK.md +++ b/docs/MIGRATION-PLAYBOOK.md @@ -675,6 +675,19 @@ plugins-reference, and hooks pages 2026-07-17; re-verify per the `CLAUDE.md` fre **advisory** (exits 0, never blocks) vs gating; no `eval` / `curl … | sh` / outbound network; untrusted input (file contents, tool args, PR/issue text) never flows unquoted into a shell; a kill switch (a per-hook `userConfig` boolean with a `default` of `true`) exists. + - **A skill's frontmatter `allowed-tools` is a prompt-free execution grant, and workspace trust does + not gate it.** Measured on Claude Code 2.1.225: a marketplace-installed skill's `allowed-tools` + entry takes effect at **user scope** in a **never-trusted** workspace, under `-p` where no trust + dialog can appear — the covered command ran without a prompt, the uncovered one blocked with + `This command requires approval`, and a no-grant baseline confirmed that shape blocks. Bounds on + the measurement: a local-directory marketplace, and user scope only. + **Consequence: the install-time plugin trust prompt is the only gate in front of such a grant — + there is no second, per-workspace one.** Review every `allowed-tools` entry with the scrutiny a + hook command gets, and deny by default anything broader than the specific command the skill's own + scripts invoke. A wildcard interpreter grant (`Bash(python*)`, `Bash(*)`, bare `Bash`) is a deny + outright: it is arbitrary code execution in a workspace the consumer never trusted. + `claude-config:audit-permission-grants` check P1 detects exactly these shapes and is the + mechanical half of this criterion. 2. **MCP servers — `.mcp.json` / inline in `plugin.json`.** `miro` is the only plugin that ships a **local** `stdio`, bundled server (see its §2 trust accept above); `dometrain` is the only plugin that ships a **remote** server (see its review record below), which remains the higher-scrutiny From 7c48c6c4da8915e3886640218289720991bde076 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:38:02 -0400 Subject: [PATCH 17/32] feat(claude-config): prove the no-write contract, and make the reader fast enough to run Criterion 9 says these skills write nothing, in any scope, under any flag. That is the reason they are safe to point at a consumer's real configuration, so it is now proven by RUNNING all ten actions of both skills -- oracle explicitly ON against a stub `claude` -- and diffing a fixture tree and a fixture HOME either side. Reading the code would not have been evidence. Building that harness found a performance defect I introduced in Phase 4. The `conf` extension ran one `jq` invocation per key per settings surface: six spawns per file, thirty across five scopes. Process spawning dominates on Windows, so the reader took 65 seconds per run -- slow enough that every caller timed out and the harness looked like it was hanging rather than working. Collapsed to one invocation emitting all six keys: 65s to 7s, identical output. Worth fixing on its own terms; a reader nobody will wait for is a reader nobody will run. The harness itself needed four corrections, and the last is the one worth remembering. A helper returned its produced file path on STDOUT, which interleaved with the PASS lines the same function printed -- four stages silently never ran and the harness reported "All 10 checks passed". A green result over work it had not done. The path now comes back in a named variable, every stage asserts its output is non-empty, and a final assertion checks that all ten actions were attempted. A harness that can miscount its own cases is worse than no harness: it converts absence of verification into a passing claim. The three earlier corrections were a stub-only PATH that starved the scripts of tools they need (its `env` wrapper re-entered itself through the PATH it was setting up, deadlocking before the first assertion), pipelines composed inside `bash -c` strings that stalled on quoting, and a `while read` snapshot loop whose subshell deadlocked against its own feeding pipeline on Git Bash. Each failed silently -- no error, just no further output. Criterion 10 was NOT met and now is. The permissions.ask-under-auto-mode discrepancy (#83766, #42797) was recorded in the plan but appeared nowhere the skills ship, so a consumer acting on an `ask` finding had no way to know the reported behavior contradicts the documented one. It is now a named caveat with its retirement condition, and SKILL.md tells the reader to carry it. ADR 0009 records the two decisions that outlive this branch: report-never-write, and compute the merge locally bounded by decidability. Both had a defensible opposite answer, and enforcement was available rather than absent -- the posture is a choice, not a limitation. Also: audit-pass gains the permission lane, with its oracle path marked never-dispatched and its optional-lane skip required to reach the report as "unchecked with reason" rather than collapsed into "clean". OFFICIAL-DOCS dates advance for the four pages re-fetched during this work. Criterion 12 measured: 6737/8000 chars across nine listing-eligible skills. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr --- docs/OFFICIAL-DOCS.md | 10 +- ...lane-as-in-effect-and-never-write-to-it.md | 75 ++++++ docs/topics/permission-model/PLAN.md | 58 ++++- .../claude-config/skills/audit-pass/SKILL.md | 17 ++ .../skills/audit-permission-state/SKILL.md | 5 + .../reference/criteria.md | 22 ++ .../scripts/no-writes.test.sh | 230 ++++++++++++++++++ .../scripts/permission-state.sh | 38 +-- 8 files changed, 433 insertions(+), 22 deletions(-) create mode 100644 docs/adr/0009-report-the-permission-plane-as-in-effect-and-never-write-to-it.md create mode 100755 plugins/claude-config/skills/audit-permission-state/scripts/no-writes.test.sh diff --git a/docs/OFFICIAL-DOCS.md b/docs/OFFICIAL-DOCS.md index f38c752922..255264d2e7 100644 --- a/docs/OFFICIAL-DOCS.md +++ b/docs/OFFICIAL-DOCS.md @@ -47,7 +47,7 @@ components are declared in, not a component, so it has no row. | Monitors (`monitors/monitors.json`) | | 2026-08-06 | | Channels (`channels` manifest field) | | 2026-08-06 | | Executables (`bin/`) | | 2026-08-06 | -| Settings (`settings.json` defaults) | | 2026-08-06 | +| Settings (`settings.json` defaults) | | 2026-08-12 | | Dependencies (`dependencies` manifest field) | | 2026-08-06 | ## Authoring @@ -104,14 +104,14 @@ SDK-based host. | Page | Official doc page | Verified date | |---|---|---| -| Settings | | 2026-08-06 | +| Settings | | 2026-08-12 | | Server-managed settings | | 2026-08-06 | | Control MCP server access for your organization | | 2026-08-06 | | Memory — CLAUDE.md, `.claude/rules/`, auto memory | | 2026-08-06 | | The `.claude` directory | | 2026-08-06 | -| Permissions | | 2026-08-06 | -| Permission modes | | 2026-08-06 | -| Configure auto mode (`autoMode`, `claude auto-mode`) | | 2026-08-09 | +| Permissions | | 2026-08-12 | +| Permission modes | | 2026-08-12 | +| Configure auto mode (`autoMode`, `claude auto-mode`) | | 2026-08-12 | | Environment variables | | 2026-08-10 | ## Prompting doctrine (platform docs) diff --git a/docs/adr/0009-report-the-permission-plane-as-in-effect-and-never-write-to-it.md b/docs/adr/0009-report-the-permission-plane-as-in-effect-and-never-write-to-it.md new file mode 100644 index 0000000000..d49fc65e3e --- /dev/null +++ b/docs/adr/0009-report-the-permission-plane-as-in-effect-and-never-write-to-it.md @@ -0,0 +1,75 @@ +# Report the permission plane as in effect, and never write to it + +- Status: accepted +- Date: 2026-08-12 + +## Context + +Claude Code's permission plane is legible only to the harness. `/permissions` lists rules and the file +each came from, but nothing resolves which of two conflicting rules wins, nothing distinguishes a +scope that was empty from one that could not be read, and there is no machine-readable export. Auto +mode became the default permission mode for new sessions, and on entry it silently drops broad allow +rules — so a consumer's grants can stop taking effect with no signal at all. + +Research across roughly thirty third-party tools found nobody auditing an `autoMode` block, resolving +cross-scope precedence, or validating managed policy against the scopes beneath it. The highest-adoption +linter in the space carries one permission rule out of 447. The official marketplace ships 284 plugins +and none manage permission configuration. + +Two questions had to be decided before building anything, and both had a defensible opposite answer. + +## Decision 1 — report, never write + +**These skills write nothing, in any scope, under any flag.** No `--fix`, no "shall I apply this", +no follow-up offer. + +Enforcement was available: hook `deny` and exit-2 both work, measured. This is a posture choice, not a +capability limit. Editing a consumer's settings file is making a permission decision on their behalf, +and a plugin that will silently adjust what the agent may do is a worse failure than one that reports +a problem the human then fixes. The authoring lane draws the same line — it prints a block and the +human pastes it. + +The cost is real: an operator with 77 dead allow rules must fix them by hand. That is accepted. + +## Decision 2 — compute the merge locally, bounded by decidability + +**Claims follow from documented mechanics over readable inputs, each citing the mechanic it follows +from.** 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, never an +assertion. + +The alternative was to wait for an official export. Nothing suggests one is coming, and the local +merge is decidable for the part that matters. + +Two mechanics carry the result, and conflating them produces confident wrong answers: + +- **Permission rules merge across scopes rather than override.** A rule at two scopes has no winner — + both are live. Electing one would assert an override the documentation denies. +- **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 the reverse. Scope rank does + not enter into it, and an implementation that ranked scopes here gets the low-scope-deny case + exactly backwards. + +Every effective-set claim states two standing bounds: the command-line scope 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 known — over-reporting allow, +never over-reporting blocking. + +## Consequences + +- Reports are trustworthy about their own limits. `absent` means looked and found nothing; `skipped` + means could not look. A surface that could not be read is never reported as absence of policy — + an administrator reading silence as "no policy deployed" is the failure this exists to prevent. +- **Managed policy is read-only by construction**, not by restraint: those are admin-write OS + locations or a claude.ai Owner role. The conformance report says what a consumer's own policy does + and does not achieve, and ships no security floor of its own. +- Server-managed settings have no local path, so every managed claim is scoped to the local surfaces. +- One optional lane needs `python3`, because `claude auto-mode config` emits raw control characters + inside JSON string values that no line-oriented POSIX filter can repair. Absent it, that lane skips + visibly and everything else still runs. +- **Re-opens if** an official machine-readable export of resolved permission state ships, or if + `claude permissions` becomes a real subcommand — either would retire the local merge as the read + path. A debug-channel oracle already exists as corroboration but is not a read path: it costs a + session spawn and parses undocumented `[DEBUG]` strings with no stability contract. +- **The write posture re-opens only on an explicit decision**, not on a feature request. It is the + contract these skills are trusted under. diff --git a/docs/topics/permission-model/PLAN.md b/docs/topics/permission-model/PLAN.md index 58c062b482..6ab0243c8b 100644 --- a/docs/topics/permission-model/PLAN.md +++ b/docs/topics/permission-model/PLAN.md @@ -805,7 +805,63 @@ output, and the non-strict allowance exists only for the CLI's malformed emissio 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] +### Phase 8: Cross-cutting close [IN PROGRESS] + +**Settled so far, 2026-08-12:** + +- **Criterion 12 measured, not assumed: `6737/8000` chars across nine listing-eligible skills.** Both + new entries are inside the shared budget with room left. +- **Criterion 10 was NOT met and now is.** The `permissions.ask`-under-auto-mode discrepancy (#83766, + #42797) was recorded in the plan but appeared nowhere the skills ship — so a consumer acting on an + `ask` finding had no way to know the reported behavior contradicts the documented one. It is now a + named caveat in `reference/criteria.md` with its retirement condition, and `SKILL.md` tells the + reader to carry it into any `ask` report. +- **Fresh-eyes declarations verified in the enforced form**, not by grep: `check-skill.sh` returns + PASS for both skills, and each carries exactly one + `` directive. +- **Version-bump assertions hold**: `plugin.json` moved `0.29.0` → `0.32.0` from the branch point, and + its description names both new skills. +- **Frontmatter `name:` re-checked rather than assumed**, as the phase demanded: the repo-wide drop + has landed on `main`, so both new skills correctly omit it. +- **`audit-pass` lane registered.** `audit-permission-state` takes an action flag and no target, so it + is exactly one lane. Two constraints are recorded with it: its `--oracle` path is **never + dispatched** (the pass cannot price a session spawn for the operator mid-run), and its optional + `autoMode` lane's self-reported skip must be carried into the report as *unchecked with its reason* + rather than collapsed into "clean". +- **`docs/OFFICIAL-DOCS.md` verified dates advanced** for the four pages actually re-fetched during + this work. + +**A performance defect introduced in Phase 4 was found by the criterion-9 harness and fixed.** The +`conf` extension ran **one `jq` invocation per key per settings surface** — six spawns per file, +thirty across five scopes. Process spawning dominates on Windows, so the reader took **65 seconds** +per run, slow enough that every caller timed out and the harness looked like it was hanging rather +than working. Collapsed to one `jq` invocation emitting all six keys: **65s → 7s**, identical output. +Worth fixing on its own terms, not merely to unblock a test — a reader nobody will wait for is a +reader nobody will run. + +**Criterion 9 is asserted mechanically and the harness needed three rewrites to be trustworthy.** The +first ran every action under a stub-only `PATH`, which starved the scripts of tools they legitimately +need — and worse, its `env` wrapper re-entered itself through the very `PATH` it was setting up, so +the harness deadlocked before its first assertion. The second composed pipelines inside `bash -c` +strings, which stalled on quoting. The third replaced a `while read` snapshot loop whose subshell +deadlocked against its own feeding pipeline on Git Bash. Stages now chain through files and only +`claude` is stubbed, because what is under test is whether these programs write — not how they are +composed. The oracle runs with the flag explicitly ON against that stub, since the default +configuration never exercises the one path that spawns a process. + +A fourth defect was in the harness's own reporting, and it is the one worth remembering: a helper +returned its produced file path on **stdout**, which interleaved with the `PASS` lines the same +function printed. Four stages silently never ran, and the harness reported **"All 10 checks passed"** +— a green result over work it had not done. The path now comes back in a named variable, every stage +asserts its output is non-empty, and a final assertion checks that all nine actions were attempted. +A harness that can miscount its own cases is worse than no harness, because it converts absence of +verification into a passing claim. + +Each earlier rewrite failed *silently* too — no error, just no further output — which is the standing +lesson: a hung harness, a slow one, and a passing one that has not finished all look identical from +outside. + +### Phase 8 (original brief, retained for the record) - **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 diff --git a/plugins/claude-config/skills/audit-pass/SKILL.md b/plugins/claude-config/skills/audit-pass/SKILL.md index be4bcfd44d..7901dc0791 100644 --- a/plugins/claude-config/skills/audit-pass/SKILL.md +++ b/plugins/claude-config/skills/audit-pass/SKILL.md @@ -241,6 +241,23 @@ Dispatch, in inventory order, each invocation presence-gated with its fallback s belong to exactly one lane by construction rather than needing a deduplication rule downstream — and the per-class lanes drop the pair check, since dispatching it there is what created the overlap. +- **`/claude-config:audit-permission-state`** — sibling in this plugin, always available. It owns the + permission plane as it is *in effect*: the merged allow/ask/deny set with per-rule provenance, + what auto mode drops on entry, configuration written where nothing reads it, and which managed + intents are enforced versus loosenable. It takes an **action flag and no target**, so it is + **exactly one lane** covering all of that. + + **Its managed-scope reads belong to the pass's read-only managed inventory, not to a project lane.** + It reads managed policy on every OS and never writes anywhere, in any scope, under any flag — so it + is safe to dispatch under the pass's bare invocation. Its `--oracle` path spawns a real session and + is **never dispatched here**: the pass has no way to price that for the operator mid-run, and the + flag exists to make the cost an explicit choice. + + **Its optional lanes degrade rather than fail.** The `autoMode` block lane needs `python3` and + `claude` on PATH; absent either, that lane self-reports as skipped and the rest of the skill still + runs. Carry that skip into the report as **unchecked with its reason**, exactly as an absent plugin + would be — the distinction between "clean" and "not read" is this skill's whole contract and the + pass must not collapse it. - **`/claude-memory:audit`** — invoke when the `claude-memory` plugin is installed; it owns memory-layer hygiene and the within-memory-layer consistency check. It takes an **action verb and no surface filter**, so it is **exactly one lane** covering the whole memory layer. Not installed: diff --git a/plugins/claude-config/skills/audit-permission-state/SKILL.md b/plugins/claude-config/skills/audit-permission-state/SKILL.md index c8ebeaeb13..3534c3a53b 100644 --- a/plugins/claude-config/skills/audit-permission-state/SKILL.md +++ b/plugins/claude-config/skills/audit-permission-state/SKILL.md @@ -241,6 +241,11 @@ collapse it in the report: - **`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. +- **An `ask` finding carries an open upstream discrepancy.** The permissions page says content-scoped + `ask` rules always prompt, "even in auto mode"; issues #83766 and #42797 report them auto-approved + under `defaultMode: "auto"`. This plugin follows the documented behavior — it is the only source + with a stated contract — but say so when reporting an `ask` result, and point at `permissions.deny` + where the outcome must hold regardless. See `reference/criteria.md`. - **`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. 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 31e93a2284..acd7c29c0b 100644 --- a/plugins/claude-config/skills/audit-permission-state/reference/criteria.md +++ b/plugins/claude-config/skills/audit-permission-state/reference/criteria.md @@ -222,6 +222,28 @@ one feature. `claude auto-mode reset` is never run. It strips the `autoMode` section from user settings. +## Open upstream discrepancy — carry this caveat on any `ask` finding + +Any finding that rests on an `ask` rule prompting under auto mode carries this, named: + +> The permissions page states that content-scoped `ask` rules "always force a permission prompt, even +> in auto mode… The classifier cannot auto-approve a matching action." + +Two upstream issues (**#83766** and **#42797**) report the opposite — `permissions.ask` patterns +auto-approved under `defaultMode: "auto"`. Both cannot be true. This plugin follows the documented +behavior, because that is the only source with a stated contract, but a reader acting on an `ask` +finding should know the reported behavior contradicts it. + +**What this changes in practice:** an `ask` rule is reported here as outranking an `allow`, and as +surviving auto mode. If the issues are right, an `ask` rule is weaker in auto mode than this report +implies — so treat `ask` as a prompt you *expect*, not a guarantee you *rely on*, and use +`permissions.deny` where the outcome must hold. This is not a defect in the reader: it reports the +documented mechanic, and the discrepancy is upstream. + +**Retires when** the permissions page and the issue reports agree — either the issues close as +not-reproducible against a current version, or the page is corrected. Only a fresh read of both +settles it; a version bump alone does not. + ## Managed policy, and what it does not buy > "no other level, including command line arguments, can override a managed permission rule." diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/no-writes.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/no-writes.test.sh new file mode 100755 index 0000000000..1ff1e34bd7 --- /dev/null +++ b/plugins/claude-config/skills/audit-permission-state/scripts/no-writes.test.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# no-writes.test.sh — criterion 9, asserted mechanically rather than by reading +# the code. +# +# Both skills claim to write nothing, in any scope, under any flag. That claim is +# the reason they are safe to point at a consumer's real configuration, so it is +# checked by RUNNING every action of both and diffing the filesystem either side +# — a fixture tree AND a fixture HOME, since the settings files these skills are +# about live in the second one. +# +# The oracle path is exercised deliberately. Running only the defaults would +# prove nothing about the one code path that spawns a process capable of writing +# outside the tree; a stub `claude` on PATH stands in for the real CLI so the +# spawn happens without a session, a token, or a network call. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STATE="$SCRIPT_DIR/permission-state.sh" +MERGE="$SCRIPT_DIR/permission-merge.sh" +ENTRY_DIFF="$SCRIPT_DIR/automode-entry-diff.sh" +PLANE_LINT="$SCRIPT_DIR/permission-plane-lint.sh" +BLOCK_LINT="$SCRIPT_DIR/automode-block-lint.sh" +CONFORMANCE="$SCRIPT_DIR/managed-conformance.sh" +DRAFTER="$SCRIPT_DIR/../../draft-auto-mode-rules/scripts/draft-automode-block.sh" +FIXTURES="$SCRIPT_DIR/../evals/fixtures" + +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 +} + +if ! command -v jq >/dev/null 2>&1; then + echo "SKIP: jq not installed" >&2 + exit 0 +fi + +# --- The tree every action runs against -------------------------------------- +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)","Bash(python*)"],deny:["WebFetch"]}}' >"$FX/proj/.claude/settings.json" +jq -n '{permissions:{allow:["Bash(npm test)"]}}' >"$FX/proj/.claude/settings.local.json" +jq -n '{autoMode:{classifyAllShell:true},permissions:{allow:["Bash(*)"],disableAutoMode:true}}' >"$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/10-first.json" +jq -n '{permissions:{allow:["Bash(ls)"]}}' >"$FX/startdir/.claude/settings.local.json" + +# A stub `claude` so the oracle's spawn path executes without a real session. +# It writes into the debug file it is handed, which is the scratch path the +# oracle chose — proving the capture lands there and nowhere else. +STUB="$TEST_TMPDIR/stub-bin" +mkdir -p "$STUB" +real_bash="$(command -v bash)" +printf '#!%s\n' "$real_bash" >"$STUB/claude" +# A quoted heredoc: the body is the STUB's source, so its parameters must reach +# the file verbatim rather than expanding against this harness's values. +cat >>"$STUB/claude" <<'STUB_CLAUDE' +debug="" +while [[ $# -gt 0 ]]; do + case "$1" in + --debug-file) + debug="$2" + shift 2 + ;; + *) shift ;; + esac +done +if [[ -n "$debug" ]]; then + printf '%s\n' "[DEBUG] Applying permission update: Adding 1 allow rule(s) to destination 'userSettings'" >"$debug" +fi +printf 'OK\n' +STUB_CLAUDE +chmod +x "$STUB/claude" +# Only `claude` is stubbed. An earlier revision wrapped every tool the scripts +# use, which deadlocked: a wrapper for `env` re-entered itself through the very +# PATH it was setting up, and the harness hung before its first assertion. The +# real tools are reached through the inherited PATH appended below. + +snapshot() { + # Path, size and full content of every file under the given roots. Mtime is + # deliberately excluded: a read can update atime on some filesystems, and this + # is a test about writes. + # + # `find -exec` rather than a `while read` loop: on Git Bash the loop's subshell + # deadlocked against the pipeline feeding it, and the harness hung before its + # first assertion. `-exec … +` needs no subshell at all. + local root + for root in "$@"; do + find "$root" -type f -exec wc -c {} + 2>/dev/null | LC_ALL=C sort + find "$root" -type f -exec cat {} + 2>/dev/null + done +} + +fixture_env() { + 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="" \ + AUTOMODE_CONFIG_FIXTURE="$FIXTURES/automode-config-rawctl.json" \ + AUTOMODE_DEFAULTS_FIXTURE="$FIXTURES/automode-defaults.json" \ + AUTOMODE_CRITIQUE_FIXTURE="$TEST_TMPDIR/critique.txt" \ + PATH="$STUB:$PATH" \ + "$@" +} + +printf 'Your rules look consistent.\n' >"$TEST_TMPDIR/critique.txt" + +ANSWERS=$( + cat <<'EOF' +section allow +label Test Execution +covered running the test suite +not installing dependencies +why the runner is named in the command +EOF +) + +# --- Every action of both skills, oracle ON ----------------------------------- +before="$(snapshot "$FX")" + +CASE_NUM_ACTIONS=0 +run_action() { + # run_action