Skip to content

feat(claude-config): complete the permission-state skill and add the auto-mode authoring lane - #2396

Merged
kyle-sexton merged 37 commits into
mainfrom
feat/audit-permission-state
Aug 12, 2026
Merged

feat(claude-config): complete the permission-state skill and add the auto-mode authoring lane#2396
kyle-sexton merged 37 commits into
mainfrom
feat/audit-permission-state

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

PR #2260 landed the walking skeleton and the merge stage. This is the rest: four more analysis
stages, the second skill, and thirteen defect fixes from three rounds of independent verification.

What this adds on top of #2260

  • The auto-mode entry diff — every effective allow rule classified as dropped (blanket,
    wildcarded interpreter, package-manager run, or Agent) or kept, from the same shared pattern
    vocabulary audit-permission-grants check P1 scans with. Reads autoMode.classifyAllShell, which
    inverts the answer wholesale, and resolves it only from the scopes the classifier actually reads.
  • Permission-plane lints — nine checks for configuration written where nothing reads it. The
    highest-consequence one: disableAutoMode typed as a boolean instead of the string "disable" is
    valid JSON, accepted, and does nothing — so the operator believes auto mode is locked out and it is
    not.
  • The autoMode classifier block$defaults omissions (which replace the built-in list,
    not extend it), contradictions, and entries an earlier hard_deny forecloses. claude auto-mode critique is surfaced and wrapped, never reimplemented.
  • Managed conformance — which managed intents are enforced versus loosenable. A managed
    autoMode section is additive, not a policy boundary; and a lower-scope deny beats a managed
    allow without overriding it, which the precedence table alone does not suggest.
  • claude-config:draft-auto-mode-rules — the authoring counterpart. Interviews, prints a
    paste-ready block, and writes nothing, in any scope, under any flag.
  • ADR 0009 and the contract-slice prune; the allowed-tools trust measurement carried into
    MIGRATION-PLAYBOOK.md's plugin-acceptance security review.

Independent verification

Three rounds, fresh context, rationale withheld. Thirteen defects, all reproduced before being fixed.
The ones worth naming:

  • A bare Bash allow was reported as surviving auto mode. It is the whole-tool form — broader
    than Bash(*), which the same run drops. Agent's bare form was handled; the shell tools' was not.
  • --merge-only silently inverted the entry diff. Two documented flags composing into a wrong
    answer: conf records were dropped, so a suspended rule was reported kept.
  • A carriage return inside a rule was silently deleted, turning Bash(a\rb *) into Bash(ab *)
    a rule in no settings file, flowing downstream as a real grant. The newline fix from the previous
    round had covered LF only.
  • C6-winPath was dead in the real pipeline — it tested the JSON source spelling that jq -r
    decodes away, and passed its own suite only because those fixtures bypassed the reader.
  • A corrupt managed policy was reported as absence of policy — the worst answer that report can
    give an administrator.
  • Parameter matching is deny/ask only. Found by re-reading the page to settle a verifier
    challenge: "allow rules continue to use each tool's own specifier syntax", so
    allow: ["Agent(model:opus)"] is not a narrowed grant. New check C6-allowParam.

Two findings were about my own claims rather than the code: a comment asserting as measured
something never measured, and a comment describing a design that had been replaced.

Verification

Suites: reader 50, merge 51, entry diff 63, plane lint 50, block lint 62, conformance 32, drafter 39,
grants 76, managed-scope 16, permission-patterns 12, no-writes 12.

Criterion 9 (writes nothing, any scope, any flag) is proven by running all ten actions of both
skills with the oracle explicitly ON
and diffing a fixture tree and fixture HOME either side — not
by reading the code.

shellcheck -x, portability, drift, contract-slice prune, changelog parity and validate-plugins
all clean. Listing budget 6737/8000.

Note on the merge resolution

Resolving this branch against post-#2260 main produced eighteen conflicts. Taking --ours was right
for the files this branch advanced and wrong for three it did not — most importantly
permission-rule-check.sh, where main had removed a $PWD scan-root fallback that could sweep
the operator's home directory. The drift gate caught one; checking the rest of the --ours set found
the other two. All three restored from main; see f8abbafa.

Related

No linked issue — this continues the research and planning thread that produced #2260.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr

kyle-sexton and others added 26 commits August 10, 2026 01:12
Records the contract and implementation plan for two new claude-config
skills: audit-permission-state, which computes the effective merged
permission state across all five settings scopes with per-rule
provenance, and draft-auto-mode-rules, the non-writing authoring lane.

The plan is ten phases, sequential. Phase 0 discharges the fresh-docs
mandate before any version-gate constant ships. Phase 9 moved ahead of
the skill work because it owns two seams that do not exist yet: a
fixture-home override so user-global scope is testable without reading
the real home, and a side-effect-free extraction of the shared pattern
vocabulary, which today lives in a script that self-executes at load.

Two probes settled questions the Brief had parked. The debug channel
narrates the permission merge per destination and names every allow rule
auto mode drops, with its source path and reason; it becomes an opt-in,
explicitly priced oracle cross-checked against the computed prediction,
not a replacement for it. A non-strict JSON parser is genuinely required
for one optional lane: the malformed CLI output carries a raw line feed
inside a string value, so no line-oriented filter repairs it, which puts
Python in the optional-feature class and leaves the portable core intact.

A fresh-context reviewer returned nineteen findings against an earlier
draft. All nineteen were re-verified against the repository and all held.
Both critical ones were stale premises: PR #2089 had already merged with
its OFFICIAL-DOCS row, the frontmatter-name refactor had landed on main,
and this slice was untracked on a spent branch and absent from main.

Two items are left open rather than guessed: which second existing check
the Brief meant by widening two, and what a -p oracle session writes
outside its scratch path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
Phase 0 re-confirms every upstream fact the two planned skills would ship,
against the auto-mode-config, settings, permission-modes, and permissions
pages fetched today, rather than the single local capture the Brief was
resting on.

Eleven facts are confirmed with quoted wording, including the scope
precedence, the deny-then-ask-then-allow evaluation order, the four
classes of allow rule auto mode drops on entry, and that permission rules
from a pre-v2.1.211 start-directory settings.local.json stay in effect.
The absence of allowManagedAutoModeRulesOnly is now supported by the
governing page rather than by an unverified research slice.

Five corrections land against the plan. The largest: managed policy is
not two JSON files. It is a plist domain on macOS, a registry hive on
Windows, a JSON file, and a managed-settings.d drop-in directory, per OS,
so the managed read cannot be jq over a fixed pair of paths. The local
settings file also resolves through worktrees to the main checkout, which
this topic is itself being planned inside. And autoMode.classifyAllShell
suspends every shell allow rule when set, inverting the drop-set answer
that no criterion currently covers.

Three claims move to not-stated and must not ship as fact: the v2.1.142
gate on project-scope defaultMode, the scope restriction on
useAutoModeDuringPlan, and the Write(path) never-consulted mechanic,
which the page describes differently and with a startup warning as its
observable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
Phase 1's managed-policy reader is split into a portable core (the per-OS
managed-settings.json and its managed-settings.d/ drop-in directory, always
read) and two declared optional platform legs (the Windows registry policy
keys and the macOS managed-preferences domain) that warn visibly and skip
rather than failing the run. A Windows reader that checks only the Program
Files JSON reports "no managed policy deployed" while a registry-deployed
policy is in force, so dropping the registry buys a wrong finding rather
than a documented gap. An unelevated reg query of HKLM\SOFTWARE\Policies
was measured to succeed, so the leg costs no elevation.

The Brief's long-unassigned second scope-widening is named: claude-config's
audit skill gains the start-directory settings.local.json copy, whose
permission rules stay in effect and which nothing audits today.

The authoring lane drops its unnamed history input and drafts from the
interview plus the computed merge, removing an unreviewable read surface
and a second dependency on the priced debug-channel oracle.

Phase 9 absorbs the shared managed-scope path enumeration, since approving
the managed reader would otherwise make this repository's third copy of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…ared vocabulary

Check P1 scanned project and local settings only, so an interpreter-wildcard
rule in the user-global settings file was invisible to it — and that is the
scope Claude Code's own "Always allow" path writes to, so it is where the
broad rules auto mode drops actually accumulate. The user scope resolves
CLAUDE_CONFIG_DIR before $HOME, matching the resolver claude-memory already
cites to the .claude-directory doc; that resolution is also the test seam, so
a fixture home replaces the operator's real one and no test reads it.

The audit skill's structure check gains the pre-v2.1.211 start-directory
settings.local.json, whose permission rules stay in effect alongside the
repository-root copy. The row appears only where the two directories genuinely
differ, so one file is never counted as two rule sources.

Two libraries come out of the widening. The auto-mode drop vocabulary was
inline in a detector that self-executes and cannot be sourced, leaving a
second consumer no way to reuse it; the per-OS managed-policy locations were
hand-kept in three places that had already disagreed about whether the
drop-in directory exists. claude-memory carries a byte-identical copy of the
managed-scope library, registered as a cross-plugin cluster so the two cannot
drift — verified by perturbing a copy and watching the check fail.

Both file-only readers now name the managed surfaces they do not read (the
Windows policy registry keys, the macOS preferences domain) so an absent
managed-settings.json cannot read as "no managed policy deployed".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…ipping it

With neither CLAUDE_CONFIG_DIR nor HOME set there is no user-global settings
file to resolve, and the detector scanned nothing without saying so — which
would let "No fragile permission grants found." rest on a scope that was
never opened. It now writes a named notice to stderr, with a regression test
covering it.

The plugin README carries the widened scope and the fact that a user-global
finding's remediation is the operator's, and the plan records the two review
findings this pass closed plus the P2 / standards-managed machine-path
overlap it deliberately left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…ch holds

Claude Code exposes no way to see the permission rules actually in effect —
no `claude permissions` subcommand, no machine-readable export — so the
honest answer to "where is this rule coming from" has been to read five
files in five places and hope you knew all five. This reader finds them:
managed policy, user-global, project, local, and any pre-v2.1.211 copy left
in the session's start directory, each scope's allow/ask/deny rules
attributed to the surface they came from.

The status vocabulary is the substance. `absent` means looked and found
nothing; `skipped` means could not look. Every scope and every managed
surface emits a record on every OS, including the ones that do not apply
here, so a surface that was never attempted can never be read as one that is
genuinely empty. Server-managed settings arrive remotely and have no local
path at all, which the reader discloses rather than letting silence imply
completeness.

Managed policy is four surfaces per OS, not one file. The JSON file and its
drop-in directory are the portable core, read everywhere and merged in the
documented order; the Windows policy registry keys and the macOS
managed-preferences domain are optional platform integrations that announce
a skip and leave the core result intact.

Exercising it under a stub PATH caught two defects that each produced a
confident wrong answer. MSYS rewrites any argument containing backslashes as
a POSIX path, so every registry query died with "Invalid syntax" and scored
as no policy deployed. And with plugin-root resolution broken, the shared
library failed to source while the run still exited 0, reporting every
managed surface absent; that path now exits 2, in this reader and in the
audit skill's structure check, which had the same fall-through.

The registry surface is verified against the real registry via a scratch key
outside the policy tree, created and deleted within the check, so no policy
was ever deployed to the machine. The macOS domain and the Linux paths are
an honest manual-verification gap, stated in the skill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…that exists

A stray admin-level key could make user-level policy look like the managed
policy. The search stopped at the first key whose Settings value parsed, so
an HKLM key with a missing or malformed value fell through to HKCU — which
the settings doc calls lowest policy priority, used only when no admin-level
source exists. Reporting it while an admin-level key is in force is the
precise failure that rule exists to prevent.

`reg query <key> /v Settings` cannot distinguish a missing key from a
present key with no such value: both return exit 1 and the same message.
A bare `reg query <key>` does distinguish them, so key existence is now the
probe that ends the search, and an existing key that yields nothing readable
is reported unreadable with a note rather than as permission to consult the
next key.

The regression test uses HKCU\SOFTWARE, which exists on every Windows
install and carries no Settings value, so it covers the case read-only and
writes nothing to the registry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…ule's mechanic named

audit-permission-state could say what every scope holds but not what any of it
means together. This adds the merge: one effective allow/ask/deny set, each rule
naming every scope that contributes it and the documented mechanic that put it
there.

The two governing doc sections were re-fetched before any code was written, and
they overturned the planned model. Permission rules "merge across scopes rather
than override", so a rule written at two scopes has no winner and electing one
would have been a precedence claim no page supports. What a rule can lose is its
kind: deny is evaluated before ask and ask before allow, from any scope, in both
directions -- a user-level deny blocks a project-level allow just as a
project-level deny blocks a user-level allow. That is the only election made
here, and a scope-ranked implementation would get the low-scope-deny case
exactly backwards. The beaten entry ships as an `inert` record naming what beat
it, which is the answer to "why is my allow rule ignored".

Every run states the two bounds on the claim. The command-line scope
(--settings, --allowedTools, --disallowedTools) outranks the files and has no
file to read. Rules are compared by exact text, so a narrow allow blocked only
by a broader deny pattern is still reported effective -- the docs state the
broad deny wins, so the error direction is known: it over-reports allow and
never over-reports blocking.

An empty merge cannot mean an empty machine. A pipeline that swallowed the
reader's exit 2 would report "nothing in effect" on a machine full of rules, so
zero scope records on input is exit 2 before any merge line is emitted.

The skill also stops overclaiming against `/permissions`, which the permissions
page documents as listing every rule and the file it came from. The Purpose now
points readers there for "where is this rule written" and claims only the
difference: outcome resolution, telling a scope that was empty from one that
could not be read, and an output another tool can consume.

reference/criteria.md finally has content that is not a restatement -- the
precedence_basis vocabulary, each token mapped to the sentence it follows from.

40 new checks, the reader's 42 still pass, shellcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…eny removed

Exact-text comparison treated `deny: ["Bash"]` and `allow: ["Bash(git status)"]`
as two unrelated rules and reported both as effective. The permissions page says
the bare name "removes the tool from Claude's context entirely, so Claude never
sees it" -- so the report claimed prompt-free access to a tool the model cannot
see, in managed policy, which is exactly where whole-tool denies get written.

Not a case for the exact-text caveat. That caveat covers pattern subsumption,
which needs a matcher; the tool token is the text before the first `(` and
"contains no `(`" is the whole test, so this was decidable and therefore a
defect. A whole-tool deny now makes every rule naming that tool inert -- other
denies included, moot rather than weakened -- and a whole-tool ask outranks
every scoped allow for its tool, since it matches every call and ask is
evaluated first. `EndConversation` is exempt from removal per its documented
carve-out. Both cases print a NOTE naming the tool rather than leaving a reader
to infer it from a run of inert records.

