Table of Contents generated with DocToc
permission-audit
Capability: substrate:sandbox
Harness: agnostic
Audit + atomically edit Claude Code’s permissions.allow[] entries
in <repo>/.claude/settings.json and <repo>/.claude/settings.local.json.
Backs the --apply-permission-audit flag of
/magpie-setup verify
(check 8d), and is also directly usable as a CLI.
OpenCode. The same over-permissioning check applies to the other
harness through audit-opencode, which reads an
opencode.json permission
config instead of a Claude allow-list. OpenCode models permissions
differently — permission is a string or an object keyed by tool, and
permission.bash may map glob command patterns to allow/ask/deny
(last-matching-rule-wins, with a "*" default) — so this is a separate
classifier, but it enforces the same intent: flag configuration that
auto-approves dangerous shell execution. It reports, in JSON:
blanket-allow—permissionis the string"allow"(every tool auto-approved);bash-allow-all— the defaultpermission.bashdecision is"allow";dangerous-allow— a specific rule auto-approves a dangerous command family (git push,sudo,curl/wget,rm -rf, cloud CLIs,kubectl,docker run,ssh, interpreters/npx/uvx, …) while the default is stricter.
uv run --project tools/permission-audit permission-audit audit-opencode opencode.json
The Claude-only apply subcommand (atomic allow-list edits) has no
OpenCode counterpart yet; audit-opencode is read-only.
Kiro. audit-kiro reads a Kiro CLI agent config
(.kiro/agents/<name>.json). Kiro models permissions as an
allowlist/denylist rather than a per-command decision map
(Kiro shell tool):
allowedCommands / deniedCommands are \A..\z-anchored regex,
deny-before-allow, with denyByDefault and autoAllowReadonly; and the
shell tool in allowedTools auto-approves everything (overriding
toolsSettings). Same intent, reported in JSON:
shell-allow-all— the shell tool (shell/execute_bash/*/@builtin) is inallowedTools(every shell command auto-approved);dangerous-allow— anallowedCommandsregex auto-approves a dangerous family (unblocked bydeniedCommands);exfil-allow—aws/web_fetchis blanket-allowed inallowedTools, orweb_fetch.trustedis a blanket.*.
uv run --project tools/permission-audit permission-audit audit-kiro .kiro/agents/<name>.json
Like audit-opencode, audit-kiro is read-only (no apply), and a
missing shell policy is not flagged — Kiro’s default is to prompt.
Prerequisites
- Runtime: Python 3.11+ run via
uv(uv sync/uv run); the tool itself is stdlib-only. - CLIs: None beyond the runtime.
- Credentials / auth: None.
- Network: None — operates entirely on local
.claude/settings.json/.claude/settings.local.jsonfiles. - Optional: dev group adds
pytest,ruff,mypyfor the test- lint suite.
Why
Adopter permission allow-lists drift over time. Two failure modes the framework’s verify check 8d surfaces:
- Forbidden wildcards accumulate — patterns like
Bash(uv run *),Bash(python3 *),Bash(npm run *),Bash(bash *),Bash(gh api *)are equivalent to allowing arbitrary code execution. Each is easy to add for a short-lived reason (“just for this session”) and easy to forget. - Narrow read-only patterns the framework’s skills invoke
constantly are missing — the
securityfamily’s Gmail and PonyMail read MCPs,Bash(vulnogram-api-record-fetch *),Bash(lychee *). Pre-allowing them removes the per-call confirmation prompt without expanding the capability surface.
The audit phase is a pure classification — given the allow list +
the adopter’s opt-in families, return forbidden[] (✗) and
missing_recommended[] (⚠). The apply phase is the only writer:
flock-guarded read → mutate → atomic rename.
Install
uv sync --project tools/permission-audit
Stdlib-only runtime; the dev group adds pytest, ruff, mypy.
CLI
permission-audit audit-any [--dir <project-root>] [--families security,issue]
permission-audit audit <settings-path> [--families security,issue]
permission-audit apply <settings-path> [--add <entry>]... [--remove <entry>]... [--create-if-missing]
permission-audit list-known
audit-any (harness-neutral)
Scans <project-root> (default: .) for every known settings file and
audits each with the appropriate classifier — no need to know which
harness is active. Detected harnesses:
| File | Harness |
|---|---|
.claude/settings.json |
Claude Code |
.claude/settings.local.json |
Claude Code (local override) |
opencode.json |
OpenCode |
Returns JSON with one entry per detected file plus a top-level
has_forbidden flag. Exit code 1 when any harness has forbidden
entries, 0 otherwise.
$ permission-audit audit-any --dir /path/to/project
{
"dir": "/path/to/project",
"detected_harnesses": ["claude-code", "opencode"],
"results": [
{
"harness": "claude-code",
"settings_path": "/path/to/project/.claude/settings.json",
"forbidden": [...],
"missing_recommended": [...]
},
{
"harness": "opencode",
"settings_path": "/path/to/project/opencode.json",
"forbidden": [...]
}
],
"has_forbidden": true
}
This is the recommended entry point when driving the audit from a harness that is not Claude Code or OpenCode — wire it as a startup check or CI step without any harness-specific plumbing.
audit
Reads the settings file, classifies its permissions.allow[], and
prints JSON. Returns exit code 1 when any forbidden entry is
present (so a shell caller can gate a downstream action), 0
otherwise. The empty-family bucket ("") is always included in
the recommended check — those entries (Bash(lychee *) today)
apply to every adopter.
$ permission-audit audit .claude/settings.local.json --families security
{
"settings_path": "/repo/.claude/settings.local.json",
"file_exists": true,
"allow_count": 215,
"families": ["security"],
"forbidden": [
{
"severity": "forbidden",
"pattern": "Bash(uv run *)",
"json_pointer": ".permissions.allow[37]",
"family": null
}
],
"missing_recommended": [
{
"severity": "missing-recommended",
"pattern": "mcp__claude_ai_Gmail__list_labels",
"json_pointer": null,
"family": "security"
}
]
}
apply
Atomic add/remove. Concurrent writers (notably the sandbox-
allowlist helper writing sandbox.filesystem.*) serialize via
POSIX fcntl.flock on the target file. Unrelated keys
(extraKnownMarketplaces, hooks, permissions.deny, etc.) are
preserved verbatim.
permission-audit apply .claude/settings.local.json \
--remove 'Bash(uv run *)' \
--remove 'Bash(python3 *)' \
--add 'Bash(lychee *)' \
--add 'mcp__claude_ai_Gmail__get_thread'
Output: JSON describing what changed.
list-known
Dumps the canonical forbidden + recommended-by-family lists for diff-friendly inspection (used by the verify check 8d narrative and by adopters wanting to vendor the same convention).
Canonical lists
See src/permission_audit/audit.py.
Both lists are intentionally narrow:
- Forbidden — only the exact wildcard strings the verify check 8d doc enumerates. Same-category extensions (a wildcard not on the list but with the same capability surface) are the caller’s responsibility to flag — the framework cannot enumerate every variant.
- Recommended — every entry is verified against Claude Code’s
auto-allowed harness exclusions (
READONLY_COMMANDS,GIT_READ_ONLY_COMMANDS,GH_READ_ONLY_COMMANDS, …) so the tool does not propose entries that the harness would never prompt on anyway.
Tests
uv run --project tools/permission-audit pytest
Covers: classification, family-scoping, JSON-pointer numbering on duplicates, atomic add/remove, no-op rewrite skip, malformed JSON detection.
How /magpie-setup verify uses this
The verify check 8d narrative describes the human-facing report; this tool is the engine behind it. The skill calls
uv run --project <framework>/tools/permission-audit \
permission-audit audit <repo>/.claude/settings.local.json \
--families <comma-joined families from the lock>
for each of .claude/settings.json and .claude/settings.local.json,
folds the JSON output into the verify report, and — only on the
--apply-permission-audit flag and after the operator confirms
the exact list of changes — calls permission-audit apply for
each file.