DEV Community

Jasveer Singh
Jasveer Singh

Posted on

Your Claude Code allowlist is wider than you think: building an auditor for the rules you forgot you wrote

Every Claude Code user has the same file, and nobody reads it. settings.json accumulates permission rules one approval at a time: you approve a command once, the grant lives forever, and the list only grows. It also isn't one file — user settings, the shared project file committed to the repo, the project-local file, and managed settings all merge into one effective allowlist.

The prompts that guard the list aren't much of a backstop. Anthropic reports that users approve 97% of permission prompts, and in its controlled study with 1,053 paid testers, human reviewers caught only 13.6% of dangerous commands planted partway through their sessions (Anthropic, Auto mode is now the default in Claude Code, Aug 2026). If approval is a reflex, the written rules are the real security boundary. So I built permission-audit: a static, read-only audit of the whole chain, in dependency-free Python.

pipx install git+https://github.com/jasveerai88/permission-audit
permission-audit audit ~/.claude/settings.json
Enter fullscreen mode Exit fullscreen mode

The semantics are the product

An auditor is only as good as the rules it judges against, and the failure mode here is confidently wrong: an early draft of this tool modelled shadowing the way intuition suggests — an allow "overriding" a deny. The official documentation says the opposite, and checking it changed the implementation:

  • Evaluation order is deny, then ask, then allow. The first match in that order decides; specificity doesn't change the order. A broad deny blocks a narrower allow, and an allow cannot carve an exception out of a deny.
  • Lists merge across settings files rather than overriding.
  • File precedence: managed > command line > project local > shared project > user.

The consequence that surprises everyone: a deny rule can never be shadowed. Deny evaluates first, so the dead rules are allows covered by a wider deny or ask, and asks covered by a wider deny. allow Bash(aws s3 ls) under deny Bash(aws *) is dead code that looks like an exception. permission-audit reports exactly that shape — and its SHADOWED_RULE findings say "Heuristic", because the coverage judgement rests on a stated prefix model (in SKILL.md), not on the runtime. Every other check rests on the documented semantics, cited in the code and the skill file with the date they were checked (2026-11-04).

What it finds in a real-shaped file

The repo ships a constructed "dangerous" fixture — a shared project settings file with every fault planted:

  • Bash(*), a bare Write, and WebFetch(domain:*) — wildcard-everything grants → BROAD_GRANT errors.
  • defaultMode: bypassPermissions and skipDangerousModePermissionPrompt: true in a shared file → DANGEROUS_MODE errors. The finding carries the nuance: current Claude Code (≥ v2.1.257) ignores bypassPermissions from project and local files, but older versions honored it from any file — and a committed bypass is the loaded gun of a settings file either way.
  • The dead allow and dead ask above → SHADOWED_RULE.
  • Bash(npm run *) twice → DUPLICATE_RULE; the lists merge, so the copy changes nothing.
  • Entries the loader skips (an empty string, an unbalanced rule, a bare 42) → PERMISSIONS_SHAPE.
  • bash(npm test) — lowercase → RULE_NEVER_MATCHES. Tool names match canonically; the rule does nothing, ever.
  • Read(**) and Edit(//**) → UNBOUNDED_FILE_ACCESS: grants rooted at the filesystem itself.

Total: 15 findings — 6 errors, 8 warnings, 1 info — exit 1. The chain fixture shows the cross-file version: one project deny killing an allow in both the user and local files. A clean fixture passes with zero findings. All four fixtures' expected reports are pinned to hand-counted tables in the test suite (68 tests), so the behaviour described here is the behaviour tested.

Read-only, and provably so

An auditor you have to trust is a poor joke, so permission-audit is built to be checkable:

  • It never evaluates, simulates, or shell-parses a command from a rule; everything is static text analysis.
  • Its own source imports no network and no subprocess module — just argparse, json, sys, pathlib.
  • The test suite hashes every fixture before and after CLI runs; the bytes must be identical.

The limits, in the same size type

Coverage is a model, not the runtime. Real matching involves shell parsing and tool-specific behaviour a prefix model can't see: a rule reported dead is provably dead only under the model, and a rule not reported may still never fire. The tool-name list behind RULE_NEVER_MATCHES is a snapshot dated 2026-11-04 — new tools aren't case-checked until it refreshes. Settings keys beyond permissions (hooks, env, model) are out of scope, and additionalDirectories — which widens file access in practice — is deliberately not modelled in v1.0.0, named in the docs so the gap is explicit.

Try it on your own chain

permission-audit audit ~/.claude/settings.json     # one file
permission-audit audit .claude/                    # a project's folder
permission-audit audit a.json b.json --json        # a chain, for tooling
Enter fullscreen mode Exit fullscreen mode

Exit codes are the CI contract: 1 = at least one ERROR, 0 = no errors, 2 = input error (always a clean {"error": ...} in JSON mode, never a traceback). A warnings-only audit exits 0 on purpose: warnings are dead weight, not live exposure, and a gate that fails on warnings trains people to bypass the gate.

The full rule table, the coverage model, and the documented-semantics citations are in the repo's SKILL.md. If you audit a chain and the tool gets it wrong — a dead rule it misses, a finding it shouldn't fire — an issue with a constructed fixture is the most useful thing you can send. (Never a real allowlist: it's a map of what a machine will run without asking.) Sister tool: skill-vet, which vets agent skill packs before you install them — the two audit the two halves of an agent setup.

https://github.com/jasveerai88/permission-audit

Top comments (0)