Purpose
/permissions lists your rules and the settings file each one came from, and for "where is this rule
written" that is the answer, so 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
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-memoryplugin.
Report-only, permanently
This skill writes no settings file, in any scope, under any flag. That is the contract, and it
holds including under --oracle. It is not the same as writing nothing at all: --oracle spawns a
real claude -p session, and a session rewrites ~/.claude.json and adds project, session-env,
security, subagent and backup state under your config directory. The flag prints that before it
spawns anything. Every other action writes nothing anywhere. 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".--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 realclaude -psession; 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
Run the deterministic spine:
bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-state/scripts/permission-state.sh"
It emits one record per line:
<scope> <surface> <status> <path> one per settings surface
rule <scope> <surface> <kind> <rule text> one per allow/ask/deny entry
NOTE: <text> anything the operator must know
| Field | Values |
|---|---|
scope |
managed, user, project, local, startdir-local |
surface |
file, dropin-dir, dropin-file:<name>, registry, plist (managed); settings elsewhere |
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:
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:
CAVEAT: <text> what bounds the claim
effective <kind> scopes=<a,b> precedence_basis=<token> <rule> one per live rule
inert <kind> scopes=<a,b> outranked_by=<kind> <rule> 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
inertrecord 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, other denies included;
EndConversationis the documented exception. A whole-tool ask prompts for every call, so no scoped allow for that tool applies. Both print aNOTE: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.
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:
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"
DIFF-NOTE: <text> classifyAllShell state, bounds
entry-diff dropped class=<class> scopes=<a,b> <rule> dropped on entry
entry-diff suspended reason=classifyAllShell ... suspended while auto mode is active
entry-diff kept scopes=<a,b> <rule> carries over
entry-diff summary allow_before=<n> dropped=<n> suspended=<n> kept=<n>
- 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".
classnames the documented reason:blanket,interpreter-wildcard,package-manager-run,agent, ormonitor. The three shell shapes come fromlib/permission-patterns.sh, the vocabularyaudit-permission-grantscheck P1 also scans with;agentandmonitorare whole-tool classes this script tests on the tool token.Monitorallow rules joined the dropped set upstream in v2.1.236, because Claude Code runs Monitor commands through the shell.autoMode.classifyAllShellinverts the answer wholesale. When true it suspends every Bash and 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.--oracleis opt-in and priced. It spawns a realclaude -psession to corroborate the prediction. Measured cost: your settings files are untouched, but~/.claude.jsonis 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:
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"
finding <severity> [<check>] <scope> <detail>
lint summary findings=<n> checks_run=<n>
Nine checks: three C2-* dead-config gates, C5-disableType, and five 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-disableTypeis the one to read first.disableAutoModemust 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
C2gates 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".
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:
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 inallowand in a deny section.C3-shadowed: an entry an earlierhard_denyalready forecloses, so it can never fire.--critiquesurfacesclaude auto-mode critique, wrapped and 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.
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:
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 <rule>: 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 managedautoModesection is additive, not a policy boundary. A developer cannot remove entries it provides, but a developer-addedallowcan override an organizationsoft_deny. Permissions, hooks, MCP, sandbox-filesystem and sandbox-network each got an exclusivity lock; auto mode did not.managed loosenable lockout:disableAutoModeset 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
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:
absentmeans looked and found nothing.skippedmeans could not look. Never present askippedsurface as "no policy". Say the surface was not read and why. The script emits aNOTE: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. managedmeans 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
askfinding carries an open upstream discrepancy. The permissions page says content-scopedaskrules always prompt, "even in auto mode"; issues #83766 and #42797 report them auto-approved underdefaultMode: "auto". This plugin follows the documented behavior, the only source with a stated contract, but say so when reporting anaskresult, and point atpermissions.denywhere the outcome must hold regardless. Seereference/criteria.md. invalid-jsonis notabsent. 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
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, since 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
and the dated record for the pre-v2.1.211 boundary.
Prerequisites
jq, required for correctness. Absent, the script stops at the entry point withERROR: jq requiredand exit 2. Report the environment gap; do not report a clean bill.reg(Windows) anddefaults(macOS), required for an optional feature. Absent, that one managed surface isskippedwith 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
Failure modes that produce a confidently wrong answer:
- 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.exemangled and the query dies withERROR: 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 invokeregyourself while debugging, do the same or you will reproduce the wrong answer by hand. - A missing shared library must not look like a clean machine. If the plugin's
lib/managed-scope.shcannot be sourced, the reader exits 2 rather than reporting every managed surfaceabsent. 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.jsonresolves through worktrees to the main checkout, so a reader anchored ongit rev-parse --show-toplevellooks where the file is not and reportsabsent. 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. The merge exits 2 when the input carries no scope records at all, so a reader that died cannot feed it a clean "nothing in effect"; if you build your own pipeline around these scripts, check the status rather than the output.
- Two live copies of
settings.local.jsonare 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. The dated record for the boundary isreference/criteria.md§Scopes.