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
2 changes: 1 addition & 1 deletion plugins/claude-config/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "claude-config",
"version": "0.13.0",
"version": "0.14.0",
"description": "Five audit skills for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-instructions (locally-owned instruction surfaces vs current model capability — proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), and audit-pass (one coordinated, ordered, resumable pass over a named target — three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate — delegating every check to the plugin that owns it).",
"author": {
"name": "Melodic Software",
Expand Down
61 changes: 61 additions & 0 deletions plugins/claude-config/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,67 @@
All notable changes to the `claude-config` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.14.0]

### Added

- **"Scope of a Read deny" in `audit`'s `reference/required-permissions.md`.** The
`sensitive-file-deny` table recommended `Read(./.env)` / `Read(./secrets/**)` /
`Read(./.claude/settings.local.json)` with no statement of what a `Read` deny actually reaches, so a
reader came away believing the file was protected. The new subsection splits covered from not
covered against current official docs: the rule reaches the built-in file tools (Read, Grep, Glob,
LSP), `@file` mentions, IDE selection context, Edit on the same path, **and the file commands Claude
Code recognizes inside a Bash command such as `cat`, `head`, `tail`, and `sed`** — but *not* an
arbitrary subprocess that opens the path itself, which is how a `python -c` or `node -e` one-liner
reads a denied file with no deny firing. Remedies are ranked rather than listed: the sandbox
(`sandbox.filesystem.denyRead`, `sandbox.credentials.files` with `"mode": "deny"`) is the documented
OS-level enforcement path, carrying the platform limit that it does not run on native Windows; a
`PreToolUse` hook on `Bash|PowerShell` is explicitly a speed bump, not a boundary, because it
inspects the same evadable command string; and where no OS-level boundary exists the durable control
is that the secret is not in a file the session's OS principal can read at all — directory location
is explicitly named as *not* a boundary, since a subprocess opens absolute paths and relocation
changes nothing about who can read the file. Enumerating shell readers as `Bash(cat *)`
deny globs is named as a non-remedy, since upstream documents argument-constraining Bash patterns as
fragile. Two facts are flagged unverified rather than asserted: whether PowerShell-tool reads
(`Get-Content`, `type`) are covered at all, and the full membership of the recognized-command set,
which upstream gives with "such as".
- **The sandbox's four escape surfaces, tabled alongside the recommendation.** `sandbox.enabled: true`
on its own is not a boundary: `allowUnsandboxedCommands` lets a failing command be retried outside
it, `failIfUnavailable` defaults to warning and running unsandboxed, `excludedCommands` runs listed
commands outside and can always be appended to, and `filesystem.disabled` lifts the `denyRead` and
`credentials.files` read protections outright. All four are open at their defaults, so an
enabled-but-default sandbox is reported as partial — recommending it without them would repeat the
defect this release fixes.
- **`check-structure.sh` now separates unreadable from malformed.** A `Read` deny merged into a
sandbox boundary, or plain filesystem permissions, makes the script's `open()` fail; it previously
surfaced as `Valid JSON: no` and failed the run, i.e. a false malformed-config finding. The script
now reports `Present: yes` / `Readable: no` with a `not inspectable` note and exits cleanly, and
both `SKILL.md` Phase 1 and `context/procedures.md` say that is a correct result to record rather
than a reason to find another reader. Covered by a new test case that announces a skip where the
platform does not enforce `chmod 000`.
- **Eval 7 on the `audit` skill (`read-deny-scope-not-overstated`).** Asks whether present deny
patterns mean the secrets are protected; expects the scope split, the ranked remedies, and no
`Bash(cat *)` enumeration.

### Changed