Found by fresh-context review after the merge had already passed 40 checks and
its own author had signed off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
Version conflicts only, plus one comment hunk. main took claude-config to
0.31.0 and claude-memory to 0.8.0 while this branch held 0.30.0 and 0.7.1, so
this branch's entries move up to 0.32.0 and 0.8.1 above them -- still one entry
per plugin on this branch, not two.

check-structure.sh resolves to this branch: main only re-dated the comment above
the per-OS managed paths, and this branch had already replaced that block with a
source of lib/managed-scope.sh, which carries the 2026-08-10 verification date
itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…new scripts

Four CI lanes were red on this branch, all on gates that had never run against
these files before.

Every shipped script carried index mode 100644 despite a shebang. The exec-bit
gate names them individually; the two define-only libraries stay 644, correctly,
since they have no shebang and are only ever sourced.

The spell checker splits hyphenated compounds, so "mis-resolved" and "mis-cited"
each read as the non-word "mis". Reworded rather than suppressed -- "resolved to
the wrong place" and "cited the wrong section" are also plainer.

Fixture records in the merge suite used /home/.claude/settings.json, which the
machine-specific-path detector reads as a real Linux user path. They are
synthetic strings in heredoc records, never touched on disk, so they move to an
unmistakably fake /fx/home. The two places that write a real file under the temp
fixture home are unchanged.

The portability gate flagged Windows registry key literals as regex escapes.
They are neither -- they are key paths compared as strings -- so each site takes
the sanctioned per-site `portability-ok:` escape. The four inside multi-line
`env` continuations could not carry a comment where they stood, so they are
hoisted to named variables that can. The oracle fixture capture is a different
case: it reproduces Claude Code's own [DEBUG] narration byte for byte, and that
narration prints Windows paths in native form, so rewriting it to POSIX form
would make the fixture stop matching what the parser must survive. That file
takes a whole-file `portability-scope:` declaration stating exactly that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
Auto mode became the default for new sessions, and on entry it silently drops
broad allow rules. Nothing told a consumer which of theirs survive. This
classifies every effective allow rule as dropped -- with the documented reason
named: blanket, wildcarded interpreter, package-manager run, or Agent -- or as
carried over.

The classes come from lib/permission-patterns.sh, the vocabulary
audit-permission-grants check P1 already scans with. The library now exposes its
four alternatives individually as well as the union, because a driver that must
NAME the class a rule fell into has to test them one at a time; the union is
composed from them rather than written twice.

autoMode.classifyAllShell inverts the answer wholesale -- with it on, narrow
rules do NOT carry over -- so the reader inventories it as a new `conf` record,
emitted as JSON so a boolean true and the string "true" stay distinguishable.
It is resolved only from the scopes the classifier actually reads: user,
managed, and inline --settings/SDK JSON. A project- or local-scope copy is
reported inert rather than obeyed.

Both of the phase's open questions were measured rather than reasoned about,
and one answer changed what ships. A -p oracle session does NOT modify any
settings file, so criterion 9 holds -- but it DOES rewrite ~/.claude.json and
add project, session-env, security, subagent and backup state under the config
root. The cost notice said only "does not modify any settings file", which was
true and incomplete in the way that matters for a flag whose whole job is being
honest about what it costs. It now enumerates the measured writes. The second
question is moot: a plain -p run with no mode flag emitted 216 drop lines, so
the differential capture held in reserve was never needed. A third fact fell
out of the probe -- an isolated run with CLAUDE_CONFIG_DIR pointed at scratch
cannot authenticate, because credentials live in the real config root, so a
probe of this shape necessarily touches it. Copying credentials into a scratch
root to fake isolation was rejected rather than attempted.

One defect caught by its own regression case: the oracle's rule-text parse
claimed to cut at the LAST " from " but the sed was leftmost-matching, so
`Bash(python3 import from x *)` parsed as `Bash(python3 import`.

49 checks, shellcheck clean, every sibling suite still green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
… read

The permission plane accepts things it silently ignores, and the operator who
wrote them has no way to know. Eight checks over every scope at once, before a
session starts.

The highest-consequence one is disableAutoMode typed as a boolean. The
documented value is the STRING "disable"; a boolean is valid JSON, is accepted,
and does nothing -- so an operator who set it believes auto mode is locked out
when it is not. It is read at BOTH documented key paths, top-level and under
`permissions`, in every scope, because it is not managed-only and a scan of one
path would miss half the surface.

The three dead-config gates stay three findings. They cover different scope sets
and carry different version histories, so a merged count would let someone fix
one, watch the number drop, and believe they had fixed all three.

Criterion 6's fourth item shipped as two checks rather than one. A
parameter-form rule on a primary content field (`Bash(command:rm *)`) is ignored
with a startup warning; a path rule on Write/NotebookEdit/Glob/MultiEdit is
accepted and never consulted. Different mechanics with different observables --
one merged check would have been wrong about at least one of them. Both were
re-derived against the pages rather than shipped as the plan wrote them.

The reader owns the config reads, not the lint: permission-state.sh's conf
record grew from one key to six, emitted as JSON so a boolean true and the
string "true" stay distinguishable. For the disableAutoMode check that
distinction IS the finding. A lint that re-opened settings files itself would
have duplicated scope resolution the reader already does correctly.

False positives were designed against explicitly and each has a regression case
asserting it does NOT fire: a bare tool-name rule (legitimate at the tool level,
and the most common rule shape there is), `:*` at pattern end, a parameter rule
on a non-content field, and a POSIX-form Windows path.

SKILL.md was over its soft line target, so the scope tables and the per-check
mechanic table moved to reference/criteria.md.

43 checks, shellcheck clean, siblings still green, check-skill PASS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…ading it

A different surface from everything else in this skill: four natural-language
sections an LLM classifier reads, not permission rules the harness matches. So
only the mechanical checks live here. `claude auto-mode critique` owns the
semantic judgment and is surfaced rather than reimplemented.

The headline finding is a section that omits "$defaults". Customizing a section
REPLACES the built-in list rather than adding to it, so the finding names how
many built-in entries are gone and the first one by label -- "you dropped 65
soft_deny rules" is actionable where "missing $defaults" is not. It fires only
when entries are actually missing, never merely when the token is absent: the
CLI expands "$defaults" in its own output, so an expanded section and an omitted
one differ only in what is gone, and the first draft fired on three sections the
fixture had never customized.

Contradiction and shadowing compare label SUBJECTS, never bodies. The bodies are
prose written for an LLM; no mechanical comparison of prose is defensible, and
attempting one would be exactly the judgment reserved for critique.

Reading this block at all required the whole measured defensive contract, every
item of it a way to have reported a confident wrong answer:

  - `auto-mode config` emits raw control characters inside JSON string values.
    jq rejects it outright, exit status still 0. The offending byte is a raw
    line feed inside a string, so no line-oriented POSIX filter can tell it from
    the pretty-printer's structural newlines -- which is why this lane needs a
    real parser, and why pure POSIX was tested and rejected rather than assumed
    inadequate.
  - `defaults --label` OMITS a non-matching key rather than returning an empty
    list, so a missing key is tolerated as "no entries", not raised.
  - Labels carry a bracketed annotation before the colon
    (`Git Destructive [named+specifics ...]: ...`), so the split is at the first
    `[`, verified against the real captured payload rather than a description.
  - Exit status is never consulted. A run that produced nothing is reported
    status=unavailable with an explicit "NOT a clean bill", because "your block
    is clean" and "the block was never read" are different claims and only one
    of them is true.

The raw-control-character fixture is ASSERTED invalid, not assumed: the suite
runs `jq -e .` on it and fails if jq parses cleanly. That caught a real defect
during construction -- the fixture had been regenerated through json.dumps,
which escaped the line feed, so the headline regression case was testing
nothing. It is now authored as explicit bytes and pinned `-text` in
.gitattributes, because line-ending normalization would rewrite that byte and
silently make it valid JSON again.

The lane is optional by declaration: absent python3 or claude it prints a
visible skip notice and exits 0, and every other stage of the skill still runs.

44 checks, shellcheck clean, portability gate clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…ich are not

An administrator deploys managed policy believing it is policy. Some of it is,
some of it is not, and nothing in the product surfaces which.

A managed `permissions.deny` is the strongest thing an administrator can write:
no level, command line included, can override it, and a tool denied at any level
cannot be allowed at another. A managed `autoMode` section is not that at all --
it is additive. A developer cannot remove entries it provides, but a
developer-added allow can override an organization soft_deny, because
permissions, hooks, MCP, sandbox-filesystem and sandbox-network each got an
exclusivity lock and auto mode did not. The finding quotes that wording and
names the page's own remedy rather than inventing one.

That claim was supposed to ship caveated, on the grounds that it came from a
research slice nobody had independently verified. It no longer needs the caveat:
the governing page states it affirmatively, and Phase 0 recorded the wording. The
caveat is discharged rather than narrowed, and nothing here rests on the slice.

A second loosenable class fell out of the merge mechanics and was not in the
plan. "Managed settings are highest" and "deny before ask before allow, from any
scope" are both true, so a lower-scope deny beats a managed allow WITHOUT
overriding it. An administrator reading only the precedence table would not
expect that, which makes it precisely this report's remit.

The report prescribes nothing and ships no security floor of its own. That
property is asserted positively -- the suite walks every rule the report printed
and fails if any string is absent from the input -- rather than by checking that
some recommendation marker never appears, which would pass unconditionally.

Completeness bounds every claim. Server-managed settings have no local path, and
a managed surface that could not be read gets a note saying it is not evidence
that no policy is deployed there. "No local policy" is a stated status, not an
empty report: an administrator reading silence as "nothing to worry about" is
the exact failure this exists to prevent.

28 checks, shellcheck clean, portability gate clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…part

The autoMode block is a natural-language prompt for a classifier, not a rule
list the harness matches, so it fails in ways a config file does not. This skill
interviews for one entry at a time and prints a paste-ready block.

The entry shape is not invented here. `claude auto-mode critique`, run against a
real 66 KB hand-authored block, found the classifier is "an LLM doing a single
pass under a 'default is ALLOW' instruction", so "buried conditions in paragraph
position 40 will be missed at a materially higher rate than conditions in a
bullet list", and recommended exactly this: a label, bulleted COVERED / NOT
COVERED, one line of rationale, provenance stripped. The finding is acted on at
authoring time rather than reported after the fact.

The interview's fourth question is the load-bearing one. The same critique named
uncheckable conditions the biggest weakness -- entries state preconditions the
classifier cannot evaluate from command text, so it either allows blindly (the
condition is decorative) or blocks (the grant is inoperable), with no stated
disposition. The skill pushes back and asks for the observable form: "only when
the deploy is approved" is not checkable; "only when the command names the
staging endpoint" is.

Every emitted section opens with "$defaults", by construction. Customizing a
section REPLACES the built-in rule list rather than adding to it, so a drafter
that omitted the token would bake in the exact defect the audit sibling reports.

jq composes the JSON rather than shell string-building, because hand-rolled
escaping is how a draft would end up shipping the raw-control-character defect
this plugin reports elsewhere. A case feeds quotes, backslashes and a tab and
asserts the output is strict-valid.

It writes nothing, in any scope, under any flag. That contract is asserted by
RUNNING the script and diffing the filesystem, including a fixture HOME whose
settings file is checked byte-identical afterwards. Grepping the source for
redirect syntax was tried and rejected: the script is mostly an embedded jq
program where `>` is a comparison operator, so a text scan cannot tell a
redirect from a comparison. Measuring the effect is sound where scanning is not.

The history input the earlier design called for stays dropped: it named no
actual location, and an unnamed read surface in a shipped skill is
unreviewable. SKILL.md says so, so it is not silently reintroduced.

33 checks, shellcheck clean, check-skill PASS, catalog and cheat sheet
regenerated for the ninth skill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…rity review

Criterion 1 reviewed hook commands as the code-execution surface and said
nothing about a skill's frontmatter `allowed-tools`, which is also one.

Measured on 2.1.225: a marketplace-installed skill's allowed-tools entry takes
effect at USER scope in a NEVER-TRUSTED workspace, under -p where no trust
dialog can appear. The covered command ran without a prompt, the uncovered one
blocked with "This command requires approval", and a no-grant baseline confirmed
that shape blocks. So the install-time plugin trust prompt is the ONLY gate in
front of such a grant -- there is no second, per-workspace one.

The criterion now says so, and denies by default anything broader than the
specific command a skill's own scripts invoke. A wildcard interpreter grant is a
deny outright: it is arbitrary code execution in a workspace the consumer never
trusted. audit-permission-grants check P1 already detects those shapes, so the
criterion names it as its mechanical half rather than leaving the check to
memory.

The measurement's bounds are stated with it: a local-directory marketplace, user
scope only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
… fast enough to run

Criterion 9 says these skills write nothing, in any scope, under any flag. That
is the reason they are safe to point at a consumer's real configuration, so it
is now proven by RUNNING all ten actions of both skills -- oracle explicitly ON
against a stub `claude` -- and diffing a fixture tree and a fixture HOME either
side. Reading the code would not have been evidence.

Building that harness found a performance defect I introduced in Phase 4. The
`conf` extension ran one `jq` invocation per key per settings surface: six
spawns per file, thirty across five scopes. Process spawning dominates on
Windows, so the reader took 65 seconds per run -- slow enough that every caller
timed out and the harness looked like it was hanging rather than working.
Collapsed to one invocation emitting all six keys: 65s to 7s, identical output.
Worth fixing on its own terms; a reader nobody will wait for is a reader nobody
will run.

The harness itself needed four corrections, and the last is the one worth
remembering. A helper returned its produced file path on STDOUT, which
interleaved with the PASS lines the same function printed -- four stages
silently never ran and the harness reported "All 10 checks passed". A green
result over work it had not done. The path now comes back in a named variable,
every stage asserts its output is non-empty, and a final assertion checks that
all ten actions were attempted. A harness that can miscount its own cases is
worse than no harness: it converts absence of verification into a passing claim.

The three earlier corrections were a stub-only PATH that starved the scripts of
tools they need (its `env` wrapper re-entered itself through the PATH it was
setting up, deadlocking before the first assertion), pipelines composed inside
`bash -c` strings that stalled on quoting, and a `while read` snapshot loop
whose subshell deadlocked against its own feeding pipeline on Git Bash. Each
failed silently -- no error, just no further output.

