From 6dc6393922592a9d38defce0e779ceb5029c5a1d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:05:40 -0400 Subject: [PATCH 1/5] docs(claude-config): state the scope of a Read deny in the audit permission baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `required-permissions.md` recommended `Read(./.env)` / `Read(./secrets/**)` / `Read(./.claude/settings.local.json)` as secret protection without saying what a `Read` deny reaches, so a reader came away believing the file was protected. Add a "Scope of a Read deny" subsection, verified against current official docs: the rule covers 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 (`cat`, `head`, `tail`, `sed`) — but not an arbitrary subprocess that opens the path itself, which is how an interpreter one-liner reads a denied file with no deny firing. Remedies are ranked rather than listed: the sandbox is the documented OS-level enforcement path and does not run on native Windows; a PreToolUse hook on Bash|PowerShell is a speed bump, not a boundary, because it inspects the same evadable command string; where no OS-level boundary exists, the durable control is keeping the secret out of the session's reach. Enumerating shell readers as `Bash(cat *)` deny globs is named a non-remedy. PowerShell-tool read coverage and the full recognized-command set are flagged unverified rather than asserted. Category B now reports the baseline with that scope in both directions, and `context/procedures.md` stops implying its own `settings.local.json` recipes escape the recommended deny: the safety is in what gets emitted, not what gets opened, and routing around a deny with an interpreter one-liner is prohibited. The pattern table is unchanged — it is the Category B presence check. Refs #1598 Co-Authored-By: Claude Opus 5 (1M context) --- .../claude-config/.claude-plugin/plugin.json | 2 +- plugins/claude-config/CHANGELOG.md | 38 +++++++++++++ plugins/claude-config/skills/audit/SKILL.md | 7 +++ .../skills/audit/context/procedures.md | 10 ++++ .../skills/audit/evals/evals.json | 14 +++++ .../audit/reference/required-permissions.md | 57 +++++++++++++++++++ 6 files changed, 127 insertions(+), 1 deletion(-) diff --git a/plugins/claude-config/.claude-plugin/plugin.json b/plugins/claude-config/.claude-plugin/plugin.json index 567095c2b4..261884a0ca 100644 --- a/plugins/claude-config/.claude-plugin/plugin.json +++ b/plugins/claude-config/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index 6bd2ef8c96..179e43511d 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -3,6 +3,44 @@ 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 keeping the secret out of the session's reach at all. 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". +- **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. + ## [0.13.0] ### Added diff --git a/plugins/claude-config/skills/audit/SKILL.md b/plugins/claude-config/skills/audit/SKILL.md index a5a5be2fc1..3bea186f6a 100644 --- a/plugins/claude-config/skills/audit/SKILL.md +++ b/plugins/claude-config/skills/audit/SKILL.md @@ -205,6 +205,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. diff --git a/plugins/claude-config/skills/audit/context/procedures.md b/plugins/claude-config/skills/audit/context/procedures.md index d1bab28674..d0fbadd4e8 100644 --- a/plugins/claude-config/skills/audit/context/procedures.md +++ b/plugins/claude-config/skills/audit/context/procedures.md @@ -8,6 +8,16 @@ 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 +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. Take the counts +`check-structure.sh` gives you, and where a check genuinely needs more, report it as not inspectable +under the project's own deny rule. See "Scope of a Read deny" in +[reference/required-permissions.md](../reference/required-permissions.md). + ```bash # Key inventory (no values) cat .claude/settings.local.json | tr -d '\r' | jq 'keys' diff --git a/plugins/claude-config/skills/audit/evals/evals.json b/plugins/claude-config/skills/audit/evals/evals.json index 5102e65bea..b6c83f518e 100644 --- a/plugins/claude-config/skills/audit/evals/evals.json +++ b/plugins/claude-config/skills/audit/evals/evals.json @@ -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" + ] } ] } diff --git a/plugins/claude-config/skills/audit/reference/required-permissions.md b/plugins/claude-config/skills/audit/reference/required-permissions.md index 176422a159..46d214efa8 100644 --- a/plugins/claude-config/skills/audit/reference/required-permissions.md +++ b/plugins/claude-config/skills/audit/reference/required-permissions.md @@ -31,6 +31,63 @@ 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 +boundary. The sandbox's default read policy still allows credential files such as `~/.aws/credentials` +and `~/.ssh/` unless they are listed. + +**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 the call before permission rules are +evaluated. 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. The durable control is to put the secret out of the +session's reach — outside the working directory and `additionalDirectories`, in an OS credential +store or a secrets manager, injected at use time rather than sitting in a readable file. 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. From 51a4c079a0fee00a75c4b378bd2dc985df93b01d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:17:59 -0400 Subject: [PATCH 2/5] docs(claude-config): fix two review findings in the Read-deny scope section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 — directory location is not a boundary. The residual-risk paragraph offered "outside the working directory and additionalDirectories" as a place to put a secret, which is the same false confidence this change exists to remove: a subprocess opens absolute paths, so relocation changes nothing about who can read the file. The boundary is the OS principal. Reworded to say so and to give controls that actually hold — credential store or secrets manager with use-time injection, a short-lived credential, or a different principal / container. P2 — reconcile the hook ordering. The new text said a hook exiting 2 blocks before permission rules are evaluated, while "Interaction with hook-based gates" said a deny fires before any PreToolUse hook. Both are true in one direction each: a hook cannot loosen a deny or ask, and a hook exiting 2 can tighten past an allow rule. Both passages now state which direction they mean. Refs #1598 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/claude-config/CHANGELOG.md | 10 +++++- .../audit/reference/required-permissions.md | 34 +++++++++++++------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index 179e43511d..72c449d556 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -20,7 +20,9 @@ All notable changes to the `claude-config` plugin are documented here. Format fo 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 keeping the secret out of the session's reach at all. Enumerating shell readers as `Bash(cat *)` + 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, @@ -40,6 +42,12 @@ All notable changes to the `claude-config` plugin are documented here. Format fo 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. Deny and ask rules are + evaluated regardless of what a hook returns, so a hook cannot loosen them; a hook exiting 2 stops + the call before permission rules are evaluated, so it can tighten past an allow rule. The + consequence for this baseline — a deny entry suppressing a project hook's ask escalation — is + unchanged. ## [0.13.0] diff --git a/plugins/claude-config/skills/audit/reference/required-permissions.md b/plugins/claude-config/skills/audit/reference/required-permissions.md index 46d214efa8..1a00675247 100644 --- a/plugins/claude-config/skills/audit/reference/required-permissions.md +++ b/plugins/claude-config/skills/audit/reference/required-permissions.md @@ -71,15 +71,21 @@ among its preview limitations. On a native-Windows workstation the OS-level reme 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 the call before permission rules are -evaluated. 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. +command string and deny the call, and a hook exiting 2 blocks a call an *allow* rule would otherwise +have permitted. It 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. The durable control is to put the secret out of the -session's reach — outside the working directory and `additionalDirectories`, in an OS credential -store or a secrets manager, injected at use time rather than sitting in a readable file. Keep the -deny rules above; do not report them as proof the file is protected. +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 @@ -123,7 +129,13 @@ 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. A hook cannot loosen a rule: deny and ask rules +are evaluated regardless of what a `PreToolUse` hook returns, so a matching deny blocks the call even +when the hook returned `allow`, and a matching ask still prompts. A hook can tighten one: a hook that +exits 2 stops the call before permission rules are evaluated, so it blocks even where an allow rule +would have let the call through. + +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. From 76c4931ba3941fe838e9b54508600d1a14685f33 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:26:05 -0400 Subject: [PATCH 3/5] docs(claude-config): scope the hook-ordering "regardless" to returned decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconciled ordering paragraph claimed ask rules are evaluated regardless of the hook result and then said exit 2 stops the call before permission rules are evaluated, which gives two answers for a hook that exits 2 while an ask rule also matches. Split the two cases: a returned decision cannot loosen a deny or ask, and exit 2 short-circuits instead of feeding a decision in — it stops the call before permission evaluation, so nothing downstream runs, an otherwise-matching ask rule included. Refs #1598 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/claude-config/CHANGELOG.md | 11 ++++++----- .../audit/reference/required-permissions.md | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index 72c449d556..177d11a28d 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -43,11 +43,12 @@ All notable changes to the `claude-config` plugin are documented here. Format fo 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. Deny and ask rules are - evaluated regardless of what a hook returns, so a hook cannot loosen them; a hook exiting 2 stops - the call before permission rules are evaluated, so it can tighten past an allow rule. The - consequence for this baseline — a deny entry suppressing a project hook's ask escalation — is - unchanged. + 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] diff --git a/plugins/claude-config/skills/audit/reference/required-permissions.md b/plugins/claude-config/skills/audit/reference/required-permissions.md index 1a00675247..7954536132 100644 --- a/plugins/claude-config/skills/audit/reference/required-permissions.md +++ b/plugins/claude-config/skills/audit/reference/required-permissions.md @@ -72,8 +72,8 @@ 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. It 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 +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 @@ -129,11 +129,16 @@ still a finding. ## Interaction with hook-based gates -The ordering runs both ways, so state it precisely. A hook cannot loosen a rule: deny and ask rules -are evaluated regardless of what a `PreToolUse` hook returns, so a matching deny blocks the call even -when the hook returned `allow`, and a matching ask still prompts. A hook can tighten one: a hook that -exits 2 stops the call before permission rules are evaluated, so it blocks even where an allow rule -would have let the call through. +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 From 812b963ecbfca20be70e821cd2a310086c21725e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:39:31 -0400 Subject: [PATCH 4/5] docs(claude-config): qualify the sandbox as a boundary and handle a blocked read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 — the sandbox was ranked as the enforcement path without its escape surfaces, which recreates the assurance this change removes. All four are open at their defaults: allowUnsandboxedCommands lets a failing command be retried outside the sandbox, failIfUnavailable warns and runs unsandboxed, excludedCommands runs listed commands outside and can always be appended to, and filesystem.disabled lifts the denyRead and credentials.files read protections. Tabled, with an enabled-but-default sandbox reported as partial rather than as protection. P2 — procedures.md asserted check-structure.sh still supplies counts, but with the sandbox enabled the baseline Read deny merges into the filesystem boundary and the OS blocks the script and its tr/jq children too. The script previously surfaced that as `Valid JSON: no` and failed the run — a false malformed-config finding. It now separates the two: `Present: yes` / `Readable: no` with a `not inspectable` note, exiting cleanly. SKILL.md Phase 1 and procedures.md both say that is a correct result to record, not a reason to find another reader. New test case, announcing a skip where the platform does not enforce chmod 000. Refs #1598 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/claude-config/CHANGELOG.md | 14 ++++++++ plugins/claude-config/skills/audit/SKILL.md | 5 +++ .../skills/audit/context/procedures.md | 14 +++++--- .../audit/reference/required-permissions.md | 14 ++++++++ .../skills/audit/scripts/check-structure.sh | 22 +++++++++--- .../audit/scripts/check-structure.test.sh | 35 ++++++++++++++++--- 6 files changed, 91 insertions(+), 13 deletions(-) diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index 177d11a28d..707050d67c 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -27,6 +27,20 @@ All notable changes to the `claude-config` plugin are documented here. Format fo 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. diff --git a/plugins/claude-config/skills/audit/SKILL.md b/plugins/claude-config/skills/audit/SKILL.md index 3bea186f6a..df787e07a8 100644 --- a/plugins/claude-config/skills/audit/SKILL.md +++ b/plugins/claude-config/skills/audit/SKILL.md @@ -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 diff --git a/plugins/claude-config/skills/audit/context/procedures.md b/plugins/claude-config/skills/audit/context/procedures.md index d0fbadd4e8..a3179b0ed9 100644 --- a/plugins/claude-config/skills/audit/context/procedures.md +++ b/plugins/claude-config/skills/audit/context/procedures.md @@ -13,10 +13,16 @@ inside a subprocess, which a `Read(...)` deny does not cover — it is safe beca 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. Take the counts -`check-structure.sh` gives you, and where a check genuinely needs more, report it as not inspectable -under the project's own deny rule. See "Scope of a Read deny" in -[reference/required-permissions.md](../reference/required-permissions.md). +(`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) diff --git a/plugins/claude-config/skills/audit/reference/required-permissions.md b/plugins/claude-config/skills/audit/reference/required-permissions.md index 7954536132..e98d5ca1a8 100644 --- a/plugins/claude-config/skills/audit/reference/required-permissions.md +++ b/plugins/claude-config/skills/audit/reference/required-permissions.md @@ -65,6 +65,20 @@ its child processes: `sandbox.filesystem.denyRead`, or `sandbox.credentials.file 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 diff --git a/plugins/claude-config/skills/audit/scripts/check-structure.sh b/plugins/claude-config/skills/audit/scripts/check-structure.sh index 07634da620..65d9307516 100755 --- a/plugins/claude-config/skills/audit/scripts/check-structure.sh +++ b/plugins/claude-config/skills/audit/scripts/check-structure.sh @@ -57,17 +57,29 @@ emit_file_facts() { return 0 fi printf 'Present: yes\n' - if ! tr -d '\r' <"$path" | jq empty 2>/dev/null; then + # A read that fails is NOT invalid JSON. A Read deny rule, a sandbox denyRead + # path, or plain filesystem permissions all make open() fail here, and + # reporting that as malformed config would be a false finding. Distinguish it + # and report the file as not inspectable instead. + local content + if ! content="$(tr -d '\r' <"$path" 2>/dev/null)"; then + printf 'Readable: no\n' + printf 'Valid JSON: n/a\n' + printf 'Top-level keys: n/a\n' + printf 'Note: present but unreadable — not inspectable (deny rule, sandbox denyRead, or filesystem permissions). Not a malformed-config finding.\n' + return 0 + fi + if ! printf '%s' "$content" | jq empty 2>/dev/null; then printf 'Valid JSON: no\n' return 1 fi printf 'Valid JSON: yes\n' local keys - keys="$(tr -d '\r' <"$path" | jq -r 'keys | length')" + keys="$(printf '%s' "$content" | jq -r 'keys | length')" printf 'Top-level keys: %s\n' "$keys" case "$kind" in settings) - tr -d '\r' <"$path" | jq -r ' + printf '%s' "$content" | jq -r ' "Deny count: \((.permissions.deny // []) | length)", "Ask count: \((.permissions.ask // []) | length)", "Allow count: \((.permissions.allow // []) | length)", @@ -77,7 +89,7 @@ emit_file_facts() { ' ;; local) - tr -d '\r' <"$path" | jq -r ' + printf '%s' "$content" | jq -r ' "Env keys: \((.env // {} | keys | length))", "Deny count: \((.permissions.deny // []) | length)", "Ask count: \((.permissions.ask // []) | length)", @@ -86,7 +98,7 @@ emit_file_facts() { ' ;; mcp) - tr -d '\r' <"$path" | jq -r '"MCP servers: \((.mcpServers // {} | keys | length))"' + printf '%s' "$content" | jq -r '"MCP servers: \((.mcpServers // {} | keys | length))"' ;; *) ;; esac diff --git a/plugins/claude-config/skills/audit/scripts/check-structure.test.sh b/plugins/claude-config/skills/audit/scripts/check-structure.test.sh index 6e59a9b812..69ee56ae9b 100755 --- a/plugins/claude-config/skills/audit/scripts/check-structure.test.sh +++ b/plugins/claude-config/skills/audit/scripts/check-structure.test.sh @@ -24,14 +24,14 @@ assert_exit() { } assert_contains() { case "$2" in - *"$3"*) pass "$1" ;; - *) fail "$1" "expected to contain: $3" ;; + *"$3"*) pass "$1" ;; + *) fail "$1" "expected to contain: $3" ;; esac } assert_not_contains() { case "$2" in - *"$3"*) fail "$1" "unexpected substring: $3" ;; - *) pass "$1" ;; + *"$3"*) fail "$1" "unexpected substring: $3" ;; + *) pass "$1" ;; esac } @@ -68,6 +68,33 @@ out=$( assert_exit "case 2: exit 1 on invalid JSON" 1 "$rc" assert_contains "case 2: invalid json reported" "$out" "Valid JSON: no" +# --- Case 4: present-but-unreadable is not reported as invalid JSON ------------- +# A Read deny rule, a sandbox denyRead path, or filesystem permissions all make the +# script's open() fail. That must read as "not inspectable", never as malformed +# config, and must not fail the run. chmod is the only portable way to simulate it; +# where the platform or user does not enforce it (Windows, root), the case is +# announced as skipped rather than silently dropped. +fixture_dir="$TEST_TMPDIR/unreadable" +mkdir -p "$fixture_dir/.claude" +printf '{"env":{"TOKEN":"sk-not-a-real-secret"}}\n' >"$fixture_dir/.claude/settings.local.json" +printf '{"permissions":{"deny":[]}}\n' >"$fixture_dir/.claude/settings.json" +chmod 000 "$fixture_dir/.claude/settings.local.json" 2>/dev/null +if [[ -r "$fixture_dir/.claude/settings.local.json" ]]; then + echo "SKIP: case 4 — this platform/user does not enforce chmod 000 on the fixture" >&2 +else + rc=0 + out=$( + SETTINGS_AUDIT_STRUCTURE_FIXTURE_DIR="$fixture_dir" \ + bash "$SCRIPT" 2>/dev/null + ) || rc=$? + assert_exit "case 4: unreadable file does not fail the run" 0 "$rc" + assert_contains "case 4: reported unreadable" "$out" "Readable: no" + assert_contains "case 4: reported not inspectable" "$out" "not inspectable" + assert_not_contains "case 4: not reported as invalid JSON" "$out" "Valid JSON: no" + assert_not_contains "case 4: no secret leaked" "$out" "sk-" +fi +chmod 600 "$fixture_dir/.claude/settings.local.json" 2>/dev/null + # --- Case 3: missing jq exits 2 ------------------------------------------------- # Run the script under an EMPTY PATH so its `command -v jq` resolves nothing. The # script exits at the jq gate before invoking any external tool, so an empty PATH From 5efde36fa578008e04655386f13c88948dc03adb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:45:48 -0400 Subject: [PATCH 5/5] fix(claude-config): keep settings.local.json contents out of shell variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The readability check introduced in 812b963 assigned the whole file to a shell variable, so `bash -x` or an inherited SHELLOPTS=xtrace would print every credential in settings.local.json — a regression in a script whose contract is never to emit secret values. Replace it with `: <"$path"`, an open() probe that reads no bytes, and restore the original `tr | jq` pipelines. File contents stay inside pipelines, where tracing prints the command and not the data. Refs #1598 Co-Authored-By: Claude Opus 5 (1M context) --- .../skills/audit/scripts/check-structure.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/plugins/claude-config/skills/audit/scripts/check-structure.sh b/plugins/claude-config/skills/audit/scripts/check-structure.sh index 65d9307516..795ad54165 100755 --- a/plugins/claude-config/skills/audit/scripts/check-structure.sh +++ b/plugins/claude-config/skills/audit/scripts/check-structure.sh @@ -61,25 +61,29 @@ emit_file_facts() { # path, or plain filesystem permissions all make open() fail here, and # reporting that as malformed config would be a false finding. Distinguish it # and report the file as not inspectable instead. - local content - if ! content="$(tr -d '\r' <"$path" 2>/dev/null)"; then + # + # `: <"$path"` opens the file and discards it — an open() probe that never + # reads a byte. Deliberately not `content=$(…)`: holding the file in a shell + # variable would put every credential in it into `set -x` trace output. File + # contents stay inside pipelines, where tracing prints the command only. + if ! : <"$path" 2>/dev/null; then printf 'Readable: no\n' printf 'Valid JSON: n/a\n' printf 'Top-level keys: n/a\n' printf 'Note: present but unreadable — not inspectable (deny rule, sandbox denyRead, or filesystem permissions). Not a malformed-config finding.\n' return 0 fi - if ! printf '%s' "$content" | jq empty 2>/dev/null; then + if ! tr -d '\r' <"$path" | jq empty 2>/dev/null; then printf 'Valid JSON: no\n' return 1 fi printf 'Valid JSON: yes\n' local keys - keys="$(printf '%s' "$content" | jq -r 'keys | length')" + keys="$(tr -d '\r' <"$path" | jq -r 'keys | length')" printf 'Top-level keys: %s\n' "$keys" case "$kind" in settings) - printf '%s' "$content" | jq -r ' + tr -d '\r' <"$path" | jq -r ' "Deny count: \((.permissions.deny // []) | length)", "Ask count: \((.permissions.ask // []) | length)", "Allow count: \((.permissions.allow // []) | length)", @@ -89,7 +93,7 @@ emit_file_facts() { ' ;; local) - printf '%s' "$content" | jq -r ' + tr -d '\r' <"$path" | jq -r ' "Env keys: \((.env // {} | keys | length))", "Deny count: \((.permissions.deny // []) | length)", "Ask count: \((.permissions.ask // []) | length)", @@ -98,7 +102,7 @@ emit_file_facts() { ' ;; mcp) - printf '%s' "$content" | jq -r '"MCP servers: \((.mcpServers // {} | keys | length))"' + tr -d '\r' <"$path" | jq -r '"MCP servers: \((.mcpServers // {} | keys | length))"' ;; *) ;; esac