- **Category B now reports the secret-file Read denies with their scope.** `SKILL.md`'s "Required
permission patterns" section routes the finding write-up through the new subsection, in both
directions — a present baseline is not reported as proof the file is unreachable.
- **`context/procedures.md` no longer implies its own `settings.local.json` recipes escape the
baseline deny.** It now states that the safety is in what gets emitted, not what gets opened:
`check-structure.sh` reads the file from a subprocess and is safe because it emits counts only,
while the supplemental `cat … | jq` recipes are blocked in a project carrying the recommended deny —
correctly so. Routing around that block with an interpreter one-liner is prohibited; the audit
reports the file as not inspectable under the project's own rule instead.
- **"Interaction with hook-based gates" now states the ordering in both directions.** "A deny rule
fires before any `PreToolUse` hook" was true only of the loosening direction, and the two hook cases
are now kept apart. A *returned decision* cannot loosen a rule: deny and ask rules are evaluated
regardless of which decision the hook returns. *Exit 2* short-circuits instead: it stops the call
before permission rules are evaluated at all, so it blocks past an allow rule and nothing downstream
runs, including an otherwise-matching ask rule. The consequence for this baseline — a deny entry
suppressing a project hook's ask escalation — is unchanged.

## [0.13.0]

### Added
Expand Down
12 changes: 12 additions & 0 deletions plugins/claude-config/skills/audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ Even when no deny rule blocks it, treat `settings.local.json` as secret-bearing:
validity, never dump its contents. Supplemental jq recipes: [context/procedures.md](context/procedures.md)
"Reading settings.local.json safely".

The counts are not guaranteed. Where the project's configuration blocks the read — a sandbox
`denyRead` merged from the baseline `Read` deny, or filesystem permissions — the script reports
`Readable: no` and a `not inspectable` note instead of failing. Record the file as not inspectable,
carry that into the report, and do not reach for another reader to get the counts anyway.

---