Criterion 10 was NOT met and now is. The permissions.ask-under-auto-mode
discrepancy (#83766, #42797) was recorded in the plan but appeared nowhere the
skills ship, so a consumer acting on an `ask` finding had no way to know the
reported behavior contradicts the documented one. It is now a named caveat with
its retirement condition, and SKILL.md tells the reader to carry it.

ADR 0009 records the two decisions that outlive this branch: report-never-write,
and compute the merge locally bounded by decidability. Both had a defensible
opposite answer, and enforcement was available rather than absent -- the posture
is a choice, not a limitation.

Also: audit-pass gains the permission lane, with its oracle path marked
never-dispatched and its optional-lane skip required to reach the report as
"unchecked with reason" rather than collapsed into "clean". OFFICIAL-DOCS dates
advance for the four pages re-fetched during this work.

Criterion 12 measured: 6737/8000 chars across nine listing-eligible skills.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
docs/topics/<slug>/ is Contract tier: committed on the task branch, pruned
before merge. The durable outcomes graduated first, so nothing is lost with it:

  - The two decisions that outlive the branch -- report-never-write, and compute
    the merge locally bounded by decidability -- are ADR 0009.
  - The citation base moved into the skills' own reference/criteria.md, which is
    plugin-owned and ships to consumers rather than living in a branch-local
    planning file. Every load-bearing quotation is there with its mechanic:
    scope merge semantics, evaluation order in both directions, the v2.1.142 and
    useAutoModeDuringPlan gates, primary-content-field and uncovered-path rules,
    the systemd drop-in merge order, managed autoMode additivity, and the open
    permissions.ask discrepancy with its retirement condition.
  - The plugin-acceptance consequence of the allowed-tools measurement went to
    MIGRATION-PLAYBOOK.md, where the review that needs it lives.
  - The researcher preload defect is issue #2338.

Verified before deleting: no shipped surface cites docs/topics/ -- a runtime
reference into a path that will not exist after merge would have been the one
unrecoverable mistake here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…atterns

The portability gate flagged four `\s` / `\S` runs. Three were in the drafter's
embedded jq program, where POSIX bracket expressions say the same thing and are
what the gate asks for -- `[[:space:]]` and `[^[:space:]]`. Behavior unchanged;
33 checks still pass.

The fourth is different in kind and takes the declared-scope escape: the suite
deliberately feeds a literal backslash through the drafter to prove jq owns the
JSON escaping rather than shell string-building. That backslash is adversarial
INPUT inside a heredoc, not shell code, so rewriting it would delete the case
rather than fix anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…fication

An independent fresh-context verifier, given the acceptance criteria with the
rationale withheld, found six defects in Phase 3. Five are fixed here; all were
reproduced before being touched.

A bare `Bash` allow was reported as SURVIVING auto mode. It is the whole-tool
form -- strictly broader than `Bash(*)`, which the same run drops as blanket.
The asymmetry was the evidence: `Agent`'s bare form was handled and the two
shell tools' were not. Wrong in the direction that costs an operator: it says
their widest shell grant is safe.

`--merge-only` silently inverted criterion 4. The merge dropped `conf` records
under that flag, so the entry diff lost `autoMode.classifyAllShell` and reported
a narrow shell rule as kept when it is actually suspended. Two documented flags
composing into a wrong answer with no warning. conf records now always pass
through -- they are input a downstream stage needs to be correct, not
presentation.

The oracle's rule/path split was wrong in both directions, and the second was
invisible to the first fix. The narration delimits neither field, and both sides
may legitimately contain " from ": a rule (`Bash(python3 import from x *)`) and
a directory (`notes from work`). Cutting at the first separator truncated the
rule; cutting at the last truncated it when the PATH carried the word, yielding
two false verdicts plus a phantom rule. No fixed choice is right for both, so
the split is now resolved by which candidate leaves a well-formed tool token on
the left. A line that resolves to zero or several candidates is ANNOUNCED as
unresolvable rather than silently split -- a wrong rule name in a divergence
verdict is worse than an admitted gap.

`classifyAllShell: false` was described as a type error. It is the documented
default. The code was right and the operator-facing sentence was wrong, which is
its own kind of defect.

Criterion 3 was only half met. The classifier reads autoMode from three scopes
and this reader can open two; inline --settings and SDK JSON have no file, and
classifyAllShell set there inverts every shell verdict. Both SKILL.md and
criteria.md named that third scope while nothing disclosed the gap. Every run
now states it.

Also corrected: a comment asserted as MEASURED that drop lines "appear exactly
when the session's permission mode is auto". That was never measured -- the
recorded probe emitted 216 drop lines with no mode flag passed. The flag is
valid and harmless, but the claim was mine and unproven, and the verifier was
right to flag the artifact contradicting its own evidence file.

D6 (an empty rule in the narration parsing to "" and being filtered) is left:
the new tool-token resolution rejects it as unresolvable, so it now surfaces in
the unparsed count rather than vanishing.

63 checks in the entry diff (was 49), 51 in the merge (was 49), shellcheck and
portability clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…ng it in two

The verifier flagged this as pipeline-wide rather than oracle-only, and asked
whether it was reachable through a real settings file. It is -- measured: a
settings file containing "Bash(echo hi\nthere *)" is ordinary valid JSON, and
the reader emitted

  rule user settings allow Bash(echo hi
  rule user settings allow there *)

Two records, neither of which is a rule in any file, both flowing downstream
into the merge and the entry diff as if they were real grants. That is the same
failure class as the phantom rule in the oracle parse, on the read path instead
of the corroboration path -- and it outranks where I had it, because settings
files are the input this tool exists to read.

These records are line-oriented, so such a rule cannot be represented in one.
Rather than pick a lossy encoding, the reader now detects it and says so: no
rule record is emitted, and a NOTE names the scope, the kind, and the rule text
rendered on a single line. Admitting one rule cannot be represented beats
inventing two that do not exist.

Applied to the registry read path as well as the file path. Managed policy is
where a phantom rule would do the most damage, so it does not keep the older
splitting read.

47 checks in the reader suite (was 42), shellcheck and portability clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
An in-string CARRIAGE RETURN was silently deleted, not announced. The newline
fix landed last round covered LF only; `tr -d '\r'` then quietly turned
"Bash(a\rb *)" into the rule string "Bash(ab *)" -- present in no settings file
and flowing downstream as a real grant, verbatim the failure the newline NOTE
exists to prevent. The strip is load-bearing (jq emits CRLF on Windows) so it
stays; the in-string case is now caught before reaching it.

C6-winPath was DEAD in the real pipeline. It tested for a doubled backslash --
the JSON SOURCE spelling -- but `jq -r` decodes the escape, so the reader emits
`Read(C:\Users\alice\**)` and the check never fired. It passed its own suite
only because every fixture there was a hand-written record that bypassed the
reader. Now tested on the single backslash the reader actually emits, with an
end-to-end case that drives the reader rather than standing in for it.

The autoMode block lane exited 0 with a traceback and NO summary line on a
payload that parsed but had the wrong shape (a JSON array, a string, a number).
A caller grepping status= got nothing and a success exit -- in the one lane
whose whole premise is that the CLI's output shape cannot be trusted. Shape is
checked before use; status=unexpected-shape, exactly one summary line, always.

--critique spawned a real session with no cost notice while its entry-diff
sibling prices its spawn. An unpriced spawn is the surprise the opt-in flag
exists to prevent.

managed-conformance reported a CORRUPT managed policy as absence of policy:
invalid-json raised no note and yielded status=no-local-policy, which an
administrator reads as "nothing deployed". It now groups with skipped and
unreadable exactly as the merge already did, and "no local policy" is claimed
only when every surface was looked at and found genuinely empty -- otherwise
status=incomplete.

The drafter emitted section names Claude Code does not read: `section bogus`
became a key, and an empty section name became "". An authoring skill
manufacturing the "written where nothing reads it" defect its own Phase 4
sibling exists to catch is the worst possible source for it. Unknown sections
are skipped with a warning on stderr; stdout stays valid JSON.

Suites: reader 50, merge 51, entry diff 63, plane lint 45, block lint 62,
conformance 32, drafter 39. shellcheck and portability clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
… round two

Parameter matching is documented for DENY AND ASK RULES ONLY. Re-fetching the
section to settle the ambiguity the verifier flagged turned up a defect nobody
had raised: "An allow rule for one parameter value wouldn't establish that the
call is safe overall, so allow rules continue to use each tool's own specifier
syntax." So `allow: ["Agent(model:opus)"]` is not a narrowed grant -- it is a
rule whose form the allow path does not use, and the operator believes they
scoped a permission and did not. New check C6-allowParam.

It fires only where the shape is unambiguously the parameter form: a top-level
parameter the page names, on a tool whose own syntax is a path or a command.
`WebFetch(domain:host)` is the documented WebFetch form and `Bash(npm:*)` is a
command prefix -- neither is distinguishable from a parameter by shape, so
neither fires. That was the verifier's actual complaint about the colonStar
message, and this is the honest resolution: narrow the check to what the page
supports rather than restate an unhedged claim in different words.

The content-field wording stays as it is. The page is unconditional there --
"Claude Code ignores it and emits a startup warning" -- so the certainty is the
page's, not mine.

Removed a stale comment block that described a superseded oracle design and
asserted a limitation the shipped code does not have ("a path containing a
space still defeats this" -- it does not; the tool-token resolution handles it).
A comment claiming a constraint that was removed outlives everyone who knew
better.

Moved the oracle cost measurement into reference/criteria.md. The verifier's
sharpest point: the artifact stopped contradicting its evidence because the
evidence was PRUNED with the contract slice, leaving a comment citing a capture
the repository no longer contains. The measurement was real and is now
somewhere a reader can actually check it, in a file that ships to consumers.

Plane lint 50 checks (was 45, now nine checks not eight), entry diff 63,
shellcheck and portability clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
PR #2260 was merged as a squash while this branch kept moving, so main now
carries the walking skeleton and the merge stage while this branch carries those
plus Phases 3-8, the contract-slice prune, and thirteen defect fixes from three
independent verification rounds.

Every code conflict resolves to this branch: same lineage, strictly further
along. The squash landed the Phase 1-2 versions of permission-state.sh and
permission-merge.sh; this branch has both plus the whole-tool deny handling, the
conf-record passthrough, the newline and carriage-return reporting, and the
9x reader speedup.

Versions move above what main reached rather than re-using the numbers this
branch had claimed: claude-config 0.36.0 (main was at 0.35.2), claude-memory
0.9.1 (main was at 0.9.0). Both CHANGELOGs interleave so this branch's entry
sits above main's history in descending order.

Catalog and cheat sheet regenerated. validate-plugins and all four
changelog-parity modes pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
Resolving eighteen conflicts with --ours was right for the files this branch
advanced and wrong for three it did not, and the drift gate caught the first of
them rather than my own review. Checking the rest of the --ours set against main
found two more.

permission-rule-check.sh is the one that mattered. Main had REMOVED a $PWD
fallback for the scan root, because outside a git repository $PWD is typically
the user profile and both scans walk the root with an unbounded `find` -- so an
unresolved root became a silent sweep of the operator's home that still exited
0, indistinguishable from a clean bill. My side was simply the older file and
added nothing; taking --ours reintroduced the fallback. Restored from main, with
its SKILL.md and test suite.

cross-plugin-source-registry.txt lost main's lib/state-key.sh entry the same
way, which is what the drift gate failed on. managed-scope.test.sh differed only
in the wording of two portability-ok comments; took main's to keep the diff
small.

permission-patterns.sh stays on this branch's version: it splits the P1 union
into its four named alternatives so the entry diff can report WHICH class
dropped a rule, and composes the union from them rather than restating it.

Suites after restoring: permission-rule-check 76, managed-scope 16,
permission-patterns 12, permission-state 50. Drift, portability and
validate-plugins all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
@cursor

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 0s —— View job


Security Review

  • Gather context (PR diff, changed files)
  • Review shell scripts for injection / unsafe handling (automode-entry-diff.sh, automode-block-lint.sh, permission-plane-lint.sh, managed-conformance.sh, permission-merge.sh, permission-state.sh, draft-automode-block.sh)
  • Review permission-patterns.sh changes
  • Review .gitattributes and doc/ADR changes for security-relevant content
  • Check for GitHub Actions workflow changes (none present in this diff)
  • Post final findings

No security issues found. This PR contains no GitHub Actions workflow changes and no changes to permissions: blocks or action pins (zizmor's lane doesn't apply here). The added surface is all local, read-only bash/jq/awk/python tooling plus documentation.

Notes from the review (informational, not findings):

  • All new scripts (automode-entry-diff.sh, automode-block-lint.sh, permission-plane-lint.sh, managed-conformance.sh, draft-automode-block.sh, and the modified permission-merge.sh/permission-state.sh) are read-only by design and this is mechanically enforced — no-writes.test.sh snapshots a fixture tree and fixture HOME and diffs before/after running all ten actions of both skills, including the one path (automode-entry-diff.sh --oracle) that spawns a subprocess. That's a solid control for the "never writes" claim the PR body makes.
  • jq invocations consistently parameterize untrusted values via --arg/--argjson rather than string-interpolating into the jq program text (e.g. permission-state.sh's emit_file_conf, draft-automode-block.sh), and shell calls to reg/defaults/claude pass arguments as an argv array rather than through eval or a constructed string — no injection surface found in the rule/config text that flows from settings files into these tools.
  • The two flags that spawn a real claude process (automode-entry-diff.sh --oracle, automode-block-lint.sh --critique) are opt-in, never fire silently, print a cost notice naming what they touch (~/.claude.json, config-directory session state) before spawning, and pass their prompt as a single quoted argv element (not shell-concatenated), so no injection via ENTRY_DIFF_ORACLE_PROMPT.
  • docs/MIGRATION-PLAYBOOK.md's new "allowed-tools is a prompt-free execution grant" entry documents a measured trust-boundary gap in Claude Code itself (a skill's allowed-tools takes effect at user scope in a never-trusted workspace with no per-workspace prompt) — this is upstream product behavior being documented for reviewers, not something this PR introduces or weakens.
  • No secrets, tokens, or credential-shaped strings were introduced in the diff.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f8abbafa2a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-config/.claude-plugin/plugin.json Outdated
…s settle

C6-winPath went from dead to flooding. Round two fixed it to test a bare
backslash, and the verifier's follow-up caught what that costs: backslashes are
ordinary in shell rules -- a regex `sed 's/\./_/g'`, an escape `Bash(echo \n *)`,
a quoted string -- so every one became a severity-`error` finding carrying
Windows-path advice, and the one true finding drowned in them. Worse than the
dead check it replaced, because dead was at least quiet.

Now tested on SHAPE: a drive-letter or UNC prefix, the two forms that actually
are a Windows path. A backslash anywhere else is somebody escaping something.
UNC gets its own message, since "use //c/**" is wrong advice for it.

C6-colonStar fired on `WebFetch(domain:*.example.com)`, calling a documented
working rule broken. The page: "WebFetch rules use a `domain:` prefix and match
against the hostname… supports `*` wildcards." The colonStar mechanic it states
is about COMMAND PREFIX patterns specifically -- "In a pattern like
`Bash(git:* push)`, the colon is treated as a literal character". Documented
parameter forms are now exempt.

The same page settles the other half in the opposite direction, so nothing
changes there: `Bash(command:rm *)` is the page's OWN example of a rule Claude
Code ignores, so the unconditional wording in C6-contentField is the page's
certainty, not mine.

A section present but not a list (`{"allow": "not-an-array"}`) counted as a
present section and reported status=read -- a clean bill on something no check
could examine, the same class as the wrong-shape payload fixed last round, one
level down. Now status=partial with a note naming the section.

Header comments corrected: they still described the doubled-backslash test and
the unqualified colonStar rule.

Plane lint 58 checks (was 50), block lint 62, shellcheck and portability clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 39s —— View job


Security Review

  • Gather context (PR diff, changed files via gh pr diff 2396)
  • Review shell scripts for injection / unsafe handling (automode-entry-diff.sh, automode-block-lint.sh, permission-plane-lint.sh, managed-conformance.sh, permission-merge.sh, permission-state.sh, draft-automode-block.sh)
  • Review permission-patterns.sh changes and logic/authorization-relevant gaps in the audit reports
  • Review .gitattributes / doc / ADR changes for security-relevant content
  • Check for GitHub Actions workflow changes (none present in this diff)
  • Post final findings

No injection, secret-handling, or access-control vulnerabilities found. All new/modified scripts (automode-entry-diff.sh, automode-block-lint.sh, permission-plane-lint.sh, managed-conformance.sh, draft-automode-block.sh, permission-merge.sh, permission-state.sh) are read-only, pass values to jq/awk/python3 via --arg/--argjson/argv rather than string-interpolated eval, and the two flags that spawn a real claude process (--oracle, --critique) are opt-in, non-silent, and pass their prompts as single quoted argv elements. mktemp -d is used correctly for scratch directories. No GitHub Actions files changed.

This PR's changed files are a security-reporting tool, so I also verified the logic-flaw findings already surfaced by Codex against the current head (abb6b8f) rather than re-deriving new ones from scratch — a bug that makes this specific tool over-report "safe"/"enforced" is a trust-boundary issue for whoever relies on its output.

Confirmed, still present at abb6b8f:

  1. IMPORTANT (confidence: high) — managed-conformance.sh#L98-L109: a present macOS managed-preferences plist increments n_managed_present but contributes zero rule records (permission-state.sh explicitly does not inventory plist contents yet). If the plist is the only present managed surface, the final line reads managed conformance summary enforced=0 loosenable=0 status=read — a clean "read" status — even though the actual policy content was never examined. This is the same class of bug the PR's own commit message says it fixed for corrupt/unreadable managed policy ("reporting it as absence of policy is the worst answer this report can give an administrator"), but the present-but-uninventoried plist case isn't routed through that unread[] handling, so it still produces a false-clean report. Matches Codex's inline comment on this file; independently reproduced by tracing the awk state machine.

  2. IMPORTANT (confidence: high) — permission-state.sh#L189-L204: classify_json_file marks a settings file present based solely on jq empty (syntax validity). A syntactically valid but schema-invalid file, e.g. {"permissions":{"allow":"Bash(*)"}} (a string instead of an array), is marked present; emit_file_rules's jq -r ... | .[] then errors on the non-array value, is silently swallowed by 2>/dev/null, and yields zero rule records. Downstream, this scope reports as read-and-clean instead of invalid/incomplete, which is exactly the "false clean bill" failure mode this script's own design commentary (lines 5–11, 32–37) says it exists to avoid. Confirmed by tracing classify_json_fileemit_file_rules.

  3. SUGGESTION (confidence: medium) — automode-entry-diff.sh#L196-L201: the drop-classification grep -qE calls test the whole rule string against $CCPERM_P1_BLANKET_ERE/$CCPERM_P1_INTERP_ERE/$CCPERM_P1_RUNNER_ERE without anchoring to the parsed tool name. A non-shell allow rule whose payload happens to contain shell-rule-shaped text as a substring (e.g. a hypothetical Read(notes/Bash(*)-guide.md)) would match (Bash|PowerShell)\(\*\) and get misreported as dropped class=blanket. Lower severity than the two above since it requires an unusual rule shape and the error direction (falsely reporting something as dropped rather than kept) doesn't create a false sense of safety — if anything it's the safer-biased direction. Matches Codex's inline comment on this file; independently confirmed via the regex/string trace.

None of the above are exploitable in the traditional sense (no attacker-controlled input reaches these paths — they operate on the operator's own local settings files), but all three cause this audit tool to under-report risk or over-state assurance to the administrator reading it, which is the specific failure mode this PR's design commentary repeatedly says it's trying to eliminate. Worth fixing before merge given the PR's own stated bar (three rounds of independent verification, "no silent false-clean reports").

Everything else reviewed — permission-merge.sh, permission-plane-lint.sh, automode-block-lint.sh, draft-automode-block.sh, the ADR, MIGRATION-PLAYBOOK.md, .gitattributes — is either purely read-only reporting logic with correct jq/awk parameterization or documentation with no security implication.

The changelog-parity gate caught this and it is a real defect, not a gate
quirk: resolving the merge relabelled the `## [0.8.1]` heading to `## [0.9.1]`
rather than adding a new one, so a released entry's heading vanished. The gate's
own message names that exact failure mode -- "RELABELLING that heading to the
new version; git leaves no conflict marker behind for either".

The right fix turned out to be smaller than restoring the heading. PR #2260
already shipped 0.8.1 with this body, and diffing the two entries shows they are
identical apart from the heading. This branch changes nothing in claude-memory:
no code, no docs, only a version number and a relabelled heading it had no
business touching. So both files revert to main exactly, and the plugin carries
no bump at all.

claude-config is unaffected -- 0.36.0 sits above main's history with every
heading main shipped still present.

All four parity modes and validate-plugins pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 10s —— View job


Security Review

  • Gather context (PR diff, changed files)
  • Review shell scripts for injection / unsafe handling
  • Review permission-patterns.sh changes and logic gaps in the audit reports
  • Review prior automated findings (Codex, earlier Claude runs) against current head
  • Review docs/ADR/.gitattributes changes for security-relevant content
  • Check for GitHub Actions workflow changes
  • Post final findings

No injection, secret-handling, or GitHub Actions issues found. No workflow files changed. jq/awk calls consistently pass untrusted values via --arg/--argjson/argv rather than string-interpolated eval; the two flags that spawn a real claude process (--oracle, --critique) are opt-in and pass prompts as single quoted argv elements.

This PR's diff is a security-reporting tool, so the relevant trust boundary is: can a bug in it cause the tool to overstate assurance to the administrator reading its output (report something as safe/enforced/read when it isn't)? I verified against the current head (70b9d92) rather than re-deriving from scratch — the only commit since the last review (abb6b8f) touches unrelated claude-memory files, so all four items below are still live in this diff.

1. IMPORTANT (confidence: high) — a present-but-uninventoried managed surface is reported as a clean, fully-read policy.
permission-state.sh#L393-L395 emits managed plist present for a macOS managed-preferences domain while noting in-line that its rules are "NOT inventoried yet." managed-conformance.sh#L98-L109 counts any present surface into n_managed_present without checking whether it actually contributed rule records, and only routes skipped/unreadable/invalid-json into the unread[] warning path — present-but-empty is not one of them. Since n_managed_present > 0, line 204 unconditionally prints status=read. If the plist is the only present managed surface, the report reads as "audited, nothing found" when the policy content was never examined — precisely the "corrupt policy reported as absent policy" failure mode this PR's own commit history says it eliminated, just via an uninventoried-but-present path instead of an unreadable one.

2. IMPORTANT (confidence: high) — a schema-invalid settings file is silently treated as an empty, clean scope.
permission-state.sh#L189-L203 (classify_json_file) marks a file present based solely on jq empty (syntax validity). A syntactically valid but schema-invalid file such as {"permissions":{"allow":"Bash(*)"}} (string instead of array) passes this check. emit_file_rules's .permissions[$k] // [] | .[] | ... then errors trying to iterate a string — // [] never triggers because the string is truthy — and the error is swallowed by 2>/dev/null, yielding zero rule records. Downstream this scope reports as read-and-empty rather than invalid/incomplete, the exact false-clean-bill scenario this script's own design commentary (lines 5–11, 32–37) says it exists to prevent.

3. SUGGESTION (confidence: medium) — auto-mode drop classification isn't anchored to the rule's own tool.
automode-entry-diff.sh#L196-L201 tests the drop-classification regexes (CCPERM_P1_BLANKET_ERE etc., confirmed unanchored in permission-patterns.sh#L63-L67) against the whole rule string rather than gating on the parsed tool. A non-shell allow rule whose payload happens to contain shell-rule-shaped text as a substring (e.g. Read(notes/Bash(*)-guide.md)) matches (Bash|PowerShell)\(\*\) and is misreported as dropped class=blanket. Lower severity: this requires an unusual rule shape, and the error direction (falsely reporting something as dropped) is the safer-biased one rather than a false-clean report.

4. SUGGESTION (confidence: medium) — managed-conformance.sh's "enforced" verdict for a managed allow/ask is exact-text only, and (unlike the merge stage) doesn't disclose that.
managed-conformance.sh#L159-L165: a managed allow Bash(npm test) is checked against lower[deny SUBSEP "Bash(npm test)"] by exact text. A broader lower-scope deny (Bash(npm *)) or an invisible --disallowedTools rule (command-line scope has no file, per permission-merge.sh's own CAVEAT at line 159) won't match, so beaten stays empty and the report prints managed enforced allow ... — a stronger claim ("cannot be overridden") than merge's plain "effective" listing, and made without carrying forward merge's own documented exact-text caveat. Confirmed no MANAGED-NOTE/CAVEAT about this limitation is emitted anywhere in managed-conformance.sh. Lower severity because the error direction says a real grant is less certain than reported (Claude Code's own deny-wins evaluation still applies at runtime), not more permissive than reported — so it doesn't itself widen access, only overstates the administrator's certainty about it.

Everything else in scope — permission-plane-lint.sh (the C6-winPath bare-backslash false-positive Codex flagged earlier has since been fixed to shape-based drive/UNC detection at lines 231-232), automode-block-lint.sh, draft-automode-block.sh (verified no eval/network/write primitives, consistent with its "writes nothing" claim), the ADR, MIGRATION-PLAYBOOK.md, .gitattributes — is read-only reporting logic with correct jq/awk parameterization or documentation with no security implication. The plugin.json mojibake (â€" in place of em dashes) that Codex flagged is a data/encoding defect, not a security issue, so it's out of scope for this lane.

None of the four findings above are remotely exploitable — no attacker-controlled input reaches these paths, they operate on the operator's own local settings. All four are about this audit tool giving false assurance to the administrator reading it, which is the specific failure mode this PR's design commentary and commit history repeatedly say it exists to eliminate. #1 and #2 are the ones worth fixing before merge; #3 and #4 are lower-stakes refinements.

kyle-sexton and others added 2 commits August 12, 2026 04:14
…osed on an unknown section

Three fixes, all from round-four verification. The first two correct my own
reasoning, not just my code.

C6-colonStar exempted `WebFetch/domain` by name, and the verifier immediately
found `Agent(model:*-haiku)` -- a documented parameter form whose wildcard lands
mid-value -- still flagged. The allowlist was the wrong SHAPE, not just too
short: the page says parameter matching works "on any tool" for "any scalar
parameter the tool accepts", so enumerating names could only ever chase it. Now
by grammar: in a deny or ask rule an `identifier:value` body IS the parameter
form. In an allow rule it cannot be -- allow rules use the syntax each tool
defines -- so a command-prefix reading is the only one available there and the
check still applies.

The CR strip narrows rather than disappears, and my reason for not fixing it was
wrong. I had argued that catching an in-string CR meant deleting the strip that
makes CRLF-on-Windows work. It does not: `tr -d '\r'` removes every CR, while
`sed 's/\r$//'` removes only a line-ending one. A trailing CR is a line ending;
a CR anywhere else is data. So both work at once, and the sharper argument is
not the rule text at all -- the old path REPAIRED a payload before classifying
it, so a file could be reported `status=present` on the strength of a repair the
reader made itself. A status must never be kinder than the file it describes.

The drafter now FAILS CLOSED on an unknown section: exit 2, nothing on stdout.
Skipping it with a stderr warning was correct until a caller captured both
streams, and agent tooling merges them by default -- so the merged stream was
not parseable JSON. Failing loudly leaves no partial output to corrupt and
matches the posture every script here already takes. A `_warnings` key inside
the JSON was considered and rejected: it would land in the pasted autoMode block
as a section the classifier does not read, manufacturing the exact defect this
check exists to prevent.

Plane lint 58, drafter 42 (was 39), reader 50, shellcheck and portability clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 52s —— View job


Security Review

  • Gather context (PR diff at current HEAD 6004d1a, changed files)
  • Check what changed since the last review pass (70b9d926004d1a)
  • Re-verify the four previously-reported findings against current HEAD
  • Review remaining shell scripts / patterns for injection, auth-boundary, secret-handling issues
  • Check for GitHub Actions workflow changes
  • Post final findings

No injection, secret-handling, or GitHub Actions issues in this diff (still none — no workflow files changed; jq/awk calls throughout use --arg/--argjson/argv, never string-built eval). This tool's trust boundary, as in prior passes, is whether a logic bug makes it overstate assurance to the administrator reading its report. I diffed 70b9d926004d1a (commit "exempt the parameter form by grammar, and fail closed on an unknown section") and traced its new logic in addition to re-verifying the four findings from earlier runs.

New since the last review — introduced by 6004d1a itself:

5. IMPORTANT (confidence: high) — the new C6-colonStar parameter-form exemption is grammar-shaped, not tool/param-checked, so it silences the check for ordinary Bash/PowerShell deny/ask command-prefix rules — including the check's own textbook broken example.
permission-plane-lint.sh#L260-L262:

param_form = (kind != "allow") && (prefix_of(body) ~ /^[A-Za-z_][A-Za-z0-9_]*$/)
if (cs > 0 && cs + 1 < length(body) && !param_form && !((tool SUBSEP prefix_of(body)) in documented_param))

prefix_of() (L97-L103) just returns the text before the first :. For any deny/ask rule, param_form is true whenever that prefix is bare-word shaped — with no check against a real per-tool parameter table. The file already has exactly that table for this purpose, param_only[tool SUBSEP param] (L195-L198, used correctly by C6-allowParam at L283) — but C6-colonStar doesn't consult it. Consequence: deny Bash(git:* push) — the exact "In a pattern like Bash(git:* push), the colon is treated as a literal character" example quoted in this file's own comment two lines above — no longer fires C6-colonStar when written as a deny or ask rule, because prefix_of("git:* push")"git", which matches the bare-identifier regex even though "Bash" SUBSEP "git" is not in param_only. Same for ask Bash(rm:* -rf), deny Bash(curl:* http://...), etc. — any ordinary shell command-prefix deny/ask rule whose command starts with a single word before a colon. The error direction here is the worse one relative to prior findings in this PR: a deny rule silently failing to match what it was written to block is a false sense of safety, not just a missed grant. Traced statically (execution wasn't available in this sandbox); confirmed no test in permission-plane-lint.test.sh covers C6-colonStar with kind=deny or kind=ask — all existing cases (e.g. L151, L273) use allow.

Still present — confirmed unchanged at 6004d1a, not touched by the intervening commits:

1. IMPORTANT — a present-but-uninventoried managed plist is still reported as a clean status=read. permission-state.sh#L405-L409 emits present while noting rules are "NOT inventoried yet"; managed-conformance.sh#L103,L108,L204 counts any present surface toward n_managed_present and only routes skipped/unreadable/invalid-json into unread[]present-but-empty isn't one of them, so status=read prints unconditionally.

2. IMPORTANT — a schema-invalid (non-array) permissions.allow/ask/deny is still silently treated as empty-and-clean. permission-state.sh#L201-L216 (classify_json_file) checks only jq empty (syntax). A file like {"permissions":{"allow":"Bash(*)"}} passes as present; emit_file_rules's .permissions[$k] // [] | .[] (L243) errors iterating a string, swallowed by 2>/dev/null, yielding zero rules — reported as read-and-empty instead of invalid.

3. SUGGESTION — auto-mode drop classification still isn't anchored to the rule's own parsed tool. automode-entry-diff.sh#L184, #L196, #L200 test the drop-classification regexes against the whole rule string. A non-shell allow rule whose payload substring happens to look shell-rule-shaped (e.g. Read(notes/Bash(*)-guide.md)) would still misclassify as dropped. Lower severity — the error direction is safer-biased (over-reports dropped, not kept).

4. SUGGESTIONmanaged-conformance.sh's "enforced" verdict for a managed allow/ask is still exact-text only and still doesn't disclose that. managed-conformance.sh#L159-L170: a broader lower-scope deny (Bash(npm *) vs. managed Bash(npm test)) or an invisible --disallowedTools rule won't match beaten, so it still prints an unqualified managed enforced allow .... Lower severity — understates certainty, doesn't widen actual access.

Everything else in this diff — permission-merge.sh, automode-block-lint.sh, draft-automode-block.sh (including the new fail-closed halt_error(2) behavior on an unknown section, which is a correctness improvement with no injection surface — jq args stay parameterized), the new crlf_strip helper in permission-state.sh (narrows CR-stripping to trailing-only via a fixed sed pattern, no untrusted input in the pattern itself), the ADR, docs, .gitattributes — is read-only and either correctly parameterized or has no security implication.

Summary: 1 new IMPORTANT finding introduced in the latest commit (C6-colonStar false-negative on deny/ask command-prefix rules), plus the 4 previously-reported findings (2 IMPORTANT, 2 SUGGESTION) which remain unaddressed as of this HEAD. All five are about this audit/lint tool under-reporting risk or over-stating assurance to the administrator — none are attacker-reachable (all operate on the operator's own local settings/policy).

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 47s —— View job


Security Review

  • Gather context (gh pr diff against origin/main, changed files at head a6b67a9)
  • Re-verify prior findings (this comment's 3 earlier rounds, and Codex's 5 inline comments) against current head
  • Review changes since the last-reviewed head (70b9d92) for new security issues
  • Post final findings

No injection, secret-handling, or GitHub Actions issues found. No workflow files changed in this PR. jq/awk calls continue to pass untrusted values via --arg/--argjson/argv rather than string-interpolated eval; the two flags that spawn a real claude process (--oracle, --critique) remain opt-in and pass prompts as single quoted argv elements.

This diff is a security-reporting tool, so I re-checked whether the prior rounds' "overstates assurance to the administrator" findings are still live at a6b67a9, and reviewed the four commits landed since the last review (70b9d92) for anything new. Files touched since then — permission-state.sh (CRLF-in-rule-text fix), permission-plane-lint.sh (C6-colonStar deny/ask parameter-form exemption), draft-automode-block.sh (fail-closed on unknown section) — are correctness fixes, not regressions, and don't introduce new injection/secret/authz issues. managed-conformance.sh and automode-entry-diff.sh are byte-for-byte unchanged since the last review.

Still present at a6b67a9:

  1. IMPORTANT (confidence: high) — a present-but-uninventoried managed surface, on both the platforms this reads, is reported as a clean, fully-audited policy.

    • macOS: permission-state.sh#L405-L407 emits managed plist present while noting in the same breath that its rules are "NOT inventoried yet."
    • Windows: permission-state.sh#L365-L373 is worse than previously described — the status present is emitted at L365 before the JSON-parseability check at L372. When the registry Settings value doesn't parse as JSON, the code at L373 prints a note claiming it is "reporting it as unread," but the status already sent downstream is present, not unreadable/invalid-json. The note's claim doesn't match what actually happens.
    • managed-conformance.sh#L98-L108 only routes skipped/unreadable/invalid-json into the unread[] warning path added in this PR (visible in the MANAGED-NOTE / status=incomplete handling at L113-L138). present-but-empty is not one of them, so n_managed_present > 0 and, if it's the only present managed surface, L204 unconditionally prints status=read enforced=0 loosenable=0 — "audited, nothing found" when the content was never examined. This is exactly the class of bug this PR's own history says it eliminated (corrupt-policy-reported-as-absent), just reached via an uninventoried-but-present path this round's unread[] fix doesn't cover.
  2. IMPORTANT (confidence: high) — a schema-invalid settings file is silently treated as an empty, clean scope.
    permission-state.sh#L201-L216 (classify_json_file) still marks a file present based solely on jq empty (syntax validity, L211). A syntactically valid but schema-invalid file such as {"permissions":{"allow":"Bash(*)"}} (string instead of array) passes. emit_file_rules's .permissions[$k] // [] | .[] | ... (L238-L246) then errors trying to iterate a truthy non-array value (// [] never triggers), and the error is swallowed by 2>/dev/null, yielding zero rule records — read-and-empty rather than invalid/incomplete. Same class as chore: initialize marketplace scaffold #1: a scope whose content was never actually surfaced reports clean. The same .permissions[$k] // [] | .[] pattern is reused verbatim for the Windows registry path at L389-L390, so it shares the defect.

  3. SUGGESTION (confidence: medium) — auto-mode drop classification isn't anchored to the rule's own tool.
    automode-entry-diff.sh#L196-L200 tests the drop-classification regexes against the whole rule string, not gated on the parsed tool. A non-shell allow rule whose payload contains shell-rule-shaped text as a substring (e.g. Read(notes/Bash(*)-guide.md)) matches (Bash|PowerShell)\(\*\) and is misreported as dropped class=blanket. Lower severity: unusual rule shape, and the error direction (falsely "dropped") is the safer-biased one, not a false-clean report.

  4. SUGGESTION (confidence: medium) — managed-conformance.sh's "enforced" verdict for a managed allow/ask is exact-text only, without disclosing that.
    managed-conformance.sh#L159-L167: a managed allow Bash(npm test) is checked against lower[...] by exact text, so a broader lower-scope deny (Bash(npm *)) or an invisible --disallowedTools rule (no local path for command-line scope) won't register as beaten, and the report prints an unqualified managed enforced allow ... — stronger than merge's own documented exact-text caveat. Error direction: overstates certainty, doesn't itself widen access (Claude Code's own deny-wins evaluation still applies at runtime).

None of the four are exploitable in the traditional sense — no attacker-controlled input reaches these paths, they operate on the operator's own local settings/managed policy. All four are about this audit tool giving false assurance to the administrator reading it, the specific failure mode this PR's design commentary and three-rounds-of-verification history say it exists to eliminate. #1 and #2 are worth fixing before merge; #3 and #4 are lower-stakes refinements. These align with Codex's four P1/P2 inline comments on this PR (managed-conformance.sh:164, permission-state.sh:215, managed-conformance.sh:108, automode-entry-diff.sh:200) — I independently re-traced each against current head rather than taking them on faith, and #1 above additionally identifies that the registry path's "reporting it as unread" note (permission-state.sh#L373) doesn't match the status actually emitted at L365, which Codex's comment didn't call out. Codex's fifth comment (plugin.json mojibake) and the permission-plane-lint.sh C6-winPath backslash false-positive it flagged are data-encoding / lint-accuracy issues, not security, and are out of scope for this lane; the latter has since been fixed to shape-based drive/UNC detection.

Everything else in scope — permission-merge.sh, permission-plane-lint.sh, automode-block-lint.sh, draft-automode-block.sh, the ADR, MIGRATION-PLAYBOOK.md, .gitattributes — is read-only reporting logic with correct jq/awk parameterization, or documentation, with no security implication.
· branch feat/audit-permission-state

kyle-sexton and others added 3 commits August 12, 2026 04:26
…laiming managed enforcement

A second verifier found a FOURTH file my merge resolution reverted, and it is
the worst of the four. main's classify_json_file has a second validation stage
after `jq empty`: it checks `.permissions` is null or an object whose values are
null or arrays. Taking --ours deleted it, so `["Bash(x)"]` and
`{"permissions":{"allow":"Bash(*)"}}` -- valid JSON, not settings files --
reported `status=present`. A malformed scope read as a healthy empty one, which
is the exact thing the status vocabulary exists to prevent.

The suite could not catch it. Its malformed fixture is SYNTACTICALLY broken,
which `jq empty` rejects on its own, so the assertion passed identically with
the stage gone -- a green suite over a reverted fix. The new fixture is
structurally valid and malformed, and it fails without the stage.

That is now two independent verifiers disagreeing about the same merge: one
proved the resolution clean by `git apply --reverse --check` over the conflict
set, the other found this by asking what a consumer hits on day one. The second
method won because the first tests whether main's PATCH still applies, and a
patch whose surrounding lines we rewrote no longer applies either way.

managed-conformance over-claimed enforcement. `lower[]` was keyed on exact rule
text, so a lower-scope WHOLE-TOOL rule was invisible: managed
`allow Bash(npm test)` under user `deny Bash` reported `enforced allow` while
the merge, on the same records, reported `inert allow removed_by=deny@Bash`.
Two stages, opposite answers, and the administrator-facing one was wrong. It now
tests the tool token as well, using the same tool_of() definition the merge uses
so the two cannot drift apart again.

Two write claims were false as written. "Writes nothing, in any scope, under any
flag" is contradicted by the skill's own --oracle cost notice; the contract that
actually holds is that no SETTINGS FILE is written, including under --oracle.
Both sites now say that, and say what the spawn does leave behind.

The registry test seam used `:-`, which treats set-but-empty as unset -- so
every test passing "" was probing the operator's REAL registry rather than the
empty surface it asked for. Now `-`.

Reader 55 checks (was 50), conformance 37 (was 32), plane lint 58, entry diff
63, merge 51, block lint 62, drafter 42, no-writes 12. shellcheck, portability
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…kind

My grammar exemption opened a fail-open hole, and the verifier found it by
asking the question I asked it to ask. `git` is a valid identifier, so
`deny Bash(git:* push)` parsed as the parameter form and went silent -- the
dead-rule example the permissions page itself gives, silenced in the kind where
silence costs most.

The direction is what makes this worse than the false positive it replaced. A
dead ALLOW fails closed: the operator is denied something they thought they had,
and finds out immediately. A dead DENY fails OPEN: they believe they blocked
`git push`, they did not, and nothing tells them. Six command-prefix shapes were
silent as denies.

The discriminator is the SPACE, and it is grammar rather than a name list, so it
does not reintroduce the allowlist the last fix removed. "Each rule names one
parameter" and its value is a single scalar, so a parameter value never carries
space-separated trailing words -- while a dead command prefix is precisely a
prefix FOLLOWED BY more words. Verified over the verifier's eleven-rule split:
all six space-carrying prefixes fire in deny, ask and allow; all five parameter
forms stay exempt in every kind.

The suite gap that let this through is closed: no case covered a mid-pattern
`:*` in a DENY rule, so 58/58 passed while the docs' own example went unreported.

Plane lint 62 checks, shellcheck and portability clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
…patterns

main's PR #2382 tightened the P1 runner alternative (a `([\"' :])` before the
`\*`, so `Bash(npmx *)` no longer matches the `npm` runner). This branch had
restructured the same four patterns into named sub-EREs so the entry diff can
report WHICH class dropped a rule.

Composed rather than resolved away. Taking --ours here would have reverted a
precision fix and been the fifth such clobber on this branch, so the resolution
is checked the only way that proves it: the composed $CCPERM_P1_ERE is
byte-identical to main's, which makes the restructure provably
behaviour-preserving rather than merely test-passing.

claude-config goes to 0.36.0, above main's 0.35.3, with every CHANGELOG heading
main shipped still present -- verified by set difference, since relabelling one
is exactly how this branch lost a released entry before.

The manifest was rebuilt from main's text rather than patched, because an
earlier Python edit of mine had corrupted its em-dashes to mojibake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 0s —— View job


Security Review

  • Gather context (gh pr diff 2396, changed files at head af123a7)
  • Check what changed since the last-reviewed head (a6b67a9)
  • Re-verify previously reported findings against current head
  • Review the merge-resolution / colonStar-fix commits for new issues
  • Post final findings

No injection, secret-handling, or GitHub Actions issues found. No workflow files changed. jq/awk calls throughout continue to pass untrusted values via --arg/--argjson/argv rather than string-interpolated eval; the two flags that spawn a real claude process (--oracle, --critique) remain opt-in and pass prompts as single quoted argv elements.

This diff is a security-reporting tool, so the trust boundary that matters, as in every prior pass on this PR, is whether a logic bug lets it overstate assurance to the administrator reading its output. Since the last review (a6b67a9), four commits landed touching this PR's file set: 696433c (fixes the C6-colonStar fail-open finding), and af123a7 (a merge of origin/main that pulls in 50f3ff2's and 3bd8238's prior fixes plus the mojibake fix, none of which are net-new to this branch's diff). I re-traced all five previously-reported items against af123a7 rather than taking prior "fixed" claims on faith.

Fixed since the last review:

  • C6-colonStar deny/ask fail-openpermission-plane-lint.sh#L262-L266 now requires the colon's value to carry no space (value_of(body) !~ /[ \t]/) before granting the parameter-form exemption. deny Bash(git:* push) — the exact previously-silent case — has value_of("git:* push") = "* push", which contains a space, so param_form is false and the check fires. Verified by tracing the new value_of() helper against both the broken and legitimate-parameter shapes.
  • Manifest mojibakeplugin.json's description now has real UTF‑8 em dashes, no â€" sequences (Codex's P2 finding).
  • Schema-invalid local settings filesclassify_json_file (permission-state.sh#L201-L237) now validates that .permissions is null/absent or an object whose values are null/array, closing the {"permissions":{"allow":"Bash(*)"}} false-present case for file-based scopes (user/project/local/enterprise/managed file, drop-ins). This does not extend to the Windows registry path — see below, still open.
  • Managed "enforced" whole-tool blind spotmanaged-conformance.sh now adds tool_of() (L84) and checks a lower-scope bare-tool deny/ask (e.g. deny Bash) against a managed allow Bash(npm test) (L173-L192) — this closes the exact case the merge stage vs. conformance stage disagreed on. It does not close the general pattern-subsumption case (see below).

Still open at af123a7 — same class as before, re-confirmed by re-tracing the current code (not re-run from memory):

  1. IMPORTANT — a present-but-uninventoried managed surface still yields an unqualified status=read, and I traced a second sub-case on the Windows path that's worse than previously described.

    • macOS: permission-state.sh#L431-L432 emits plist present while noting its rules are "NOT inventoried yet."
    • Windows: permission-state.sh#L375-L397registry_status="present" is set at L382 and emitted at L389, before the JSON-parseability check at L396 even runs. If the Settings value then fails to parse, the L397 note claims "reporting it as unread" — but the status already sent downstream is present, not unreadable/invalid-json. The note's claim doesn't match what was actually emitted.
    • managed-conformance.sh#L108,L113 counts any present surface toward n_managed_present and only routes skipped/unreadable/invalid-json into the unread[] warning path — present-but-contributing-zero-rules isn't one of them — so if the plist (or an unparseable registry value) is the only present managed surface, L224 unconditionally prints status=read enforced=0 loosenable=0 — "audited, nothing found" when the content was never examined. Confirmed no test in managed-conformance.test.sh covers a present-only-and-empty managed surface (existing "unread" cases at L217-L228 all use skipped/unreadable, never bare present).
  2. IMPORTANT — the schema-shape validation added this round doesn't cover the Windows registry's own JSON payload, so it inherits the exact defect class classify_json_file was just fixed for.
    permission-state.sh#L394-L417: reg_json is checked only with jq empty (syntax) at L396 — no equivalent of the new .permissions shape check. A registry Settings value like {"permissions":{"allow":"Bash(*)"}} passes, and (combined with finding 1's status-already-emitted issue) the scope reports present/status=read while .permissions[$k] // [] | .[] (L412-L414) errors on the non-array value (// [] never triggers on a truthy string) and silently yields zero rules.

  3. SUGGESTION — auto-mode drop classification is still unanchored to the rule's own parsed tool.
    automode-entry-diff.sh#L198-L202 tests CCPERM_P1_BLANKET_ERE/INTERP_ERE/RUNNER_ERE against the whole rule string, not gated on the parsed tool. A non-shell allow whose payload substring looks shell-rule-shaped (e.g. Read(notes/Bash(*)-guide.md)) still misclassifies as dropped class=blanket. Unchanged since the first review round — only a doc comment in this file changed this round (L78-L81, unrelated to this logic). Lower severity — the error direction (over-reporting "dropped") is safer-biased, not a false-clean report.

  4. SUGGESTIONmanaged-conformance.sh's "enforced" verdict is narrower-but-still-incomplete, and still undisclosed.
    The new tool_of() check (L173-L192) only catches a lower-scope bare whole-tool deny/ask (e.g. deny Bash). It still does not catch a lower-scope wildcarded deny that subsumes the managed rule without being the whole-tool form — e.g. managed allow Bash(npm test) vs. a lower-scope deny Bash(npm *) — or an invisible --disallowedTools command-line rule (no local path for that scope, per permission-merge.sh's own documented caveat). Those cases still fall through to beaten == "" and print an unqualified managed enforced allow .... No MANAGED-NOTE discloses this remaining exact-text/whole-tool-only limitation anywhere in the file. Error direction: overstates certainty, doesn't itself widen access (Claude Code's runtime deny-wins evaluation still applies).

None of the four are attacker-reachable — no untrusted input reaches these paths, they operate on the operator's own local settings/managed policy. All four are about this audit/lint tool under-reporting risk or over-stating assurance to the administrator, the specific failure mode this PR's design commentary and its multiple rounds of independent verification say it exists to eliminate. #1 and #2 are the same root cause (a "present" status asserted before/without content validation) surfacing on the two platform-specific managed surfaces this reader touches; #3 and #4 are the lower-stakes refinements carried forward from earlier rounds, largely unchanged.

Everything else in the current diff — permission-merge.sh, permission-plane-lint.sh's other checks (including the C6-winPath shape-based fix and the new value_of() helper, which introduces no injection surface), automode-block-lint.sh, draft-automode-block.sh, the ADR, MIGRATION-PLAYBOOK.md, .gitattributes, and the permission-patterns.sh/permission-rule-check.sh precision tightening pulled in via the origin/main merge — is read-only reporting logic with correct jq/awk parameterization, or documentation, with no security implication.
· branch feat/audit-permission-state

…cidable residual

`allow Agent(model:*-haiku)` drew two findings with two mechanics, and the
colonStar one explained it as a dead Bash COMMAND PREFIX. Agent does not take
command prefixes, so that explanation is simply wrong. The rule is dead either
way -- allow plus parameter form is ignored -- and C6-allowParam already says
so correctly, so the fix is to suppress the second, not to add a third.

Suppression is scoped to the parameter shape rather than to the allow kind: a
real dead command prefix in an allow rule (`allow Bash(git:* push)`) still
fires colonStar, which the regression asserts.

The remaining fail-open window is recorded as a known gap rather than quietly
accepted. In a deny or ask rule, a mid-pattern `:*` with NO space after it --
`Bash(git:*push)` -- goes unreported, because it is structurally identical to
the parameter form `Agent(model:*-haiku)`. Once the space is gone there is
nothing in the rule text to tell them apart; the space was the only signal, so
this is undecidable by grammar rather than merely unimplemented. The documented
example is the space form, and the pages show no no-space mid-pattern rule
anywhere.

Saying that in criteria.md is the honest treatment: a reader who hits the case
learns the check has a boundary and why, rather than trusting silence.

Plane lint 66 checks, shellcheck and portability clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 40s —— View job


Security Review

  • Gather context (diff since last-reviewed head a6b67a9/af123a7 → current HEAD 0573f80)
  • Review the new commit 0573f80 ("one rule, one explanation — and document the undecidable residual")
  • Re-verify previously reported findings (1-4) against current head
  • Post final findings

No new injection, secret-handling, or GitHub Actions issues. No workflow files changed. The only commit since the last-reviewed head (af123a7) touches three files: permission-plane-lint.sh, its test, and criteria.md.

Commit 0573f80 reviewed — not a security-relevant change, no new issue introduced.
permission-plane-lint.sh#L260-L263 changes C6-colonStar's allow-rule exemption from "never exempt allow rules" to "exempt only when the shape also matches a real param_only[tool,param] entry":

is_param_shape = (prefix_of(body) ~ /^[A-Za-z_][A-Za-z0-9_]*$/) && (value_of(body) !~ /[ \t]/)
param_form = (kind != "allow") ? is_param_shape \
  : (is_param_shape && (tool SUBSEP prefix_of(body)) in param_only)

I traced whether this could open a gap where a dead allow rule now gets neither C6-colonStar nor C6-allowParam (a false-clean report, the failure class this PR's history repeatedly flags). It can't: the suppression only activates when (tool, prefix_of(body)) is a member of param_only, and C6-allowParam's own firing condition (L317) is exactly kind == "allow" && (tool SUBSEP param) in param_only over the same tool/param pair (verified param at L305-L306 and prefix_of(body) compute identically from the same first-colon split). So every rule that loses C6-colonStar under the new gate is guaranteed to gain C6-allowParam instead — this is a de-duplication of two findings on the same dead rule (and a correction of C6-colonStar's wrong "dead Bash command prefix" explanation for a tool like Agent that has no command prefixes), not a coverage change. deny/ask rules are untouched by this diff (param_form for kind != "allow" is unchanged), and the new test cases (permission-plane-lint.test.sh +27 lines) confirm both directions: the parameter-shaped allow rule now yields exactly one finding (C6-allowParam), and an allow-rule command prefix that is not a real parameter (Bash(git:* push)) still fires C6-colonStar as before.

Previously reported findings — unchanged since the last review, still present at 0573f80 (the files they live in, permission-state.sh, managed-conformance.sh, automode-entry-diff.sh, were not touched by this commit):

  1. IMPORTANT — a present-but-uninventoried managed surface (macOS plist, or a Windows registry value whose present status is emitted before its JSON-parseability is checked) still yields an unqualified status=read enforced=0 loosenable=0 in managed-conformance.sh, because only skipped/unreadable/invalid-json route into the unread[] warning path — present-but-contributing-zero-rules does not. permission-state.sh#L431-L432, permission-state.sh#L375-L397, managed-conformance.sh#L108.
  2. IMPORTANT — the Windows registry JSON payload is validated only with jq empty (syntax), not the schema-shape check now applied to file-based scopes, so a non-array permissions.allow/ask/deny in the registry still silently yields zero rules instead of an invalid/incomplete report. permission-state.sh#L394-L417.
  3. SUGGESTION — auto-mode drop classification in automode-entry-diff.sh still tests the whole rule string rather than gating on the parsed tool, so a non-shell allow whose payload substring looks shell-rule-shaped could misclassify as dropped. Safer-biased error direction (over-reports dropped, not kept). automode-entry-diff.sh#L198-L202.
  4. SUGGESTIONmanaged-conformance.sh's "enforced" verdict for a managed allow/ask is still exact-text only (plus the whole-tool check added earlier), so a pattern-subsuming lower-scope deny or an invisible --disallowedTools rule still isn't caught, and no MANAGED-NOTE discloses the remaining limitation. Overstates certainty, doesn't itself widen access. managed-conformance.sh#L173-L192.

None of the four are attacker-reachable — all operate on the operator's own local settings/managed policy, not untrusted input. They're carried forward unchanged from the prior review round; #1 and #2 are the ones worth fixing before merge.
· branch feat/audit-permission-state

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 8m 13s —— View job


Code Review (correctness / architecture / maintainability lane)

  • Gather context (PR diff, CLAUDE.md, REVIEW.md)
  • Review new scripts for correctness/architecture (permission-plane-lint.sh, automode-entry-diff.sh, managed-conformance.sh, automode-block-lint.sh, draft-automode-block.sh)
  • Review modified scripts (permission-merge.sh, permission-state.sh, permission-patterns.sh)
  • Review test coverage (*.test.sh, no-writes.test.sh)
  • Review SKILL.md / docs / ADR for consistency with implementation
  • Post inline findings and summary

Per REVIEW.md, this lane owns correctness/design/conventions/error-handling/tests/docs and leaves security (vulnerabilities, authz, secrets, injection) to the dedicated security lane, which has already run five independent rounds on this PR and covered the "false clean report" class of bug (uninventoried managed surfaces, schema-invalid rule lists, unanchored drop regexes, exact-text-only "enforced" verdicts). I did not re-report any of that — this pass looked for issues outside that scope.

Two inline findings posted (both suggestion/nit level — nothing blocking):

  1. permission-plane-lint.sh:319checks_run=9 is a hand-maintained literal, not derived from the actual check set, and the test only asserts the substring — a future check add/remove could silently desync it from reality.

One additional finding that predates this PR's diff (the arg-parsing block itself wasn't touched by this PR, so no changed line could carry it — noted here per REVIEW.md's cross-file/whole-PR routing):

  1. permission-state.sh:83-97 — this script silently accepts any unrecognized first argument (falls through to mode="full") instead of erroring. Every one of the six sibling scripts this PR adds or touchesautomode-block-lint.sh:84, automode-entry-diff.sh:99, managed-conformance.sh:58, permission-merge.sh:69, permission-plane-lint.sh:62, draft-automode-block.sh:75 — reject an unrecognized argument with ERROR: unknown argument '$1' + exit 2, each with a matching assert_exit ... 2 test; permission-state.sh has neither. Concrete scenario: SKILL.md has the invoking agent construct this command line itself, so permission-state.sh --scope (typo for --scopes) silently returns the full inventory instead of the terse surfaces-only output, with no signal anything was misspelled — the exact "confident but wrong" failure mode this PR's design commentary repeatedly says it exists to eliminate. Worth a follow-up patch even though it's outside this PR's diff.

Cross-file maintainability note (not inline-able — spans four files, none of which individually "owns" the fix):
text_of()/tool_of() — the awk helpers that split a rule record into scope/kind/text and split a rule's text into tool+payload — are duplicated near-verbatim across permission-merge.sh:90-100, managed-conformance.sh:75-84, permission-plane-lint.sh:87,213 (tool split inlined rather than via a named function), and a fourth bash-native reimplementation in automode-entry-diff.sh:186. lib/permission-patterns.sh's own header argues at length for centralizing exactly this kind of shared vocabulary to prevent drift ("a second copy... would drift the moment one side learned a new interpreter"), but the tool/text-splitting primitive has no equivalent shared home. Not a current bug — all four copies agree today — but a real risk if the rule-record grammar ever changes (e.g. to support an escaped ( in a payload), since nothing would force all four sites to update together. Low priority; flagging for awareness rather than requesting a blocking change, since awk-embedded-in-bash makes a clean shared-source fix nontrivial (would need an awk -f snippet file or similar).

Confirmed clean against REVIEW.md's two standing code-review checks:

  • Audit-log-entry check: not applicable — no script here performs a high-risk security action; all are read-only reporting.
  • Atomicity-for-multi-write check: not applicable, and the "writes nothing" claim itself is mechanically tested rather than asserted — no-writes.test.sh snapshots a fixture tree + fixture HOME and diffs before/after running all ten actions of both skills (including the --oracle subprocess path), and draft-automode-block.test.sh independently confirms the drafter changes nothing in CWD or a fixture settings file.

Test coverage for the new logic is otherwise thorough — the major branches (oracle ambiguous-line parsing, status=partial/unexpected-shape/unavailable, managed whole-tool loosenable cases, all nine plane-lint checks) each have dedicated assertions. SKILL.md and criteria.md descriptions of script behavior/flags/output format match the implementation as read.
· branch feat/audit-permission-state

The typo gate flagged six occurrences across the block lint, its suite, and the
drafter's SKILL.md. `status=unparsable` is an output string, so the script and
the assertions that read it change together or the suite breaks.

The one occurrence in source-control is main's and untouched here.

Block lint 62 checks, shellcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 42s —— View job


Security Review

  • Gather context (diff since last-reviewed head 0573f80 → current HEAD a4437e8)
  • Review the new commit a4437e8 ("spell it 'unparsable'")
  • Re-verify previously reported findings (1-4) against current head
  • Post final findings

No new issues. The only commit since the last review round is a4437e8, a pure spelling fix (unparseableunparsable) touching automode-block-lint.sh, its test, and a doc string in draft-auto-mode-rules/SKILL.md. It changes string literals only — no logic, no control flow — and has no security implication.

Previously reported findings — unchanged, still present at a4437e8 (the files they live in — permission-state.sh, managed-conformance.sh, automode-entry-diff.sh — are untouched since the last review, so line numbers are identical):

  1. IMPORTANT — a present-but-uninventoried managed surface (macOS plist, or a Windows registry value whose present status is emitted before its JSON-parseability is checked) still yields an unqualified status=read enforced=0 loosenable=0 in managed-conformance.sh, because only skipped/unreadable/invalid-json route into the unread[] warning path — present-but-contributing-zero-rules does not.
    permission-state.sh#L431-L432, permission-state.sh#L375-L397, managed-conformance.sh#L108.

  2. IMPORTANT — the Windows registry JSON payload is still validated only with jq empty (syntax), not the schema-shape check applied to file-based scopes, so a non-array permissions.allow/ask/deny in the registry silently yields zero rules instead of an invalid/incomplete report.
    permission-state.sh#L394-L417.

  3. SUGGESTION — auto-mode drop classification in automode-entry-diff.sh still tests the whole rule string rather than gating on the parsed tool, so a non-shell allow whose payload substring looks shell-rule-shaped (e.g. Read(notes/Bash(*)-guide.md)) could still misclassify as dropped. Safer-biased error direction (over-reports dropped, not kept).
    automode-entry-diff.sh#L198-L202.

  4. SUGGESTIONmanaged-conformance.sh's "enforced" verdict for a managed allow/ask is still exact-text (plus the whole-tool check added earlier), so a pattern-subsuming lower-scope deny (e.g. managed allow Bash(npm test) vs. lower-scope deny Bash(npm *)) or an invisible --disallowedTools rule still isn't caught, and no MANAGED-NOTE discloses the remaining limitation. Overstates certainty, doesn't itself widen access.
    managed-conformance.sh#L173-L192.

None of the four are attacker-reachable — all operate on the operator's own local settings/managed policy, not untrusted input. They're carried forward unchanged from prior rounds; #1 and #2 (both "present status asserted before/without content validation") are the ones worth fixing before merge; #3 and #4 are lower-stakes refinements.

No GitHub Actions workflow files are part of this diff, so zizmor's advisory lane doesn't apply.
· branch feat/audit-permission-state

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude has reviewed this PR 2 times. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 1s —— View job


Code Review (correctness / architecture / tests / maintainability)

  • Gather context (PR diff, changed files, CLAUDE.md / REVIEW.md)
  • Review docs (SKILL.md files, criteria.md, ADR 0009, CHANGELOG, plugin.json) — consistent, no issues found
  • Review new scripts for correctness (automode-entry-diff.sh, automode-block-lint.sh, permission-plane-lint.sh, managed-conformance.sh, draft-automode-block.sh)
  • Review modified scripts (permission-merge.sh, permission-state.sh) and their tests
  • Post inline findings and summary

Note: security-relevant findings are covered by the dedicated security-review lane (already posted above, several rounds) and are out of scope for this lane per REVIEW.md's lane split.

Deep script review in progress.

kyle-sexton and others added 2 commits August 12, 2026 05:07
…-path gate

The editorconfig failure was a real defect wearing a whitespace costume. Two
fixtures had been written by a script that turned `\n` into a literal newline,
so `Bash(echo \n *)` became two lines and `printf 'section \n...'` became four.
Both suites still passed -- the fragments happened not to match anything -- so
the trailing-whitespace error was the only signal that a test was asserting
against input it was no longer feeding. Restored to single lines; 66 and 42
checks still pass, now over the fixtures they were written for.

The machine-specific-path gate flags this skill for the reason the gate exists:
C6-winPath's whole job is finding Windows paths in permission rules, so its
finding message quotes one, criteria.md quotes the settings page quoting one,
and its fixtures must carry them or the check has nothing to detect. The oracle
fixtures reproduce Claude Code's own [DEBUG] narration, which prints native
Windows settings paths -- rewriting those would make the fixture stop matching
what the parser must survive on the plugin's primary platform.

That is the same class as the two exclusions already in this lane (the
guardrails path-detection lib self-matches the detector; the autonomy fixtures
carry adversarial host paths on purpose), so it takes the same treatment: four
named files, with the reason recorded beside them. The paths are invented --
`alice` and `x` -- not any operator's.

actionlint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgD5TknENb3ifk1a47eqUr
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Warning

Automated security review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

The check is green on purpose, and it is not evidence. It certifies that a security pass ran, and this one did not complete — but the cause is outside this PR's control, so merging is deliberately left unblocked rather than locking every merge for the length of the outage. Nothing was reviewed at this head. Where this check is required, it is satisfied without that evidence; a human should review security-sensitive changes here before merging.

Re-run the job to retry the review; a new push also retries it only if the caller's pull_request triggers include synchronize (the canonical security caller keeps it). An automatic retry may already have run — it is skipped when a partial review could duplicate comments, or when the failure class needs an operator.

Re-running does NOT help for every class:

  • rate-limit that persists across re-runs, or auth — the credential or usage budget needs an operator; retrying will not clear it.
  • a run that exhausted its turn budget ("subtype":"error_max_turns" above) will exhaust it again. As the PR author, split the change into smaller PRs; raising --max-turns is a change to the caller workflow, not something you can set on this PR.

@kyle-sexton
kyle-sexton merged commit e3c577d into main Aug 12, 2026
34 checks passed
@kyle-sexton
kyle-sexton deleted the feat/audit-permission-state branch August 12, 2026 09:56
@github-actions

Copy link
Copy Markdown

Warning

Automated review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-run the job to retry the review. A new push re-triggers this lane only if the caller's pull_request triggers include synchronize (the canonical caller omits it).
An automatic retry may already have run — it is skipped when a partial review could duplicate comments, or when the failure class needs an operator (auth).

kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…make audit-prompting-postures' contract agree with itself (#2408)

Closes #2281
Closes #2283

> **Scope note.** #2281's eight rows are all taken, with **CC-F11
partial and said so below**.
> #2283 takes four of five — **A8 is declined on its rationale** and is
filed as **#2406** with the
> corrected mechanism, so neither closing keyword drops a reproducing
defect. Two follow-ups filed:
> **#2406** (A8) and **#2415** (`audit-pass`'s missing
`disallowed-tools`, blocked on #2403).

## Summary

Two `claude-config` skills whose contracts contradicted themselves.
Released together as **0.37.0**
(minor: new output on every detector run, new env-var surface). #2382
(0.35.3), #2403 (0.35.4) and
#2396 (0.36.0) all merged while this was in flight; 0.37.0 sits above
all three and the CHANGELOG
order gate is green.

> **Review round: 7 P2 threads, all real, all fixed, replied to
individually and resolved** (verified
> unresolved=0 via GraphQL, not inferred). Four were *this PR's own
defect class turned on this PR*,
> which is itself the finding — so they are named rather than folded in
silently:
>
> | # | Finding | Instance of |
> |---|---|---|
> | 1 | P3 axis missing from `audited` | denominator counting only
successes (2nd) — already fixed at HEAD; thread was outdated |
> | 2, 5 | A candidate `find` can list but the process cannot **read**
is counted nowhere | same shape (3rd) — two reviewers converged
independently |
> | 6 | `audited` counts "produced a finding" on two axes, "examined" on
the third | same shape (4th) |
> | 3 | `disallowed-tools` claimed to make read-only "a property of the
tool set" | **a false assurance claim, mine** |
> | 7 | Phase D "dropped **or** demoted" vs the new schema's "kept for
the record" | contract disagreeing with itself, introduced by its own
fix |
> | 4 | P7 searched rules + hooks but not the **script gate** its own
catalog blesses | same, introduced by its own fix |
>
> After three instances of one shape, the completeness property stopped
being asserted per-site and
> is **derived once**: four exhaustive buckets, reconciled against the
enumeration on every run, with
> a negative test that deletes a bucket increment and asserts the check
fires. The denominator's unit
> is stated once for all three axes — *an input successfully read and
examined, never one that
> produced something* — and printed on every run.

### `audit-permission-grants` — reports a clean bill with no denominator
(#2283)

**A5 — the headline.** `No fragile permission grants found.` printed
identically whether the run
parsed forty `allowed-tools` blocks and found them healthy or parsed
none at all. Every run now ends
with a coverage block, and a run whose denominator is zero prints
`NOTHING TO AUDIT` instead.

**The denominator counts what was *not* read, because one built only
from successes is the same
defect in a new spelling.** Establishing that turned up two fail-open
paths that were not in the
issue, both folded in:

- A settings file present but **not valid JSON** was skipped by a silent
`|| return 0`. Its rules
were never read and the run still printed a clean bill — and an
unparsable rules file is exactly
  where a fragile grant would sit unexamined. Now reported per scope as
  `NOT VALID JSON — its rules were not read`.
- Both `find` walks discarded stderr. This script's own header already
argues against that: *"a
swallowed permission error was indistinguishable from a clean bill."*
Unreadable paths are now
  captured and counted.

The `vendor/` exclusion moved out of the `find` predicate into the loop
so the run can report how
many files it removed. Same predicate, same result set — but an
exclusion whose count is printed
cannot suppress silently. `--count` keeps the bare integer on stdout
(the machine contract) and puts
the block on stderr.

**A11.** `$PERMISSION_HYGIENE_SCAN_ROOT` is now the sanctioned name for
the one scoping lever, with
`$PERMISSION_HYGIENE_FIXTURE_DIR` kept as a back-compatible alias (new
name wins when both are set).
#2249 made that variable the documented operator remedy for the exit-2
refusal while its name still
told them it was a test seam; `reference/criteria.md`, which never
mentioned it at all, now sanctions
it explicitly.

**A15.** Consumer-declared exemptions must disclose themselves, may
**widen** the fragile set but
never delete a finding, and a run where every finding is exempted says
so instead of printing a clean
bill. The audited repo authors those declarations — the threat model the
docs name directly (*"Review
project skills before trusting a repository, since a skill can grant
itself broad tool access"*,
fetched 2026-08-12). The report schema grows an `Exempt?` column to hold
it.

**A16 — the filed remedy declined, with the measurement.** The row says
the four scope filters are
"advertised but not implemented". They *are* implemented, as a
presentation filter, and `SKILL.md`
says so at `:70-71`. The real defect is that the argument hint reads
like a scan-scope. The filed fix
(detector flags) rests on a cost claim that no longer holds: since #2249
the root is a git toplevel,
`$CLAUDE_PROJECT_DIR`, or an explicitly named directory — never an
unbounded sweep — and I measured
the two walks over this repository at **0.49 s** and **0.41 s**. Flags
to skip half a second would
buy nothing and add a second place for scope to be defined. Fixed the
wording instead, adopting the
formulation both sibling audit skills already ship.

**A8 — declined, filed as #2406.** It reasons from the `vendor/`
exclusion's own justification ("not
loadable, so the grant never takes effect") to `node_modules/`,
worktrees and marketplace mirrors.
That step is false. <https://code.claude.com/docs/en/skills>, fetched
2026-08-12:

> Skills also load from nested `.claude/skills/` directories below your
working directory. When
> Claude reads or edits a file in a subdirectory, skills from that
subdirectory's `.claude/skills/`
> become available.

So `node_modules/<pkg>/.claude/skills/<name>/SKILL.md` **is** loadable,
and the exclusion would make
P2 — an `error`-tier check whose whole subject is a leaked username —
silently blind to a live grant.
Same failure shape as the `//` path exemption on #2382. The defensible
half (every exclusion reports
its own count) ships here; the rest needs a loadability model, which
#2406 specifies.

### `audit-prompting-postures` — contract disagrees with itself in eight
places (#2281)

All eight rows taken (CC-F11 partial — see its bullet). Prose-only, so
there is no behavioral test to
write and I am not inventing one — same posture #2403 took on the
sibling skill.

- **CC-F3.** P7 blesses a deny-by-default hook or script gate as
presence evidence "without any
prose", while Phase B inventories instruction *text* — so the one
evidence form P7 names was the
one form Phase B could not see, on the posture whose false MISSING is
most expensive. Split the
two: the inventory bounds what may produce a finding, not what counts as
evidence, and Phase C now
looks for the gate before judging P7 — in all three places the catalog
blesses: settings rules,
hook configuration, and, **after review caught the procedure searching
only the first two**, the
script the component delegates the destructive step to, followed and
read. A component whose
destructive action runs through a gating script is gated and nothing in
its own text says so.
Tightened `destructive-capable` from "**can** delete, reset, force-push"
— which matches every
component with a shell — to what the body has the model DO, per the
classification section's own
  opening line.
- **CC-F5 + CC-F10, written as one edit** because both move the same
seam and landing them
separately would ship a Phase A that disagreed with itself. The
best-practices page is fetched
every run and its failure **aborts** (single non-negotiable input; ten
`wording-unverified`
postures is a report shaped like an audit that audited nothing). Model
subpages are fetched lazily
in Phase C per applicable row and fail locally — which is what the
observed run already did and the
wording forbade. The verdict schema was closed at three tokens while the
body mandated two more; it
now carries four verdicts including `info`, with `wording-unverified` /
`(unverified)` named as
  markers that ride alongside a verdict rather than replacing one.
- **CC-F6.** The surface set is named in this skill instead of inherited
by reference from a sibling
that versions independently — the coupling that let `output-styles`
become inventoried here and
unnameable by this skill's own filter. `output-styles` is now a scope
token.
- **CC-F7.** P8 carries the model condition the skill's own gotcha
mandates. **Leg the issue marked
unverified, re-fetched by me 2026-08-12:** the pointed-at section scopes
context awareness to
"Claude Sonnet 5, Claude Sonnet 4.6, Claude Sonnet 4.5, and Claude Haiku
4.5".
- **CC-F4 — and the false assurance claim review caught in it.**
`disallowed-tools: Edit,
NotebookEdit` is declared, but an earlier draft of this PR claimed it
made "never edits a
component" *a property of the tool set*. **That was false and is removed
rather than softened.**
`Write` is retained for the mandated persist and Phase B has already
read every audited component,
so it can overwrite one; `Bash` is retained for the state key, and a
shell mutates files too. The
declaration narrows the accident surface, it does not enforce the
contract — both skills now say
so, and both forbid telling an operator the skill *cannot* edit their
files. A skill whose subject
is auditing assurance must not ship a false assurance claim about
itself; the CHANGELOG records
that the earlier claim was wrong instead of quietly shipping the
corrected text. **Second
unverified leg, re-fetched 2026-08-12** — the frontmatter reference's
semantics, including *"The
restriction clears when you send your next message"*, which is the right
lifetime: whoever accepts
  a proposal can apply it.
- **CC-F8 — the issue's stated mechanism is wrong at HEAD, and I fixed
the real one.** The issue
says `grep -c "audit-prompting-postures"
.../audit-instructions/SKILL.md` → **0**, "the token
appears nowhere in the sibling". At HEAD it returns **1**, at
`audit-instructions/SKILL.md:392`, in
a state-key aside. The grep claim is false; the substantive claim
survives, because a mention in an
aside is not a route-out — `audit-instructions`' Scope boundary section
still never named this
skill. Added that route-out line. Also added two evals: one whose prompt
carries **no slash
invocation**, so description-driven selection is exercised for the first
time (all five existing
cases invoke explicitly), and one pinning CC-F3's mechanical-gate rule.
The
description-drives-discovery claim was itself an unverified leg —
**third one re-fetched
  2026-08-12** (`skills.md:259`, `:425-426`).
- **CC-F11 — partial, and the partial goes one way I should name.** The
uninstall half is fixed: the
state key stops overwrites, not reaping, with the sentence quoted and
`--keep-data` named
(re-fetched 2026-08-12). The row's other two observations are
**recorded, not fixed** — and one
moved the wrong way. `when_to_use` is still unused, and the description
grew from **1,290 to
1,305** of its 1,536-char cap to carry `output-styles` for CC-F6, which
is the opposite direction
from the row's headroom note. That trade is deliberate (a scope token
that is unnameable is the
actual defect; 231 chars of headroom is not), but "all eight rows taken"
would have been the wrong
  sentence to leave standing.

CC-F9 is correctly **not** touched: the issue records it as falsified,
and it is.

### One divergence this PR would otherwise have created

CC-F4 declares `disallowed-tools: Edit, NotebookEdit` on
`audit-prompting-postures`.
`audit-instructions` states the **identical** report-only contract
("There is no `--fix`… never by
this skill") and names neither `Edit` nor `Write` anywhere in its body —
so declaring it on one of
the pair and not the other would have opened a fresh instance of exactly
the sibling divergence
CC-F6 is about, in the release that fixes CC-F6. It is declared on both,
in the file this PR was
already editing. `audit-pass` states the contract too and is **not**
touched: PR #2403 owns that
file right now, so it is filed as **#2415** rather than collided with.

## Test plan

**Fail-before / pass-after for A5.** Three roots that `origin/main`
(`5ea4f87f`) describes with one
identical string, run against the old and new detectors:

```
==================== BEFORE — origin/main (5ea4f87) ====================
--- root with 0 grants ---
No fragile permission grants found.
--- root with 2 healthy grants ---
No fragile permission grants found.
--- root whose only rules file is invalid JSON ---
No fragile permission grants found.
--- --count ---
  empty=0   healthy=0   badjson=0

==================== AFTER — this branch ====================
--- root with 0 grants ---
NOTHING TO AUDIT: 0 allowed-tools block(s) and 0 allow rule(s) were read under this root, so this
run has no denominator. That is a scan of nothing, not a clean bill — do not report it as one.

Scan coverage (the denominator — what this run actually read):
  root: /tmp/tmp.EbQtjPHKzq/empty-root (resolved from $PERMISSION_HYGIENE_FIXTURE_DIR (alias of $PERMISSION_HYGIENE_SCAN_ROOT))
  frontmatter: 0 allowed-tools block(s) parsed from 0 candidate file(s); 0 excluded under a vendor/ path segment as non-loadable
  settings: 0 allow rule(s) from 0 scope(s) read — project: absent; local: absent; user-global (...): absent
  plugins: 0 manifest(s); 0 settings.json parsed
  NOT read: 0 path(s) the walk could not open; 0 settings file(s) and 0 plugin settings.json present but not valid JSON
  never in scope here: managed-policy and enterprise settings, a --settings flag file, and the pre-v2.1.211 start-directory copy...

--- root with 2 healthy grants ---
No fragile permission grants found.

Scan coverage (the denominator — what this run actually read):
  ...
  settings: 2 allow rule(s) from 1 scope(s) read — project: 2 rule(s); local: absent; user-global (...): absent
  ...

--- root whose only rules file is invalid JSON ---
NOTHING TO AUDIT: 0 allowed-tools block(s) and 0 allow rule(s) were read under this root...

Scan coverage (the denominator — what this run actually read):
  ...
  settings: 0 allow rule(s) from 0 scope(s) read — project: NOT VALID JSON — its rules were not read; local: absent; ...
  NOT read: 0 path(s) the walk could not open; 1 settings file(s) and 0 plugin settings.json present but not valid JSON
  ...
--- --count (stdout only) ---
  empty=0   healthy=0
```

The `--count` line is the point restated: stdout is still `0` in both
cases (the machine contract is
unchanged), and stderr now separates them.

**The denominator got the same defect wrong four times, and the fourth
is why it is now structural.**
Recorded in full because the pattern is more useful than any one
instance:

1. `audited` omitted the P3 axis, so a root of clean plugin
`settings.json` printed `NOTHING TO
AUDIT` two lines above `plugins: 2 manifest(s); 2 settings.json parsed`.
Caught in self-review.
2. A candidate `find` can *list* but the process cannot *read* — `find`
needs only directory
traversal to report `-type f` — reached `awk`, failed, wrote to the real
stderr rather than
`WALK_ERR`, and was counted in no bucket, while the coverage block
promised to disclose exactly
   that input. Caught by two reviewers independently.
3. `audited` counted "produced a finding" on the frontmatter and
settings axes but "examined
successfully" on P3's, so a `SKILL.md` with no `allowed-tools` and a
`settings.json` with an empty
`allow` array contributed nothing despite being read and found to grant
nothing.

Three instances means the invariant was maintained *by convention at
each `continue`*, so it is now
derived once. Every enumerated candidate lands in exactly one of four
buckets — vendor-excluded,
unreadable, no `allowed-tools` block, parsed — and
`reconcile_frontmatter` checks they sum to the
enumeration on every run, printing `DENOMINATOR BUG` and naming
**itself** as the defect when they do
not. A check that cannot fail is not a check, so a negative test deletes
a bucket increment from a
copy of the script and asserts the reconciliation fires. And the unit is
stated once for all three
axes — *an input successfully read and examined, never one that produced
something* — and printed:

```
  DENOMINATOR = 3 input(s) successfully examined: 2 frontmatter file(s) + 1 settings scope(s) + 0
  plugin settings.json. The unit on every axis is "read and examined", never "produced a finding".
  reconciled: 3 candidate(s) = 1 vendor-excluded + 0 unreadable + 1 without an allowed-tools block + 1 parsed
```

Extraction stderr now joins the walk's rather than escaping to the
terminal, and a run that audited
nothing **and** could not open its own inputs says so distinctly instead
of reporting an empty tree.

**Suite: 76 → 102 on this branch's own base, then 121 after the review
round, all passing.**
26 of those are this branch's, across the denominator (including the P3
axis), the
unparsable-settings skip, the `--count` stdout/stderr split, the
exclusion disclosure, and the
scan-root rename; the rest are #2382's, which landed mid-flight.

**Merge note — and the trap it walked into.** #2382 (0.35.3), #2403
(0.35.4) and #2396 (0.36.0) all
merged into `main` while this was in flight, twice leaving the PR
`CONFLICTING`. **A conflicted merge
ref suppresses the `pull_request`-triggered runs entirely**, and the PR
then displayed **3 checks,
all passing** instead of 33 — nothing distinguishes that from a real
green except counting the rows.
Caught by comparing against #2382's 34, not by reading the failure
count.

Resolved with merge commits rather than repeated rebases, and verified
marker-free across the whole
tree before each commit:

- `plugin.json` — **0.37.0**, because #2396 took 0.36.0 (the version
this PR originally claimed) and
also rewrote the description to "Nine configuration-health skills".
Theirs kept, version raised.
- `CHANGELOG.md` — 0.37.0 / 0.36.0 / 0.35.4 / 0.35.3 / 0.35.2;
`--check-order` green.
- `permission-rule-check.test.sh` — both sides pure additions, both
kept.
- `permission-rule-check.sh` and `criteria.md` auto-merged with **no**
conflict: #2382's hunks are
the P2 pattern block and P2's criteria section, neither of which this PR
touches.
- #2396 also changed `lib/permission-patterns.sh`, which this detector
sources — so the suite was
re-run against the merged library, not just against this branch's own
base. Still passing.

```
$ bash plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh
PASS: empty root reports NOTHING TO AUDIT
PASS: empty root does NOT print a clean bill
PASS: empty root still prints the coverage block
PASS: empty root denominator names zero blocks
PASS: healthy root prints the clean bill
PASS: healthy root is not NOTHING TO AUDIT
PASS: clean bill carries a non-zero rule count
PASS: a clean P3-only root is a clean bill
PASS: a clean P3-only root is NOT a scan of nothing
PASS: coverage counts the plugin settings it parsed
PASS: coverage names the project scope it read
PASS: coverage names an absent scope as absent
PASS: unparsable settings file is named, not skipped in silence
PASS: unparsable file is counted under NOT read
PASS: a run whose only rules file will not parse is not a clean bill
PASS: --count stdout is still the bare integer
PASS: --count writes the coverage block to stderr
PASS: --count coverage carries the denominator
PASS: vendor exclusion discloses its count
PASS: coverage names the candidate file total
PASS: PERMISSION_HYGIENE_SCAN_ROOT resolves a root
PASS: coverage names the rung that resolved the root
PASS: the legacy alias still resolves a root
PASS: the sanctioned name wins over the alias
PASS: the alias did not win
PASS: refusal names the sanctioned variable as the fix
...
All 121 checks passed.
```

**The A16 measurement, since it is what declines the row:**

```
$ time (find . -type f \( -name 'SKILL.md' -o \( -name '*.md' -path '*/agents/*' \) \
        -o \( -name '*.md' -path '*/commands/*' \) \) ! -path '*/vendor/*' | sort -u | wc -l)
214
real    0m0.491s
$ time (find . -type f -path '*/.claude-plugin/plugin.json' | wc -l)
65
real    0m0.412s
```

**Repo gates:**

```
$ CHECK_SKILL_SKILLS_ROOT=plugins/claude-config/skills bash plugins/skill-quality/scripts/check-skill.sh audit-prompting-postures
INFO: description length 1305/1536 chars
INFO: all 6 base-ref trigger phrase(s) preserved
INFO: SKILL.md 200/500 lines
INFO: markdownlint clean
CHECK-SKILL audit-prompting-postures: PASS — 0 errors, 0 warning(s)

$ ... check-skill.sh audit-permission-grants
INFO: script test passed: scripts/permission-rule-check.test.sh
CHECK-SKILL audit-permission-grants: PASS — 0 errors, 1 warning(s)   # no-Gotchas warning is pre-existing

$ npx markdownlint-cli2 <the 6 changed markdown files>
Summary: 0 issues in 0 files

$ shellcheck -S warning .../permission-rule-check.sh .../permission-rule-check.test.sh
(clean)

$ bash scripts/check-shell-portability.sh <merge-base>
No unexcused GNU-only constructs in 2 shell file(s).
$ bash scripts/check-skill-portability.sh <merge-base>
No unexcused coupling tokens in 6 skill file(s).
$ bash scripts/check-changelog-parity.sh --check-order
All 76 changelog(s) read newest-first with no duplicate versions.
$ bash scripts/check-changelog-parity.sh --check-bump origin/main
Every plugin whose version changed vs origin/main has a '## [<version>]' CHANGELOG.md entry.
$ bash scripts/validate-plugins.sh
All plugin manifests and the catalog validated.
$ bash scripts/check-silent-skips.sh
No silent prerequisite skips found in hook entry scripts.
$ bash plugins/skill-quality/scripts/check-evals-quality.sh .../audit-prompting-postures/evals/evals.json
check-evals-quality: PASS (0 warning(s) across 1 file(s))
$ npx ajv-cli@5 validate --spec=draft2020 -s plugins/skill-quality/reference/evals.schema.json -d .../evals.json
.../evals.json valid
```

`check-listing-budget` was run and reports the aggregate **already**
over budget on `main`
(97537/8000) — advisory-only, and this PR's contribution is **+15
chars** (description 1290 → 1305
for `output-styles`). Not introduced here and not resolvable here.

## Related

- **#2281** — closed here; all eight rows (CC-F3 … CC-F11) taken. CC-F9
deliberately untouched, as
  the issue's own "Not in this issue, on purpose" section requires.
- **#2283** — closed here for A5, A11, A15, A16. **A8 split to #2406
before merge** so the auto-close
  drops nothing.
- **#2406** — the A8 follow-up, with the falsified rationale and what a
real loadability model must
  distinguish.
- **#2382** (0.35.3), **#2403** (0.35.4) and **#2396** (0.36.0) — all
merged during this PR's life;
see the merge note in the test plan. #2396 took 0.36.0, so this PR is
**0.37.0**.
- **#2249** (closed) — removed the `$PWD` fallback and added the exit-2
refusal. This is the residue
  it named: a *resolved* root with nothing in it still reported clean.
- **#2250** (closed) — keyed the report path per project; CC-F11 is the
residual amplifier
  (uninstall still reaps the directory).
- **#2259** — added the two evals and 67 lines to
`audit-prompting-postures/SKILL.md`; every anchor
  in #2281 past `:78` was re-derived at this HEAD before editing.

Inbox items:
`20260811-021645-plugin-audit-four-components-and-guard-deadlock-ownership`
(#2281),

`20260811-024628-claude-config-audit-permission-grants-defects-and-fleet-grant-hygiene`
(#2283).
Ledgers:
`.work/handoff-inbox-batch-4/ledgers/I9-021645-four-components.md` §
Lane A;
`.work/handoff-inbox-batch-4/ledgers/I10-permission-grants-fleet.md` §
A5, A11, A15, A16.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants