"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."
Resources
3Install
npx skillscat add melodic-software/claude-code-plugins/plugins-claude-config-skills-audit-permission-state Install via the SkillsCat registry.
This skill identifies active permission scopes (managed policy, user-global, project, local, and legacy start-directory) and lists their allow/ask/deny rules with source attribution. It resolves conflicts like ignored allow rules or unclear policy origins by exposing effective permissions without modifying settings. Use it to audit current permissions, trace rule sources, or verify policy visibility before configuration changes.
Purpose
/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
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 entryTwo 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, andscopes=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": theinertrecord 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,
oragent, fromlib/permission-patterns.sh— the vocabularyaudit-permission-grantscheck P1
also scans with.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 — 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 becauseclaude 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 reportedstatus=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 — it is the only source
with a stated contract — but say so when reporting anaskresult, and point atpermissions.deny
where 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 — 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
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
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 reachesreg.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 used to look like a clean machine. If the plugin's
lib/managed-scope.shcould not be sourced, every managed surface reportedabsent. It is now a
hardexit 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.jsonresolves
through worktrees to the main checkout, so a reader anchored ongit rev-parse --show-toplevel
looks 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. 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.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.