## Track progress
Expand Down Expand Up @@ -205,6 +210,13 @@ concrete list is in [reference/required-permissions.md](reference/required-permi
with a stricter posture declare their additional required patterns in their own rules files — when the
consuming repo documents such a list, include it in the Category B check.

Report the secret-file Read denies with their scope, not as protection: a `Read(...)` deny covers the
built-in file tools and the Bash file commands Claude Code recognizes, but not a subprocess that opens
the path itself. "Scope of a Read deny" in
[reference/required-permissions.md](reference/required-permissions.md) carries the covered /
not-covered split, the ranked remedies, and the platform limit — carry it into the finding rather than
implying the file is unreachable.

CC settings schema, MCP server shape, hook event names, and permission glob syntax are upstream
invariants documented at [code.claude.com/docs/en/settings](https://code.claude.com/docs/en/settings)
and audited via the Phase 3 live doc fetch rather than asserted as fixed patterns here.
Expand Down
16 changes: 16 additions & 0 deletions plugins/claude-config/skills/audit/context/procedures.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ secrets (Phase 1), and which findings the skill may auto-fix vs which need judgm
Treat the file as secret-bearing regardless of deny rules. Run `check-structure.sh` first;
supplemental jq: below.

The safety here is *what gets emitted*, not what gets opened. `check-structure.sh` opens the file from
inside a subprocess, which a `Read(...)` deny does not cover — it is safe because it emits counts and
Comment thread
kyle-sexton marked this conversation as resolved.
never values. The `cat … | jq` recipes below go the other way: `cat` is a file command Claude Code
recognizes in Bash, so a project carrying the baseline `Read(./.claude/settings.local.json)` deny will
block them. That is the correct outcome — do not route around it with an interpreter one-liner
(`python -c`, `node -e`) to dump content the sanctioned script will not emit. See "Scope of a Read
deny" in [reference/required-permissions.md](../reference/required-permissions.md).

**Counts are not guaranteed.** Where the project also enables the sandbox, the baseline `Read` deny
merges into the sandbox filesystem boundary and the OS blocks `check-structure.sh` and its `tr`/`jq`
children too — the subprocess route closes. The script distinguishes that case: it reports
`Readable: no` with a `not inspectable` note rather than `Valid JSON: no`, and does not fail the run.
Treat that output as the answer. Record the file as not inspectable under the project's own
configuration, carry that into the report, and do not escalate to another reader to get the counts
anyway. A `Present: yes` / `Readable: no` pair is a correct result, not a broken audit.

```bash
# Key inventory (no values)
cat .claude/settings.local.json | tr -d '\r' | jq 'keys'
Expand Down
14 changes: 14 additions & 0 deletions plugins/claude-config/skills/audit/evals/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,20 @@
"Treats an orphan plugin set to true (absent upstream) as report-only, never auto-removed",
"Records a newly-discovered upstream plugin as an explicit enabledPlugins false entry rather than silently enabling it"
]
},
{
"id": 7,
"name": "read-deny-scope-not-overstated",
"prompt": "/audit permissions — our settings.json already has Read(./.env) and Read(./secrets/**) in permissions.deny, so are our secrets protected?",
"expected_output": "A Category B report that confirms the baseline sensitive-file-deny patterns are present AND states the scope of a Read deny rather than answering yes: it covers the built-in file tools and the Bash file commands Claude Code recognizes (cat, head, tail, sed), but not a subprocess that opens the path itself (a python -c or node -e one-liner). It ranks the remedies — sandbox filesystem.denyRead / credentials.files for OS-level enforcement, noting the sandbox does not run on native Windows; a PreToolUse hook as a best-effort speed bump; and keeping the secret out of the session's reach as the durable control — and does not propose enumerating Bash(cat *)-style deny globs.",
"files": [],
"expectations": [
"Does NOT answer that the secrets are protected simply because the deny patterns are present",
"States that a Read deny covers the built-in file tools and recognized Bash file commands but not a subprocess that opens the file itself",
"Does NOT propose enumerating shell readers as Bash(...) deny globs as the remedy",
"Names the sandbox (filesystem.denyRead or credentials.files) as the OS-level enforcement path and notes it is unavailable on native Windows",
"Describes a PreToolUse hook as best-effort rather than as a boundary"
]
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,83 @@ tree.
| `Read(**/*.pem)` | Block reading PEM certificate/key files anywhere in tree |
| `Read(**/id_rsa)` | Block reading SSH private keys |

### Scope of a Read deny — what it covers, and what it does not

These entries are a guardrail against routine access, not a containment boundary. Category B checks
that the rules are present; presence is not evidence the file is unreachable. Say so whenever the
category is reported, in either direction. Verified 2026-07-26 against
[permissions](https://code.claude.com/docs/en/permissions#read-and-edit),
[sandboxing](https://code.claude.com/docs/en/sandboxing), and
[tools reference](https://code.claude.com/docs/en/tools-reference#powershell-tool).

**Covered.** A `Read(...)` deny applies to the built-in file tools (Read, Grep, Glob, LSP), to
`@file` mentions in a prompt, to the selection and open-file context a connected IDE shares, to the
Edit tool on the same path (CC v2.1.208+), and — per the permissions page — to *file commands Claude
Code recognizes inside a Bash command, such as `cat`, `head`, `tail`, and `sed`*. The obvious
"`cat` it instead" fallback is therefore blocked.

**Not covered.** The same page: the rules "don't apply to arbitrary subprocesses that read or write
files indirectly, like a Python or Node script that opens files itself." A `python -c`, a `node -e`,
or any script that opens the path reads a `Read`-denied file with no deny firing. That is the real
gap, and it is reached *routinely* — an agent blocked on `Read` reaches for an interpreter one-liner
as an ordinary next step, not as an attack. This plugin's own `scripts/check-structure.sh` is an
instance: it opens `settings.local.json` from inside a subprocess, and its safety comes from emitting
only counts, never from the deny rule.

**Do not try to close the gap with `Bash(...)` deny globs.** The permissions page warns that "Bash
permission patterns that try to constrain command arguments are fragile", and the set of programs
that can open a file is unbounded. Enumerating readers relocates the false confidence instead of
removing it. Never propose a `Bash(cat *)`-style enumeration as the remedy here.

**The documented enforcement path is the sandbox**, which the OS enforces on every Bash command and
its child processes: `sandbox.filesystem.denyRead`, or `sandbox.credentials.files` entries with
`"mode": "deny"`. Read/Edit deny rules and `sandbox.filesystem` paths merge into the final sandbox
Comment thread
kyle-sexton marked this conversation as resolved.
boundary. The sandbox's default read policy still allows credential files such as `~/.aws/credentials`
and `~/.ssh/` unless they are listed.

**`sandbox.enabled: true` alone is not a boundary — check the escape surfaces before calling it one.**
Upstream documents four, all open at their defaults, and each puts a subprocess back outside the OS
boundary where it can read the denied path:

| Setting | Why it matters | What a boundary requires |
| --- | --- | --- |
| `allowUnsandboxedCommands` | A command that fails under the sandbox may be retried with `dangerouslyDisableSandbox`, which runs it outside | set to `false` |
| `failIfUnavailable` | A missing dependency or an unsupported platform warns and then runs commands unsandboxed | set to `true` |
| `excludedCommands` | Anything listed runs outside the sandbox, and upstream notes a developer can always append entries | kept narrow, and reviewed |
| `filesystem.disabled` | Turning the filesystem layer off lifts the `denyRead` and `credentials.files` read protections entirely | not set |

Report an enabled-but-default sandbox as partial, not as protection. Recommending it without these is
the same defect as recommending the deny globs without their scope.

**Platform limit — check before recommending it.** The sandbox runs on macOS, Linux, and WSL2; native
Windows is not supported, and the PowerShell tool lists "On Windows, sandboxing is not supported"
among its preview limitations. On a native-Windows workstation the OS-level remedy is unavailable, so
do not offer it there as the fix.

**A `PreToolUse` hook on `Bash|PowerShell` is a speed bump, not a boundary.** It can inspect the
command string and deny the call, and a hook exiting 2 blocks a call an *allow* rule would otherwise
have permitted. A decision it returns cannot loosen a deny — see "Interaction with hook-based gates"
below for the precise ordering. But it inspects that same command string, so it inherits the evasion surface of a
Bash deny glob. Rank it below the sandbox and never describe it as protection.

**Residual risk, stated plainly.** Where no OS-level boundary is available, a deny glob cannot keep a
secret from a session that has shell execution. **Directory location is not a boundary**: a
subprocess opens absolute paths, so moving the file outside the working directory and
`additionalDirectories` changes nothing about who can read it — never present relocation as
protection. The boundary that holds is the OS principal. A file readable by the account the session
runs as is reachable, wherever it sits. So the durable control is that the secret is not sitting in a
file that account can read at all: keep it in an OS credential store or a secrets manager and inject
it at use time, scope it to a short-lived credential whose theft expires, or run the session as a
different principal or inside a container that never receives it. Keep the deny rules above; do not
report them as proof the file is protected.

**Unverified — flag it rather than asserting either way.** No fetched page states whether reads
through the **PowerShell tool** (`Get-Content`, `type`) are covered: the permissions page scopes the
recognized-command coverage to commands "in Bash", and the tools reference lists `Read(...)` as
applying to "Read, Grep, Glob, LSP". Treat PowerShell reads as uncovered until upstream says
otherwise. The recognized-command list is also introduced with "such as" and is not exhaustive, so
`grep`, `jq`, `strings`, and shell redirects are unconfirmed in both directions.

## destructive-bash-deny (Bash deny)

Bash deny patterns for destructive git operations — the universal baseline.
Expand Down Expand Up @@ -66,7 +143,18 @@ still a finding.

## Interaction with hook-based gates

A deny rule fires before any PreToolUse hook. When a project escalates an operation to a permission
prompt via its own safety hook (e.g. a git-safety hook that turns `git branch -D` into an ask), adding
a deny entry for the same pattern would suppress that prompt — audit such patterns against the
project's own documented hook conventions rather than flagging their absence here.
The ordering runs both ways, so state it precisely, and keep the two cases apart — a hook that
*returns a decision* is not a hook that *exits 2*.

- **A returned decision cannot loosen a rule.** Deny and ask rules are evaluated regardless of which
decision a `PreToolUse` hook returns, so a matching deny blocks the call even when the hook returned
`allow`, and a matching ask still prompts.
- **Exit 2 short-circuits instead of feeding in a decision.** A hook that exits 2 stops the tool call
before permission rules are evaluated at all, so it blocks where an allow rule would have let the
call through — and nothing downstream runs, including an otherwise-matching ask rule, which never
gets to prompt. The bullet above describes returned decisions only; it does not apply here.

The consequence for this baseline is the first direction. When a project escalates an operation to a
permission prompt via its own safety hook (e.g. a git-safety hook that turns `git branch -D` into an
ask), adding a deny entry for the same pattern suppresses that prompt — audit such patterns against
the project's own documented hook conventions rather than flagging their absence here.
Loading