permission-audit 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jasveer Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,483 @@
1
+ Metadata-Version: 2.4
2
+ Name: permission-audit
3
+ Version: 1.0.0
4
+ Summary: Audit a Claude Code permission allowlist chain — wildcard grants, dangerous modes committed to shared settings, and rules that can never fire because a wider rule wins.
5
+ Author: Jasveer Singh
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jasveerai88/permission-audit
8
+ Project-URL: Repository, https://github.com/jasveerai88/permission-audit
9
+ Project-URL: Issues, https://github.com/jasveerai88/permission-audit/issues
10
+ Project-URL: Changelog, https://github.com/jasveerai88/permission-audit/blob/main/CHANGELOG.md
11
+ Keywords: claude-code,ai-agents,permissions,security,static-analysis,cli
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Security
21
+ Classifier: Topic :: Software Development :: Quality Assurance
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Dynamic: license-file
26
+
27
+ <h1 align="center">permission-audit</h1>
28
+
29
+ <p align="center"><strong>Your Claude Code allowlist is wider than you think — audit it before it hurts you.</strong></p>
30
+
31
+ <p align="center">
32
+ <img alt="tests: 68 passing" src="https://img.shields.io/badge/tests-68%20passing-brightgreen">
33
+ <img alt="python 3.10 | 3.11 | 3.12" src="https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue">
34
+ <img alt="version 1.0.0" src="https://img.shields.io/badge/version-1.0.0-orange">
35
+ <img alt="dependencies: none" src="https://img.shields.io/badge/dependencies-0-brightgreen">
36
+ <img alt="license: MIT" src="https://img.shields.io/badge/license-MIT-blue">
37
+ </p>
38
+
39
+ <p align="center"><img alt="permission-audit demo: a dangerous settings file fails with six errors, a clean file passes" src="docs/demo.gif" width="880"></p>
40
+
41
+ A permission allowlist only grows. You approve a command
42
+ once and the grant lives forever: `Bash(*)` approved for
43
+ one workflow and never revisited, allow rules dead under
44
+ a wider deny nobody remembers writing, and — worst of
45
+ all — `bypassPermissions` committed to a shared repo's
46
+ `.claude/settings.json`, where every teammate inherits
47
+ it. Nobody reads the chain until something runs that
48
+ shouldn't have.
49
+
50
+ The human backstop is weaker than it feels. Anthropic
51
+ reports that Claude Code users approve 97% of permission
52
+ prompts, and in its controlled study with 1,053 paid
53
+ testers, human reviewers caught only 13.6% of dangerous
54
+ commands planted in their sessions (Anthropic, *Auto
55
+ mode is now the default in Claude Code*, Aug 2026 —
56
+ checked 2026-11-04). If the prompts are a rubber stamp,
57
+ the written rules are the real security boundary — and
58
+ they accrete unaudited across the settings chain: user
59
+ settings, the shared project file committed to the repo,
60
+ and the local file approvals are saved into.
61
+ permission-audit reads the whole chain for you: every
62
+ rule inventoried, the documented evaluation order
63
+ applied, each finding labelled with the rule, the file,
64
+ and what is actually true about it.
65
+
66
+ **Read-only, and static.** It edits nothing, writes
67
+ nothing, and never runs a command. Permission rules are
68
+ analysed as text — nothing in the files is evaluated or
69
+ executed. The test suite proves the read-only claim:
70
+ every fixture audited through the CLI comes out
71
+ byte-identical.
72
+
73
+ **The semantics are documented, not guessed.** The
74
+ shipped checks rest on the official Claude Code
75
+ permissions and settings documentation
76
+ (code.claude.com/docs/en/permissions and /en/settings,
77
+ checked 2026-11-04): rules are evaluated **deny first,
78
+ then ask, then allow** — specificity does not change
79
+ the order, a matching ask prompts even when a more
80
+ specific allow exists, and an allow cannot carve an
81
+ exception out of a deny. Permission lists **merge**
82
+ across files rather than overriding. File precedence,
83
+ highest first: **managed > project local
84
+ (`.claude/settings.local.json`) > shared project
85
+ (`.claude/settings.json`) > user
86
+ (`~/.claude/settings.json`)**. Where a check goes
87
+ beyond the documented semantics — pattern coverage —
88
+ the model is stated in SKILL.md and the finding says
89
+ "Heuristic" in its own text.
90
+
91
+ One consequence of the documented order is worth
92
+ stating plainly, because intuition gets it backwards:
93
+ **a deny rule can never be shadowed by an allow.** Deny
94
+ is evaluated first, so the rules that die are allows
95
+ covered by a wider deny or ask, and asks covered by a
96
+ wider deny. That is what SHADOWED_RULE reports.
97
+
98
+ ## Quick start
99
+
100
+ ```bash
101
+ pipx install git+https://github.com/jasveerai88/permission-audit # or: pip install git+https://github.com/jasveerai88/permission-audit
102
+ permission-audit audit ~/.claude/settings.json
103
+ ```
104
+
105
+ That's the whole workflow: one command, one report, an
106
+ exit code you can gate on — **1** = at least one ERROR
107
+ finding, **0** = no errors, **2** = input error. Pass
108
+ one settings file, several (a chain — argument order
109
+ does not matter, precedence sorts them), or a folder (a
110
+ `.claude/` folder's settings files, or loose
111
+ `settings.json` / `settings.local.json` /
112
+ `managed-settings.json` files inside it). Add `--json`
113
+ for a machine-readable report. From a source checkout,
114
+ `python3 src/permission_audit.py audit <file>` does the
115
+ same thing. Python 3.10+, standard library only, no
116
+ dependencies.
117
+
118
+ ## What it catches
119
+
120
+ Every example below is a real line from the shipped
121
+ fixture `examples/dangerous/.claude/settings.json` —
122
+ the full report is [below](#a-real-run--a-bloated-shared-settings-file).
123
+
124
+ | Catch | What has to be true | Real example (from `examples/`) |
125
+ |---|---|---|
126
+ | **Wildcard grants** | An allow rule that auto-approves a whole tool: `Bash(*)`, a bare tool name, `WebFetch(domain:*)`, a bare `*` | `allow Bash(*)`, bare `allow Write`, `allow WebFetch(domain:*)` → `BROAD_GRANT` (ERROR) |
127
+ | **Dangerous modes, shared** | `defaultMode: bypassPermissions` or the dangerous-mode prompt switched off, in a file teammates inherit | Both set in the shared project file → `DANGEROUS_MODE` (ERROR), with the ≥ v2.1.257 version nuance stated |
128
+ | **Dead rules** | A rule that can never take effect because a higher tier covers it | `allow Bash(aws s3 ls)` under `deny Bash(aws *)` → `SHADOWED_RULE`; the same for a dead ask under a wider deny |
129
+ | **Rules that match nothing** | The tool name is a case-variant of a documented name — matching is exact | `allow bash(npm test)` → `RULE_NEVER_MATCHES` (ERROR): it does nothing, ever |
130
+ | **Filesystem-rooted access** | A Read / Edit / Write allow rooted at the filesystem itself, no project boundary | `allow Read(**)`, `allow Edit(//**)` → `UNBOUNDED_FILE_ACCESS` |
131
+ | **Accretion** | The identical rule twice in the merged chain, or a rule fully covered by a broader one in the same list | `Bash(npm run *)` twice → `DUPLICATE_RULE`; `Bash(git log *)` under `Bash(git *)` → `REDUNDANT_RULE` |
132
+ | **Files that load nothing** | JSON that does not parse, or entries the loader skips | `examples/invalid-settings.json` → `INVALID_JSON` at line 5, column 5 — including its deny rules, every rule in it is inert |
133
+
134
+ Verdicts: **FAIL** (at least one ERROR), **NO
135
+ ERROR-CLASS GRANTS** (warnings only — rules that can
136
+ never take effect, or grants broader than they look),
137
+ **CLEAN** (no findings). The exit code follows the
138
+ ERROR count: a warnings-only audit exits 0, because
139
+ warnings describe dead weight, not live exposure — and
140
+ a gate that fails on warnings trains people to bypass
141
+ the gate.
142
+
143
+ [Quick start](#quick-start) · [What it catches](#what-it-catches) ·
144
+ [How it compares](#how-it-compares) · [Reference](#reference)
145
+
146
+ ## How it compares
147
+
148
+ | | **permission-audit** | Reading the JSON yourself | A JSON linter / schema check |
149
+ |---|---|---|---|
150
+ | Scope | The whole chain, merged, in documented precedence order | One file at a time, precedence held in your head | One file, syntax only |
151
+ | Evaluation order | Documented deny → ask → allow applied to every rule | You simulate it — and intuition gets shadowing backwards | Not modelled |
152
+ | Dead rules | Shadowed, duplicate, and never-matching rules flagged, covering rule cited | Invisible until the day they matter | Not checked — dead rules are valid JSON |
153
+ | Dangerous modes | Flagged with scope-aware severity and the version nuance | One line among hundreds, easy to scroll past | `bypassPermissions` is a valid string; passes |
154
+ | Output | Text report + `--json`, exit codes 1 / 0 / 2 for CI | — | Pass/fail on syntax |
155
+
156
+ ## Honest limits (the short version)
157
+
158
+ Coverage is a model, not the runtime: real matching
159
+ involves shell parsing and tool-specific behaviour the
160
+ prefix model cannot see, so a rule reported dead is
161
+ provably dead only under the stated model — and
162
+ heuristics say so in their own text. The tool-name list
163
+ is a dated snapshot. Settings keys beyond permissions
164
+ are out of scope. The full limits are
165
+ [below](#honest-limits).
166
+
167
+ ---
168
+
169
+ ## Reference
170
+
171
+ **Contents:** [What it checks](#what-it-checks) ·
172
+ [Full reports: dangerous](#a-real-run--a-bloated-shared-settings-file) ·
173
+ [chain](#a-chain--three-files-one-merged-verdict) ·
174
+ [clean and broken](#a-clean-file-and-a-broken-one) ·
175
+ [For agents](#for-agents) · [Tests](#tests) ·
176
+ [Honest limits](#honest-limits)
177
+
178
+ ### What it checks
179
+
180
+ | Rule | Severity | What it catches |
181
+ |---|---|---|
182
+ | `INVALID_JSON` | ERROR | The file does not parse (exact line / column reported), or the JSON root is not an object. An unparseable file loads nothing — including its deny rules. |
183
+ | `PERMISSIONS_SHAPE` | ERROR / WARN | `permissions` is not an object, or a list is not a list (ERROR — nothing in it can take effect); a single entry the loader skips — non-string, empty, unparseable, empty specifier, or an `mcp__` rule with parentheses (WARN). |
184
+ | `BROAD_GRANT` | ERROR / WARN | An allow rule that auto-approves a whole tool: `Bash(*)`, a bare tool name, `WebFetch(domain:*)`, or a bare `*`. ERROR for tools that run code or write (Bash, PowerShell, Edit, Write, MultiEdit, NotebookEdit, WebFetch); WARN for read-only tools. Broad deny/ask rules are never flagged — they are not grants. |
185
+ | `DANGEROUS_MODE` | ERROR / WARN | `defaultMode: bypassPermissions` (either spelling of the key), or `skipDangerousModePermissionPrompt: true`. ERROR in shared or managed files — committed for everyone who clones; WARN in user / local / unknown files, where it is one person's own machine. The finding carries the version nuance: current Claude Code (≥ v2.1.257) ignores `bypassPermissions` from project and local files, but older versions honored it from any file. |
186
+ | `SHADOWED_RULE` | WARN | A rule that can never take effect because a higher tier covers it: an allow covered by a deny or an ask, an ask covered by a deny. Heuristic — coverage uses the SKILL.md prefix model. |
187
+ | `DUPLICATE_RULE` | WARN | The identical rule string twice in the same list across the merged chain. Lists merge, so the copy changes nothing — pure accretion. |
188
+ | `UNBOUNDED_FILE_ACCESS` | WARN | A Read / Edit / Write allow rooted at the filesystem itself: `**`, `/*`, `//**`, or a `**/`-leading pattern with no project boundary. Heuristic — the exact forms judged are listed in SKILL.md. |
189
+ | `RULE_NEVER_MATCHES` | ERROR | The tool name is a case-variant of a documented tool name (`bash(npm test)`) — rules match canonical names exactly, so the rule matches nothing, ever. Unknown tool names are left alone: the tool list grows. |
190
+ | `REDUNDANT_RULE` | INFO | A rule fully covered by a broader rule in the *same* list (`Bash(git log *)` under `Bash(git *)`). Coverage by a wildcard-everything rule is excluded — that rule is already the BROAD_GRANT finding. |
191
+ | `BYPASS_DISABLED` | INFO | `permissions.disableBypassPermissionsMode: "disable"` is set — bypass mode is fenced off machine-wide. The good kind of finding. |
192
+
193
+ Every finding names the file, and rule findings name
194
+ the list and the rule text. Exit codes: **1** when any
195
+ ERROR finding exists, **0** otherwise, **2** for input
196
+ errors (missing file, unreadable input, folder with no
197
+ settings files) — always as a clean `{"error": ...}`
198
+ object in `--json` mode, never a traceback.
199
+
200
+ ### A real run — a bloated shared settings file
201
+
202
+ `examples/dangerous/.claude/settings.json` is a
203
+ constructed project file with every fault planted: a
204
+ wildcard-everything grant, a bare `Write`,
205
+ `WebFetch(domain:*)`, `bypassPermissions` and the
206
+ dangerous-mode prompt switched off in a *shared* file,
207
+ a dead allow, a dead ask, an exact duplicate, three
208
+ entries the loader skips, a case-variant tool name, and
209
+ two filesystem-rooted file grants. Verbatim output:
210
+
211
+ ```
212
+ $ permission-audit audit examples/dangerous/.claude/settings.json
213
+ permission-audit v1.0.0 — audit · 1 settings file · scope project
214
+ 17 permission rules in the chain · effective defaultMode: bypassPermissions (from examples/dangerous/.claude/settings.json)
215
+ Read-only: it edits nothing and never runs a command. Permission rules are analysed as static text — nothing in the files is evaluated or executed.
216
+ Documented semantics: rules are evaluated deny first, then ask, then allow — specificity does not change the order; permission lists merge across scopes; file precedence is managed > project local > shared project > user.
217
+
218
+ INVENTORY (17 rules):
219
+ examples/dangerous/.claude/settings.json (project scope) — 17 rules · defaultMode: bypassPermissions
220
+ allow Bash(*)
221
+ allow Bash(npm run *)
222
+ allow Bash(npm run *)
223
+ allow Bash(aws s3 ls)
224
+ allow Bash(git *)
225
+ allow Bash(git log *)
226
+ allow bash(npm test)
227
+ allow Read(**)
228
+ allow Edit(//**)
229
+ allow WebFetch(domain:*)
230
+ allow Write
231
+ ask Bash(deploy *)
232
+ ask Bash(git push *)
233
+ deny Bash(aws *)
234
+ deny Bash(deploy *)
235
+ deny Bash(rm -rf *)
236
+ deny Read(./.env)
237
+
238
+ ERRORS — grants broader than their text, or rules that can never work as configured (6):
239
+ [BROAD_GRANT] examples/dangerous/.claude/settings.json · allow · Bash(*)
240
+ allow rule "Bash(*)" auto-approves every Bash call — a wildcard-everything grant. Grants like this are the ones an accreted allowlist hides: it was presumably added for one workflow and now covers all of them
241
+ fix: narrow it to the pattern the workflow actually needs (e.g. Bash(npm run *)), or remove it and let the calls prompt
242
+ [BROAD_GRANT] examples/dangerous/.claude/settings.json · allow · WebFetch(domain:*)
243
+ allow rule "WebFetch(domain:*)" auto-approves every WebFetch call, to any domain — a wildcard-everything grant. Grants like this are the ones an accreted allowlist hides: it was presumably added for one workflow and now covers all of them
244
+ fix: narrow it to the pattern the workflow actually needs (e.g. Bash(npm run *)), or remove it and let the calls prompt
245
+ [BROAD_GRANT] examples/dangerous/.claude/settings.json · allow · Write
246
+ allow rule "Write" auto-approves every Write call — a wildcard-everything grant. Grants like this are the ones an accreted allowlist hides: it was presumably added for one workflow and now covers all of them
247
+ fix: narrow it to the pattern the workflow actually needs (e.g. Bash(npm run *)), or remove it and let the calls prompt
248
+ [DANGEROUS_MODE] examples/dangerous/.claude/settings.json
249
+ permissions.defaultMode is "bypassPermissions" in a SHARED settings file — it is committed to the repo and ships to every teammate who clones it: sessions start with permission prompts skipped. Version nuance: current Claude Code (>= v2.1.257) ignores bypassPermissions set in 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
250
+ fix: remove bypassPermissions from the shared file; anyone who wants it can set it in their own user settings, per machine, on purpose
251
+ [DANGEROUS_MODE] examples/dangerous/.claude/settings.json
252
+ skipDangerousModePermissionPrompt is true in a SHARED settings file, committed for every teammate to inherit: the one warning that stands between a user and bypassPermissions mode is switched off
253
+ fix: remove the key from the shared file; leave the dangerous-mode prompt on
254
+ [RULE_NEVER_MATCHES] examples/dangerous/.claude/settings.json · allow · bash(npm test)
255
+ "bash(npm test)" names the tool "bash" — permission rules match canonical tool names exactly, so this rule can never match the Bash tool and does nothing (for deny and ask rules Claude Code shows a startup warning; the rule still does not match)
256
+ fix: change the tool name to "Bash"
257
+
258
+ WARNINGS — rules that can never take effect, or probably do not do what you intend (8):
259
+ [DUPLICATE_RULE] examples/dangerous/.claude/settings.json · allow · Bash(npm run *)
260
+ the allow rule "Bash(npm run *)" appears more than once in the chain (first in examples/dangerous/.claude/settings.json) — permission lists merge across scopes, so the extra copy changes nothing; it is the allowlist accreting
261
+ fix: keep the rule in one file (the scope it belongs to) and remove the copies
262
+ [PERMISSIONS_SHAPE] examples/dangerous/.claude/settings.json · allow
263
+ "" cannot be parsed as a permission rule (Tool or Tool(specifier)) — it matches nothing and the loader skips it
264
+ fix: write it as a tool name, or as Tool(specifier) with balanced parentheses
265
+ [PERMISSIONS_SHAPE] examples/dangerous/.claude/settings.json · allow · Bash(npm run
266
+ "Bash(npm run" cannot be parsed as a permission rule (Tool or Tool(specifier)) — it matches nothing and the loader skips it
267
+ fix: write it as a tool name, or as Tool(specifier) with balanced parentheses
268
+ [PERMISSIONS_SHAPE] examples/dangerous/.claude/settings.json · deny · 42
269
+ an entry in the deny list is an int, not a string — Claude Code skips malformed permission rules (a documented Settings Warning), so this entry does nothing
270
+ fix: make every entry a rule string: a tool name, or Tool(specifier)
271
+ [SHADOWED_RULE] examples/dangerous/.claude/settings.json · allow · Bash(aws s3 ls)
272
+ the allow rule "Bash(aws s3 ls)" can never take effect: the deny rule "Bash(aws *)" in examples/dangerous/.claude/settings.json matches everything it matches, and rules are evaluated deny first, then ask, then allow (documented) — the call is denied instead. Heuristic: coverage uses the prefix model in SKILL.md
273
+ fix: remove the dead rule, or narrow the covering rule if the exception was the intent (an allow cannot carve an exception out of a deny — documented)
274
+ [SHADOWED_RULE] examples/dangerous/.claude/settings.json · ask · Bash(deploy *)
275
+ the ask rule "Bash(deploy *)" can never take effect: the deny rule "Bash(deploy *)" in examples/dangerous/.claude/settings.json matches everything it matches, and rules are evaluated deny first, then ask, then allow (documented) — the call is denied instead. Heuristic: coverage uses the prefix model in SKILL.md
276
+ fix: remove the dead rule, or narrow the covering rule if the exception was the intent (an allow cannot carve an exception out of a deny — documented)
277
+ [UNBOUNDED_FILE_ACCESS] examples/dangerous/.claude/settings.json · allow · Edit(//**)
278
+ allow rule "Edit(//**)" grants Edit access rooted at the filesystem itself — the path pattern has no project boundary, so it reaches every file the user account can. Heuristic: read off the specifier's shape; the exact forms judged are listed in SKILL.md
279
+ fix: root the pattern inside the project (e.g. Read(./src/**)), or drop the rule and let reads outside the project prompt
280
+ [UNBOUNDED_FILE_ACCESS] examples/dangerous/.claude/settings.json · allow · Read(**)
281
+ allow rule "Read(**)" grants Read access rooted at the filesystem itself — the path pattern has no project boundary, so it reaches every file the user account can. Heuristic: read off the specifier's shape; the exact forms judged are listed in SKILL.md
282
+ fix: root the pattern inside the project (e.g. Read(./src/**)), or drop the rule and let reads outside the project prompt
283
+
284
+ INFO — worth knowing (1):
285
+ [REDUNDANT_RULE] examples/dangerous/.claude/settings.json · allow · Bash(git log *)
286
+ the allow rule "Bash(git log *)" is fully covered by the allow rule "Bash(git *)" in examples/dangerous/.claude/settings.json — the outcome is the same with or without it. Pure accretion: the list grows, nothing changes
287
+ fix: remove the narrower rule, or keep both knowingly — the broad rule is the one doing the work
288
+
289
+ Summary: 15 findings · 6 errors · 8 warnings · 1 info
290
+ Verdict: FAIL — 6 error(s): at least one grant is wildcard-broad, a dangerous mode is set where others inherit it, or a rule can never match. Fix the errors before trusting this allowlist.
291
+ (exit code 1)
292
+ ```
293
+
294
+ Note what is *not* flagged: `ask Bash(git push *)` sits
295
+ under `allow Bash(git *)`, but ask is evaluated before
296
+ allow, so the prompt still fires — no finding. And
297
+ `deny Bash(rm -rf *)` is exactly as live as it looks:
298
+ nothing outranks deny.
299
+
300
+ ### A chain — three files, one merged verdict
301
+
302
+ The chain example ships a user file, a shared project
303
+ file, and a project-local file under `examples/chain/`.
304
+ The arguments below are deliberately scrambled —
305
+ precedence, not argument order, sorts the chain:
306
+
307
+ ```
308
+ $ permission-audit audit examples/chain/project/.claude/settings.json examples/chain/home/.claude/settings.json examples/chain/project/.claude/settings.local.json --home examples/chain/home
309
+ permission-audit v1.0.0 — audit · 3 settings files · chain order (highest precedence first): local > project > user
310
+ 6 permission rules in the chain · effective defaultMode: acceptEdits (from examples/chain/project/.claude/settings.json)
311
+ Read-only: it edits nothing and never runs a command. Permission rules are analysed as static text — nothing in the files is evaluated or executed.
312
+ Documented semantics: rules are evaluated deny first, then ask, then allow — specificity does not change the order; permission lists merge across scopes; file precedence is managed > project local > shared project > user.
313
+
314
+ INVENTORY (6 rules):
315
+ examples/chain/project/.claude/settings.local.json (local scope) — 1 rule
316
+ allow Bash(npm run build)
317
+ examples/chain/project/.claude/settings.json (project scope) — 2 rules · defaultMode: acceptEdits
318
+ allow Bash(make *)
319
+ deny Bash(npm *)
320
+ examples/chain/home/.claude/settings.json (user scope) — 3 rules
321
+ allow Bash(make *)
322
+ allow Bash(npm run build)
323
+ allow Read(./docs/*)
324
+
325
+ WARNINGS — rules that can never take effect, or probably do not do what you intend (4):
326
+ [SHADOWED_RULE] examples/chain/project/.claude/settings.local.json · allow · Bash(npm run build)
327
+ the allow rule "Bash(npm run build)" can never take effect: the deny rule "Bash(npm *)" in examples/chain/project/.claude/settings.json matches everything it matches, and rules are evaluated deny first, then ask, then allow (documented) — the call is denied instead. Heuristic: coverage uses the prefix model in SKILL.md
328
+ fix: remove the dead rule, or narrow the covering rule if the exception was the intent (an allow cannot carve an exception out of a deny — documented)
329
+ [DUPLICATE_RULE] examples/chain/home/.claude/settings.json · allow · Bash(make *)
330
+ the allow rule "Bash(make *)" appears more than once in the chain (first in examples/chain/project/.claude/settings.json) — permission lists merge across scopes, so the extra copy changes nothing; it is the allowlist accreting
331
+ fix: keep the rule in one file (the scope it belongs to) and remove the copies
332
+ [DUPLICATE_RULE] examples/chain/home/.claude/settings.json · allow · Bash(npm run build)
333
+ the allow rule "Bash(npm run build)" appears more than once in the chain (first in examples/chain/project/.claude/settings.local.json) — permission lists merge across scopes, so the extra copy changes nothing; it is the allowlist accreting
334
+ fix: keep the rule in one file (the scope it belongs to) and remove the copies
335
+ [SHADOWED_RULE] examples/chain/home/.claude/settings.json · allow · Bash(npm run build)
336
+ the allow rule "Bash(npm run build)" can never take effect: the deny rule "Bash(npm *)" in examples/chain/project/.claude/settings.json matches everything it matches, and rules are evaluated deny first, then ask, then allow (documented) — the call is denied instead. Heuristic: coverage uses the prefix model in SKILL.md
337
+ fix: remove the dead rule, or narrow the covering rule if the exception was the intent (an allow cannot carve an exception out of a deny — documented)
338
+
339
+ Summary: 4 findings · 0 errors · 4 warnings · 0 info
340
+ Verdict: NO ERROR-CLASS GRANTS — but 4 warning(s) describe rules that can never take effect or grants broader than they look.
341
+ (exit code 0)
342
+ ```
343
+
344
+ The project deny `Bash(npm *)` kills the
345
+ `Bash(npm run build)` allow in *both* other files —
346
+ cross-file shadowing is the whole point of auditing the
347
+ chain instead of one file at a time. (`--home` points
348
+ the user-scope inference at the fixture home; on a real
349
+ machine it defaults to your actual home directory.)
350
+
351
+ ### A clean file, and a broken one
352
+
353
+ ```
354
+ $ permission-audit audit examples/clean/.claude/settings.json
355
+ permission-audit v1.0.0 — audit · 1 settings file · scope project
356
+ 6 permission rules in the chain · effective defaultMode: default (from examples/clean/.claude/settings.json)
357
+ Read-only: it edits nothing and never runs a command. Permission rules are analysed as static text — nothing in the files is evaluated or executed.
358
+ Documented semantics: rules are evaluated deny first, then ask, then allow — specificity does not change the order; permission lists merge across scopes; file precedence is managed > project local > shared project > user.
359
+
360
+ INVENTORY (6 rules):
361
+ examples/clean/.claude/settings.json (project scope) — 6 rules · defaultMode: default
362
+ allow Bash(npm run *)
363
+ allow Bash(git status)
364
+ allow Read(./src/**)
365
+ ask Bash(git push *)
366
+ deny Bash(rm -rf *)
367
+ deny Read(./.env)
368
+
369
+ No findings — every rule passes the static rules.
370
+ Summary: 0 findings · 0 errors · 0 warnings · 0 info
371
+ Verdict: CLEAN — no broad grants, dead rules or dangerous modes found by the static rules.
372
+ (exit code 0)
373
+ ```
374
+
375
+ ```
376
+ $ permission-audit audit examples/invalid-settings.json
377
+ permission-audit v1.0.0 — audit · 1 settings file · scope unknown
378
+ 0 permission rules in the chain · no defaultMode set in the chain
379
+ Read-only: it edits nothing and never runs a command. Permission rules are analysed as static text — nothing in the files is evaluated or executed.
380
+ Documented semantics: rules are evaluated deny first, then ask, then allow — specificity does not change the order; permission lists merge across scopes; file precedence is managed > project local > shared project > user.
381
+
382
+ INVENTORY (0 rules):
383
+ examples/invalid-settings.json (unknown scope) — 0 rules
384
+ (file does not parse — no rules load from it)
385
+
386
+ ERRORS — grants broader than their text, or rules that can never work as configured (1):
387
+ [INVALID_JSON] examples/invalid-settings.json
388
+ the file is not valid JSON — line 5, column 5: Expecting value. Claude Code loads nothing from a file it cannot parse: every permission rule in it is inert, including its deny rules
389
+ fix: fix the JSON at the reported position (a trailing comma and unquoted keys are the usual causes)
390
+
391
+ Summary: 1 findings · 1 errors · 0 warnings · 0 info
392
+ Verdict: FAIL — 1 error(s): at least one grant is wildcard-broad, a dangerous mode is set where others inherit it, or a rule can never match. Fix the errors before trusting this allowlist.
393
+ (exit code 1)
394
+ ```
395
+
396
+ ### For agents
397
+
398
+ `--json` emits one object: per-file records (scope,
399
+ rule count, default modes, parse status), the full rule
400
+ inventory with parsed tool and specifier, the effective
401
+ default mode and which file set it, findings (`rule`,
402
+ `severity`, `file`, `list`, `rule_text`, `message`,
403
+ `fix`), and summary counts that recount exactly against
404
+ the findings. Input errors — including argument errors —
405
+ are a single JSON object with an `error` key and exit 2.
406
+ [SKILL.md](SKILL.md) states the full rule model, the
407
+ coverage arithmetic, the documented-semantics citations,
408
+ and what the tool deliberately does not claim — and
409
+ makes permission-audit itself usable as a skill: point
410
+ your agent at this repo's SKILL.md and it will audit
411
+ settings chains with the documented semantics in hand.
412
+
413
+ ### Tests
414
+
415
+ ```
416
+ python3 -m unittest discover -s tests
417
+ ```
418
+
419
+ 68 tests: every rule in both directions, the coverage
420
+ model pinned pair by pair (including the documented
421
+ trailing-space nuance: `Bash(ls *)` also matches bare
422
+ `ls`), chain precedence and effective-mode resolution,
423
+ per-fixture hand-counted tables, JSON recount, exit
424
+ codes 0/1/2 with no-traceback assertions, and the
425
+ read-only proof — fixture bytes hashed before and after
426
+ CLI runs.
427
+
428
+ ### Honest limits
429
+
430
+ - **Coverage is a model, not the runtime.** Real
431
+ matching involves argument semantics, shell parsing,
432
+ and tool-specific behaviour the prefix model cannot
433
+ see. A rule reported dead is provably dead only under
434
+ the stated model; a rule *not* reported may still
435
+ never fire in practice. Findings say "Heuristic"
436
+ where this applies.
437
+ - **Tool inventory is a snapshot.** The never-matches
438
+ check compares case-variants against the documented
439
+ tool names as of 2026-11-04; new tools simply are not
440
+ case-checked until the list is refreshed.
441
+ - **Scope is inferred from file name and location.**
442
+ A file passed by an unusual path is `unknown` scope,
443
+ and mode findings fall back to the cautious severity.
444
+ - **Settings keys beyond permissions are out of scope**
445
+ (hooks have their own auditor — see hook-doctor).
446
+ `additionalDirectories` widens file access in practice
447
+ and is deliberately not modelled in v1.0.0.
448
+
449
+ ## Contributing
450
+
451
+ See [CONTRIBUTING.md](CONTRIBUTING.md). The rule table
452
+ is the product: every check rests on documented
453
+ semantics (cited, with the date checked) or is labelled
454
+ a heuristic in its own finding text. Found a settings
455
+ file permission-audit misjudges — a dead rule it misses
456
+ or a finding it shouldn't fire? Open an issue with a
457
+ *constructed* fixture; never a real allowlist, which is
458
+ a map of what a machine will run without asking.
459
+
460
+ ## Security
461
+
462
+ permission-audit is itself a security tool, so its own
463
+ posture is simple: it has no network code, no subprocess
464
+ code, no dependencies, and it never evaluates or
465
+ executes a command it reads. See [SECURITY.md](SECURITY.md)
466
+ for how to report a problem.
467
+
468
+ ## License
469
+
470
+ MIT — see [LICENSE](LICENSE).
471
+
472
+ ## Related
473
+
474
+ - [skill-vet](https://github.com/jasveerai88/skill-vet)
475
+ — the other half of pre-flight for agent setups: vets
476
+ a whole agent skill pack before you install it,
477
+ blocking only on proven stealing / spying /
478
+ hidden-sending combinations.
479
+
480
+ ## Support
481
+
482
+ If permission-audit saves you a 2 a.m. incident,
483
+ consider [sponsoring](https://github.com/sponsors/jasveerai88).