Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 47 additions & 20 deletions .claude/skills/setup-steward/verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,28 +462,55 @@ exclusions (`READONLY_COMMANDS`, `GIT_READ_ONLY_COMMANDS`,
`GH_READ_ONLY_COMMANDS`, etc.) so the framework does not
redundantly propose entries that never prompt anyway.

**Reporting shape:** group findings by file, then by
bucket. For each forbidden entry, print the exact JSON-
pointer-style path (`.permissions.allow[<index>]`) so the
operator can locate it instantly; for each recommended
entry missing, print the suggested string verbatim ready
for paste. **Do not auto-write the files** — the per-
machine `settings.local.json` is the operator's; surface
the proposal and let `/setup-steward verify --apply-
permission-audit` (interactive) or a hand-edit close the
gap. The interactive apply path uses an atomic JSON read
→ mutate → write so concurrent
`/setup-isolated-setup-install` (which also writes to the
same file) does not silently clobber the diff. When the
target file lives at a path the agent's sandbox marks as
`denyWithinAllow` (the per-machine settings files
typically are), the apply path requires the operator to
authorise the sandbox bypass for that single write — it
does not silently skip the file. ⚠ if either file is
**Implementation.** The classification logic and the atomic
edit path are factored out into the
[`tools/permission-audit`](../../../tools/permission-audit/README.md)
CLI; the canonical forbidden + recommended-by-family lists
live in
[`tools/permission-audit/src/permission_audit/audit.py`](../../../tools/permission-audit/src/permission_audit/audit.py).
The skill invokes the CLI once per settings file:

```bash
uv run --project <framework>/tools/permission-audit \
permission-audit audit <repo>/.claude/settings.local.json \
--families <comma-joined families from the lock>
```

The CLI emits structured JSON the skill folds into the verify
report. Exit code `1` from the CLI maps to ✗ on this check.

**Reporting shape:** group findings by file, then by bucket.
For each forbidden entry, print the exact JSON-pointer-style
path (`.permissions.allow[<index>]`) the CLI returned so the
operator can locate it instantly; for each recommended entry
missing, print the suggested string verbatim ready for paste.
**Do not auto-write the files** — the per-machine
`settings.local.json` is the operator's; surface the proposal
and let `/setup-steward verify --apply-permission-audit`
(interactive) or a hand-edit close the gap. The apply path
calls

```bash
uv run --project <framework>/tools/permission-audit \
permission-audit apply <repo>/.claude/settings.local.json \
--add '<entry>' --remove '<entry>' ...
```

which holds a POSIX `fcntl.flock` advisory exclusive lock on
the target file, re-parses under the lock, mutates
`.permissions.allow[]` in place, writes to a sibling temp
file, and `os.replace`s into place — so concurrent
`/setup-isolated-setup-install` (which also writes to the same
file's `sandbox.filesystem.*` arrays) does not silently
clobber the diff. When the target file lives at a path the
agent's sandbox marks as `denyWithinAllow` (the per-machine
settings files typically are), the apply path requires the
operator to authorise the sandbox bypass for that single write
— it does not silently skip the file. ⚠ if either file is
absent (most adopters will have at least
`settings.local.json` after the first
`/setup-isolated-setup-install` pass; absence is a soft
signal not a hard fault).
`/setup-isolated-setup-install` pass; absence is a soft signal
not a hard fault).

**Why we propose, never auto-apply.** The allow-list is
the operator's *capability surface* for the agent in this
Expand Down
31 changes: 31 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,37 @@ repos:
files: ^tools/github-body-field/(src|tests|pyproject\.toml)
pass_filenames: false

# Project-local checks for the permission-audit tool at
# `tools/permission-audit/`. Backs `/setup-steward verify --apply-
# permission-audit` (check 8d) — atomic audit + edit of
# `.claude/settings*.json` permissions.allow[] entries.
- repo: local
hooks:
- id: permission-audit-ruff-check
name: ruff check (permission-audit)
language: system
entry: uv run --directory tools/permission-audit ruff check
files: ^tools/permission-audit/(src|tests|pyproject\.toml)
pass_filenames: false
- id: permission-audit-ruff-format
name: ruff format (permission-audit)
language: system
entry: uv run --directory tools/permission-audit ruff format --check
files: ^tools/permission-audit/(src|tests|pyproject\.toml)
pass_filenames: false
- id: permission-audit-mypy
name: mypy (permission-audit)
language: system
entry: uv run --directory tools/permission-audit mypy
files: ^tools/permission-audit/(src|tests|pyproject\.toml)
pass_filenames: false
- id: permission-audit-pytest
name: pytest (permission-audit)
language: system
entry: uv run --directory tools/permission-audit pytest
files: ^tools/permission-audit/(src|tests|pyproject\.toml)
pass_filenames: false

