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.
- permission_audit-1.0.0/LICENSE +21 -0
- permission_audit-1.0.0/PKG-INFO +483 -0
- permission_audit-1.0.0/README.md +457 -0
- permission_audit-1.0.0/pyproject.toml +52 -0
- permission_audit-1.0.0/setup.cfg +4 -0
- permission_audit-1.0.0/src/permission_audit.egg-info/PKG-INFO +483 -0
- permission_audit-1.0.0/src/permission_audit.egg-info/SOURCES.txt +10 -0
- permission_audit-1.0.0/src/permission_audit.egg-info/dependency_links.txt +1 -0
- permission_audit-1.0.0/src/permission_audit.egg-info/entry_points.txt +2 -0
- permission_audit-1.0.0/src/permission_audit.egg-info/top_level.txt +1 -0
- permission_audit-1.0.0/src/permission_audit.py +1109 -0
- permission_audit-1.0.0/tests/test_permission_audit.py +799 -0
|
@@ -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).
|