# Validate `.claude/skills/**`, every `tools/<name>/README.md`, and the
# `docs/labels-and-capabilities.md` taxonomy via the
# `skill-and-tool-validate` CLI. Re-fires on validator-source changes so
Expand Down
1 change: 1 addition & 0 deletions docs/labels-and-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ Tools under [`tools/`](../tools/). Tools with two values (separated by
| [`tools/mail-archive`](../tools/mail-archive/) | `capability:setup` | Adapter contract for public mail-archive backends (PonyMail, Hyperkitty, Discourse, Google Groups, GitHub Discussions). Pure interface spec. |
| [`tools/mail-source`](../tools/mail-source/) | `capability:setup` + `capability:intake` | Mail-source backend abstraction (mbox / IMAP / Mailman 3); the abstraction is setup, every concrete read is part of the intake pipeline |
| [`tools/ponymail`](../tools/ponymail/) | `capability:setup` + `capability:intake` | PonyMail archive substrate; same dual role as `mail-source` — substrate plus an intake-pipeline component |
| [`tools/permission-audit`](../tools/permission-audit/) | `capability:setup` | Audit + atomically edit Claude Code `permissions.allow[]` entries; backs `/setup-steward verify --apply-permission-audit` (check 8d) |
| [`tools/pr-management-stats`](../tools/pr-management-stats/) | `capability:stats` | PR-backlog analytics engine |
| [`tools/privacy-llm`](../tools/privacy-llm/) | `capability:setup` | Privacy-LLM PII-scrubbing gate |
| [`tools/probe-templates`](../tools/probe-templates/) | `capability:setup` | Sandbox-doctor probe templates |
Expand Down
170 changes: 170 additions & 0 deletions tools/permission-audit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*

- [permission-audit](#permission-audit)
- [Why](#why)
- [Install](#install)
- [CLI](#cli)
- [`audit`](#audit)
- [`apply`](#apply)
- [`list-known`](#list-known)
- [Canonical lists](#canonical-lists)
- [Tests](#tests)
- [How `/setup-steward verify` uses this](#how-setup-steward-verify-uses-this)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

<!-- SPDX-License-Identifier: Apache-2.0
https://www.apache.org/licenses/LICENSE-2.0 -->

# permission-audit

**Capability:** capability:setup

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
[`/setup-steward verify`](../../.claude/skills/setup-steward/verify.md#8d-permission-allow-list-hygiene)
(check 8d), and is also directly usable as a CLI.

## 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 `security` family'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

```bash
uv sync --project tools/permission-audit
```

Stdlib-only runtime; the dev group adds `pytest`, `ruff`, `mypy`.

## CLI

```bash
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`

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.

```bash
$ 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.

```bash
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`](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

```bash
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 `/setup-steward verify` uses this

The verify check 8d narrative describes the human-facing report;
this tool is the engine behind it. The skill calls

```bash
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.
77 changes: 77 additions & 0 deletions tools/permission-audit/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "permission-audit"
version = "0.1.0"
description = "Audit + atomically edit Claude Code permissions.allow[] entries in .claude/settings*.json."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Apache-2.0" }
# Stdlib-only — fast `uv run` resolution + no surprises on a fresh install.
dependencies = []

[project.scripts]
permission-audit = "permission_audit:main"

[dependency-groups]
dev = [
"mypy>=2.1.0",
"pytest>=8.0",
"ruff>=0.15.14",
]

[tool.hatch.build.targets.wheel]
packages = ["src/permission_audit"]

[tool.ruff]
line-length = 110
target-version = "py311"
src = ["src", "tests"]

[tool.ruff.lint]
select = [
"E", "W", "F", "I", "B", "UP", "SIM", "C4", "RUF",
]
ignore = ["E501"]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["B", "SIM"]

[tool.mypy]
python_version = "3.11"
files = ["src", "tests"]
warn_unused_ignores = true
warn_redundant_casts = true
warn_unreachable = true
check_untyped_defs = true
no_implicit_optional = true
disallow_untyped_defs = true
disallow_incomplete_defs = true

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
disallow_incomplete_defs = false

[tool.pytest.ini_options]
minversion = "8.0"
addopts = "-ra -q"
testpaths = ["tests"]
Loading
Loading