Skip to content

fix(plugins): make every declared option real, and document them where they live - #2193

Merged
kyle-sexton merged 11 commits into
mainfrom
feat/plugin-enable-toggles
Aug 11, 2026
Merged

fix(plugins): make every declared option real, and document them where they live#2193
kyle-sexton merged 11 commits into
mainfrom
feat/plugin-enable-toggles

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Closes #2192

Related

What this changes

Three defects, plus the convention that stops the first one recurring.

hook::is_enabled — a non-exiting predicate

hook::check_enabled exits 0 when a plugin is gated off. That is right for a hook, and wrong for
any caller that must keep running afterward. The statusline tee is a transparent wrapper around
the user's real statusline, so an exit there would blank the status line instead of skipping the
tee's own write. check_enabled now delegates to the predicate and is otherwise unchanged.

rate-limit-guard — the tee now honors its own option

Invoked by absolute path from settings.json statusLine, the tee ran whatever the plugin's
enablement said. It was the only code path in the marketplace that kept executing while its plugin
was disabled. The snapshot is now gated; the passthrough stays unconditional and byte-identical.

source-control — a gate that could not be turned off

worktree_create_gate_enabled was read by the hook but never declared in the manifest, so Claude
Code never exported the variable and the :-true fallback always won. Declared with default: true, so behavior is unchanged for anyone who does not set it.

Per-plugin options reference, generated and gated

Each plugin README gains a marker-fenced block generated from its own manifest: every option with
its type, default, the CLAUDE_PLUGIN_OPTION_<KEY> variable each hook reads it from, the three
supported ways to set it, and links to the upstream docs. It also records the trap that plugin
option values are read from user, --settings and managed settings only — never from a
project's .claude/settings.json.

Hand-written prose is preserved untouched; actionlint's account of its stdin timeout is nuance a
generator cannot produce. The generator guarantees completeness and freshness, the prose keeps the
nuance, and both live in the plugin's own folder so they change together.

Same contract as sync-hook-utils.sh: manifest is the single source of truth, the block is never
hand-edited, CI runs --check and rejects drift. The gate is in the ci-status needs graph, so it
is required rather than informational.

Verification

  • lib/hook-utils.test.sh — 180 passed, 0 failed
  • claude plugin validate — passes for both changed plugins
  • markdownlint-cli2 "plugins/*/README.md" — 65 files, 0 errors
  • scripts/check-changelog-parity.sh --check — passes
  • actionlint .github/workflows/ci.yml — clean
  • Generator is idempotent; the new gate was negative-tested by injecting a manifest option and
    confirming --check exits 1, then 0 once regenerated
  • Tee verified to suppress its write while passing the wrapped statusline through byte-identically,
    across unset / option-false / option-true

Scope note

An audit of all 32 option-declaring plugins found exactly one dead gate (the source-control one
above) and one ungated execution path (the tee). Everything else already follows the documented
userConfig + CLAUDE_PLUGIN_OPTION_<KEY> pattern correctly — no migration was needed.

kyle-sexton and others added 4 commits August 10, 2026 22:48
hook::check_enabled exits 0 when a plugin is gated off, which is correct for a
hook -- no output means no action. It is wrong for any caller that must keep
running after a "disabled" answer.

The statusline tee is exactly that caller: it wraps the user's real statusline,
so an exit would suppress the wrapped command's stdout and blank the status line
rather than merely skipping the tee's own write. Split the resolution into a
predicate and make check_enabled delegate to it; check_enabled is unchanged.

Adds Test 20 covering both functions, including that an empty value is treated
as unset -- Claude Code exports every declared option, so an option the user
never answered arrives as an empty string and must mean "default", not
"disabled" -- and that is_enabled returns rather than terminating its caller.

Copies synced with scripts/sync-hook-utils.sh; CI rejects drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ne tee

scripts/statusline-tee.sh is invoked by absolute path from the user's
settings.json statusLine, not by the plugin hook runner, so it ran whatever the
plugin enablement said. It was the one code path in this plugin that kept
writing rate-limits.json while the plugin was disabled.

Gate the snapshot on the plugin's own option via hook::is_enabled. The
passthrough stays unconditional and byte-identical either way; check_enabled
would have exited 0 and blanked the status line. If the shared library cannot
be read the tee still runs, consistent with this script's rule that no tee
outcome ever alters the wrapped statusline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hooks/worktree-create-gate.sh reads
CLAUDE_PLUGIN_OPTION_WORKTREE_CREATE_GATE_ENABLED and names the option in its
own skip message, but the option was never declared in the manifest. Claude
Code exports CLAUDE_PLUGIN_OPTION_<KEY> only for declared options, so the
variable was never set, the hook's :-true fallback always won, and the gate
could not be turned off. Setting it produced no effect and no error.

Declared with default true, so behaviour is unchanged for anyone who does not
set it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A plugin's userConfig options were only discoverable by reading its manifest.
The /plugin UI is awkward to browse, and 54 of 65 READMEs carried a
hand-written Configuration section whose completeness nothing enforced -- an
option added later was silently undocumented.

Generate an options-reference block into each plugin README from its own
manifest: every option with its type, default, the CLAUDE_PLUGIN_OPTION_<KEY>
environment variable each hook reads it from, and the three supported ways to
set it (the /plugin configure prompt, claude plugin install --config, and a
pluginConfigs entry in user settings), plus links to the upstream docs. Notes
the trap that plugin option values are read from user, --settings and managed
settings only, never from a project's .claude/settings.json.

The block is marker-fenced and generated, so hand-written prose is preserved
untouched -- actionlint's account of its stdin timeout is nuance a generator
cannot produce. The generator guarantees completeness and freshness; the prose
keeps the nuance. Both live in the plugin's own folder so they change together.

Same contract as the shared hook library: the manifest is the single source of
truth, the block is never hand-edited, and CI runs --check and rejects drift.
The gate is wired into the ci-status needs graph, so it is required rather than
informational.

32 plugins declare 130 options.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 11, 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 11, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (PR diff, changed files)
  • Review lib/hook-utils.sh / plugin hook-utils.sh changes (hook::is_enabled predicate)
  • Review plugins/rate-limit-guard/scripts/statusline-tee.sh gating
  • Review plugins/source-control/.claude-plugin/plugin.json manifest change
  • Review .github/workflows/ci.yml change
  • Review scripts/sync-plugin-options-docs.py (new script)
  • Spot-check README diffs for injected/unsafe content
  • Post findings

No security issues found. This PR is documentation/gating plumbing, and the changes are narrowly scoped to what the description claims:

  • hook::is_enabled (lib/hook-utils.sh, synced to all plugin hooks/hook-utils.sh copies) — pure refactor. hook::check_enabled now delegates to a non-exiting predicate. The indirect expansion ${!var_name} builds the variable name from a fixed CLAUDE_PLUGIN_OPTION_ prefix plus a caller-supplied literal (e.g. "RATE_LIMIT_GUARD"), never from external/untrusted input, so there's no injection surface there.
  • plugins/rate-limit-guard/scripts/statusline-tee.sh — now sources the plugin's own hooks/hook-utils.sh via a path relative to BASH_SOURCE, guards on [[ -r ... ]], and falls open (tee still runs) if the source fails, consistent with the script's existing "never break the wrapped statusline" contract. The new gate only skips the snapshot write; the passthrough of stdin/stdout stays unconditional, matching the PR description. No new external input is consumed.
  • plugins/source-control/.claude-plugin/plugin.json — adds the missing worktree_create_gate_enabled declaration with default: true. Purely closes a dead-gate bug (the hook already read this var); no default-behavior change and no privilege change.
  • .github/workflows/ci.yml — new plugin-options-docs-gate job: actions/checkout is pinned to a full commit SHA, persist-credentials: false, no secrets referenced, runs on plain pull_request (not pull_request_target/workflow_run), permissions: contents: read at the workflow level. No dangerous trigger/secret combination.
  • scripts/sync-plugin-options-docs.py (new) — a local, offline doc generator: reads plugins/*/.claude-plugin/plugin.json, writes plugins/*/README.md. No subprocess execution, no eval, no network access, no shell-out. Table-cell content from manifest descriptions is escaped for |, [, ] before insertion into the generated markdown, which is adequate since the sink is a markdown table cell rendered by GitHub, not a shell or template context.
  • README diffs (57 files) — all mechanically generated, identical structure, no embedded scripts or unexpected links beyond the documented code.claude.com upstream doc references.

No pull_request_target/workflow_run usage, no permission-widening, no new secret handling, no unpinned actions introduced, and no command/path/template injection surfaces in the new or modified code.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 16s —— View job


Review complete

  • Gather context (diff, changed files, CLAUDE.md/REVIEW.md)
  • Review lib/hook-utils.sh / hook-utils.test.sh (is_enabled predicate)
  • Review rate-limit-guard statusline-tee gating
  • Review source-control plugin.json gate declaration
  • Review scripts/sync-plugin-options-docs.py generator
  • Review .github/workflows/ci.yml gate wiring
  • Spot-check generated README blocks for correctness
  • Post findings as inline comments + summary

Summary

Reviewed the diff against cd99f92 (the commit immediately preceding this PR's 4 commits). The three bug fixes are correct and well-scoped:

  • hook::is_enabled / hook::check_enabled (lib/hook-utils.sh:39-46) — the refactor is behavior-preserving. Confirmed the pre-PR check_enabled body ([[ "${!var_name:-true}" != "true" ]] && exit 0) is logically identical to the new hook::is_enabled "$1" || exit 0 delegation. New tests in lib/hook-utils.test.sh (Test 20) correctly probe both the exiting and non-exiting forms in a child shell, including the "empty means unset, not false" edge case.
  • rate-limit-guard/scripts/statusline-tee.sh — the gate correctly uses the non-exiting predicate (not check_enabled) so the passthrough stays unconditional, and fails open (still snapshots) if the sourced hook-utils.sh copy is unreadable, consistent with the script's "no tee outcome alters the wrapped statusline" invariant.
  • source-control's worktree_create_gate_enabled — manifest declaration matches what the hook already reads (CLAUDE_PLUGIN_OPTION_WORKTREE_CREATE_GATE_ENABLED), default: true preserves prior (accidentally-always-on) behavior.
  • All plugins/*/hooks/hook-utils.sh copies share the same blob SHA as lib/hook-utils.sh (verified one via direct diff) — sync is intact.
  • Generated README coverage: I independently cross-checked this — all 32 plugins whose manifest declares a non-empty userConfig (grep -l '"userConfig"' plugins/*/.claude-plugin/plugin.json) have a matching `` block in their README, and the count matches the PR's "32 option-declaring plugins" claim. Spot-checked actionlint and `source-control` READMEs against their manifests — field values (type/default/env var/description) render correctly, hand-written prose above the marker is untouched, and the `ci-status` `needs:` graph correctly includes the new `plugin-options-docs-gate` job.
  • One inline finding on the generator itself: it drops the multiple (and required/min/max) schema attributes, so options declared multiple: true (10 of them, all in source-control) render with a plain string type indistinguishable from a genuinely scalar option — a real gap for a block whose stated purpose is to be the options reference. Details and a suggested fix are on the inline comment.

Not independently verified (author-claimed in the PR description, not reproducible by me in this sandbox — Bash tool calls that execute python3 scripts/sync-plugin-options-docs.py and lib/hook-utils.test.sh required interactive approval that wasn't available here): the 180/0 test suite run, claude plugin validate, markdownlint-cli2 output, and check-changelog-parity.sh. I did manually trace the generator's render()/splice() logic against several real manifests and diffs and found it produces correct output for every case I checked other than the multiple gap above.

No correctness bugs, security issues, or architecture concerns found beyond the one inline finding, which is a docs-completeness gap rather than a functional defect — the gating logic itself (the PR's actual bug fixes) is sound and matches the CLAUDE.md design rules (manifest as single source of truth, userConfig pattern, no hardcoded plugin-specific state).

@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: ce2d500a39

ℹ️ 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/rate-limit-guard/scripts/statusline-tee.sh Outdated
Comment thread scripts/sync-plugin-options-docs.py
Comment thread scripts/sync-plugin-options-docs.py
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

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

Four gate failures on the first push, all legitimate repo doctrine:

- hook-utils-sync: every plugin carrying a copy of the shared library must bump
  its version when the library changes, so consumers receive it. Bumps the 14
  carrying plugins with a CHANGELOG entry each.
- plugin-gate: plugins/github/github.test.sh enforces marketplace agnosticism,
  and the generated install example hardcoded a marketplace name. The generator
  now emits a <marketplace> placeholder with an instruction to substitute it,
  which is also more correct: a plugin can be installed from a fork, a mirror,
  or a private catalog under a different name.
- hygiene/shellcheck: SC2016 on the two `bash -c 'source "$0"'` probes, where
  non-expansion is the point -- $0 is the child shell's positional, bound to the
  library path passed after the -c string. Directives added with that rationale.
- hygiene/exec-bit: the new generator needed the exec bit its siblings carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

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


Review complete

  • Gather context (PR diff, CLAUDE.md, REVIEW.md)
  • Review lib/hook-utils.sh / hook::is_enabled refactor + tests
  • Review rate-limit-guard statusline-tee gating
  • Review source-control plugin.json gate declaration
  • Review scripts/sync-plugin-options-docs.py generator
  • Review .github/workflows/ci.yml gate wiring
  • Spot-check generated README diffs
  • Post findings

Per REVIEW.md's lane split, this review is scoped to correctness/design/conventions/tests/docs and omits security (this repo has a claude-security-review lane, which already reviewed this PR above).

Findings

1. (Important, inline on plugins/rate-limit-guard/scripts/statusline-tee.sh:271) The new gate on the statusline tee's snapshot write likely doesn't work at the delivery-mechanism level. hook::is_enabled reads CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED, but this script is invoked from the user's top-level statusLine setting, not as a plugin hook. I fetched the current plugins-reference doc this session and it scopes CLAUDE_PLUGIN_OPTION_<KEY> export to hook processes specifically, and explicitly documents that the one other non-hook plugin process type it covers (Monitors) does not receive these variables and must read a config file instead. This repo's own docs/conventions/hook-config-delivery/README.md (fact 2) states the same "hook processes" scope and has no channel-matrix row for a statusLine-invoked script. If that holds for statusLine too, hook::is_enabled falls back to its unset-default (true) here regardless of the user's setting, and defect #2 from the PR description ("the tee ignoring rate_limit_guard_enabled") is likely not actually fixed. No automated test was added to exercise this call site either — statusline-tee.test.sh has zero diff in this PR; only the predicate itself (hook::is_enabled) got new tests. Full detail and a possible fix direction (a settings-read channel, per this repo's own F/G channels) are in the inline comment.

2. (Suggestion, unresolved from earlier review — not re-filed inline to avoid duplicating the existing thread) scripts/sync-plugin-options-docs.py:58render() only reads type/default/description/sensitive; it drops multiple/required/min/max. Confirmed still present at HEAD (cb2c1f9): plugins/source-control/README.md's generated table renders all 10 multiple: true babysit_* options as plain string, indistinguishable from a genuine scalar. Given this block's stated purpose is to be the options reference, this is a real completeness gap for the plugin with the most options.

3. (Suggestion, also flagged by the Codex review above, still unresolved) scripts/sync-plugin-options-docs.py:149-151 — when a plugin's manifest has no (or no longer has any) userConfig, the early continue never removes a pre-existing generated block from that plugin's README, and --check reports it as up to date. Not exercised by this PR's fixtures (every touched plugin still has options), so the gate doesn't currently catch it, but it's a real gap in a script whose whole job is to keep the docs authoritative.

4. (Nit) plugins/rate-limit-guard/.claude-plugin/plugin.json's rate_limit_guard_enabled description — "Master switch for the StopFailure detection hook" — wasn't updated to mention it's now also (attempted to be) read by the statusline tee. Since the generated README table renders this description verbatim, a user reading the options reference has no way to know the option is meant to affect the tee at all. Worth updating regardless of how finding 1 resolves.

What checked out

  • hook::is_enabled / hook::check_enabled refactor (lib/hook-utils.sh:39-46) is a clean, behavior-preserving split, with solid new test coverage in lib/hook-utils.test.sh (Test 20) covering unset/true/false/non-boolean/empty for both the exiting and non-exiting forms.
  • source-control's worktree_create_gate_enabled declaration (plugins/source-control/.claude-plugin/plugin.json) correctly closes the dead-gate bug — the hook already read this var, default: true preserves prior behavior.
  • .github/workflows/ci.yml's new plugin-options-docs-gate job is correctly wired into the ci-status needs: graph, checkout is minimal (persist-credentials: false), and it runs the generator's --check mode as documented.
  • Spot-checked source-control and rate-limit-guard generated README tables against their manifests: field values (except the multiple gap above) render correctly, hand-written prose above the marker is untouched.

Not independently verified

The PR description's test-suite/lint/validator numbers (180/0, claude plugin validate, markdownlint-cli2, check-changelog-parity.sh) are author-claimed; I did not re-run them in this pass.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (gh pr diff, changed files)
  • Review lib/hook-utils.sh (hook::is_enabled predicate) and synced plugin copies
  • Review plugins/rate-limit-guard/scripts/statusline-tee.sh gating
  • Review plugins/source-control/.claude-plugin/plugin.json manifest change
  • Review .github/workflows/ci.yml change
  • Review scripts/sync-plugin-options-docs.py (new generator script)
  • Spot-check generated README diffs for injected/unsafe content
  • Post findings as a PR review

One finding, in the logic/trust-boundary lane this pass is scoped to (zizmor already covers the Actions supply-chain/permissions surface, and I found nothing there worth adding — the new plugin-options-docs-gate job is a plain checkout + python3 --check, no secrets, no widened permissions).

IMPORTANT — the new statusline-tee kill switch almost certainly does not fire on the real invocation path (confidence: high)

plugins/rate-limit-guard/scripts/statusline-tee.sh#L266-L273 gates the snapshot write on hook::is_enabled "RATE_LIMIT_GUARD", which reads CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED (lib/hook-utils.sh#L39-L42). This script isn't run by the plugin hook runner — it's invoked directly by Claude Code as the statusLine.command from the user's own settings.json (the script's own header says so, lines 5–10).

I independently checked the current official docs (fetched this session, not from training-data recall):

  • plugins-reference.md: "All values are exported to hook processes as CLAUDE_PLUGIN_OPTION_<KEY> environment variables" and, further down, "All three are exported as environment variables to hook processes and to MCP and LSP server subprocesses." — statusline commands are never listed as a recipient. The same page explicitly calls out one exception ("Monitor processes don't receive CLAUDE_PLUGIN_OPTION_<KEY> environment variables"), which shows the doc is precise about scoping this export to specific process kinds.
  • statusline.md: the only environment variables it documents Claude Code injecting into the statusline command are COLUMNS/LINES (terminal size). No mention of CLAUDE_PLUGIN_OPTION_*.

So the statusline process almost certainly never sees CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED, and hook::is_enabled's ${!var_name:-true} fallback means an unset variable reads as true — i.e. always enabled, regardless of what the user configured. The new tests in lib/hook-utils.test.sh#L2349-L2367 don't catch this because they manually env CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED=false bash -c '...' before sourcing the lib — that simulates a hook-process invocation, not the real statusLine.command launch, so they can't distinguish "the predicate logic is correct" from "the predicate never receives real input here."

Net effect: a user who sets rate_limit_guard_enabled=false — plausibly for privacy, since this is what stops the tee from writing session/rate-limit data to ~/.claude/rate-limit-guard/rate-limits.json on every statusline refresh — most likely keeps getting that file written anyway. That's the exact defect (#1849-style: "the one code path that keeps running while the plugin is disabled") this PR sets out to close, now hidden behind code that looks gated and is covered by a green test suite. This is a genuine kill-switch/access-control gap, not a style nit, which is why I'm flagging it here rather than deferring to Codex's already-posted comment on the same line (P2) — I'd bump the severity given the confirmation against current docs, since the fix reads as complete but isn't verified end-to-end.

I was not able to spin up a real Claude Code session with a wired statusLine.command in this sandbox to observe the process's actual environment directly, so this is a strong documentary inference rather than a live repro — worth a maintainer confirming with env dumped from an actual statusline invocation before merge, or with the Fix this → link to have Claude Code verify and fix it.

No other logic, authorization, injection, secret-handling, or GitHub Actions trust-boundary issues found in this PR's changed files.
· branch feat/plugin-enable-toggles

Comment thread plugins/rate-limit-guard/scripts/statusline-tee.sh Outdated
The repo's typos gate flags UNPARSEABLE; UNPARSABLE is the accepted spelling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

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


Review complete

  • Gather context (PR diff vs. base cd99f92, CLAUDE.md/AGENTS.md, prior review threads)
  • Review lib/hook-utils.sh / hook::is_enabled refactor + tests
  • Review plugins/rate-limit-guard/scripts/statusline-tee.sh gating
  • Review plugins/source-control/.claude-plugin/plugin.json gate declaration
  • Review scripts/sync-plugin-options-docs.py generator
  • Review .github/workflows/ci.yml gate wiring
  • Spot-check generated README diffs for correctness
  • Post inline findings + summary

Note on scope: I couldn't run git fetch in this sandbox (no network approval available), so I diffed against cd99f92 rather than a freshly-fetched origin/main. Confirmed via git log --oneline cd99f92..HEAD that this is exactly the PR's 6-commit range, so the diff is equivalent. This is the correctness/design/maintainability lane per REVIEW.md's split — security is left to the dedicated security lane, which has already reviewed this PR.

Findings (posted inline)

This PR's two newest commits (cb2c1f9, 1979381) only address CI-gate failures (hook-utils-sync version bumps, marketplace-agnosticism, shellcheck, exec bit) and a spelling fix — they don't touch the three substantive gaps flagged in earlier review rounds on this PR. All three are still present at HEAD (1979381):

  1. plugins/rate-limit-guard/scripts/statusline-tee.sh:271 — the new gate almost certainly doesn't fire on the real invocation path. This repo's own docs/conventions/hook-config-delivery/README.md scopes CLAUDE_PLUGIN_OPTION_<KEY> delivery to hook processes (fact 2, channel B) with no matrix row for a statusLine-invoked script, and this script's own header says it's launched by absolute path from settings.json statusLine, "not by the plugin hook runner." No test exercises this call site — statusline-tee.test.sh has zero diff. The CHANGELOG.md entry for [0.5.5] asserts this is "Fixed," which is very likely not accurate yet.
  2. scripts/sync-plugin-options-docs.py:58 — the generator drops multiple/required/min/max. Now concretely visible as a self-contradiction within the PR: plugins/source-control/README.md line 213 (hand-written prose) correctly says babysit_watched_owners is string (multiple), while the generated block this PR adds at line 287 of the same file renders it as plain string.
  3. scripts/sync-plugin-options-docs.py:151 — when a plugin's last userConfig entry is removed, the early continue never strips a pre-existing generated block, and --check silently reports it up to date.

Minor, not filed inline: plugins/rate-limit-guard/.claude-plugin/plugin.json's rate_limit_guard_enabled description ("Master switch for the StopFailure detection hook") still doesn't mention the statusline tee, so the generated options table gives no hint the option is meant to affect it too — worth revisiting alongside finding 1.

What checked out

  • hook::is_enabled / hook::check_enabled split (lib/hook-utils.sh:19-46) — clean, behavior-preserving refactor; confirmed the old check_enabled body is logically identical to hook::is_enabled "$1" || exit 0.
  • source-control's worktree_create_gate_enabled (plugins/source-control/.claude-plugin/plugin.json) — correctly closes the dead-gate bug; the hook (hooks/worktree-create-gate.sh:62) already read this var, default: true preserves prior (accidentally-always-on) behavior, and this one is a plugin-hook invocation, so channel B is the right choice here (unlike finding 1).
  • .github/workflows/ci.yml's new plugin-options-docs-gate job — correctly wired into the ci-status needs: graph, minimal checkout, runs the generator's --check mode.
  • Version/CHANGELOG bumps across the 14 plugins carrying the shared hook-utils.sh copy — spot-checked actionlint, correctly bumped with a matching CHANGELOG entry, consistent with the sync-hook-utils.sh contract.
  • Generated README coverage and marketplace-placeholder fix (cb2c1f9) — the <marketplace> placeholder for --config install examples is correct per plugins/github/github.test.sh's agnosticism gate, and matches this repo's design rule against hardcoded marketplace names.

Not independently verified (author-claimed only): the PR description's and CHANGELOG's test-suite/lint/validator output (hook-utils.test.sh 180/0, claude plugin validate, markdownlint-cli2, check-changelog-parity.sh).
· branch feat/plugin-enable-toggles

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (git diff cd99f92...HEAD, matches PR's 85 changed files exactly)
  • Review .github/workflows/ci.yml change (new plugin-options-docs-gate job)
  • Review lib/hook-utils.sh (hook::is_enabled/hook::check_enabled) + synced plugin copies
  • Review plugins/rate-limit-guard/scripts/statusline-tee.sh gating
  • Review plugins/source-control/.claude-plugin/plugin.json manifest change
  • Review scripts/sync-plugin-options-docs.py (new generator)
  • Spot-check generated README diffs for injected/unsafe content
  • Post findings

Scoped to logic/architecture/trust-boundary security reasoning, per this repo's lane split — Actions supply-chain/permissions/trigger risk is zizmor's lane and I found nothing there worth adding (the new job is a plain actions/checkout pinned to a full SHA with persist-credentials: false, no secrets, runs on pull_request, no permission widening).

IMPORTANT — the statusline-tee kill switch still appears to be a no-op on the real delivery path (confidence: high)

plugins/rate-limit-guard/scripts/statusline-tee.sh#L266-L273 gates the snapshot write on hook::is_enabled "RATE_LIMIT_GUARD", which reads CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED (lib/hook-utils.sh#L39-L42). This script is invoked by absolute path as the user's statusLine.command, not spawned by the plugin hook runner.

I checked this repo's own delivery-channel doc, docs/conventions/hook-config-delivery/README.md. Fact 2 states configured values are exported "to hook processes as CLAUDE_PLUGIN_OPTION_<KEY>," and the channel list (A–G) documents no route for a statusLine-invoked script — there is no mention of "statusline" anywhere in that file. That matches the official plugins-reference doc's scoping of the same export to hook processes (and its explicit carve-out that Monitor processes, the one other non-hook plugin-spawned process type it covers, do not receive these variables and must read a config file instead).

Given hook::is_enabled's ${!var_name:-true} fallback, an env var that's never set here reads as true — i.e. always enabled — regardless of what the user configured with rate_limit_guard_enabled=false. That's precisely defect #2 this PR sets out to close ("the one code path that keeps running while its plugin is disabled"), and it looks like it's still open: this HEAD (1979381) only changed scripts/sync-plugin-options-docs.py since the gate was added, and no test exercises this call site — statusline-tee.test.sh has zero diff in this PR; the only new tests (lib/hook-utils.test.sh) cover the predicate in isolation via manual env VAR=... bash -c injection, which simulates a hook-process invocation, not the real statusLine.command launch, so they can't distinguish "predicate logic is correct" from "predicate never receives real input at this call site."

This is a functional/access-control gap (a documented user-facing toggle that silently doesn't take effect for the write it's supposed to suppress — plausibly used for privacy, since it's what should stop rate-limit/session data being written to disk on every statusline refresh) rather than a memory-safety or injection bug, which is why it's flagged here as IMPORTANT/high-confidence rather than CRITICAL — no secrets or authorization boundary crosses, but the plugin's own stated contract (an "off" switch that's actually off) doesn't hold.

I can't spin up a real Claude Code session with a wired statusLine.command in this sandbox to dump its actual process environment, so this is a strong documentary inference, not a live repro — worth confirming with an env dump from a real statusline invocation before merge, per this repo's own evidence bar for delivery-channel claims. If it turns out Claude Code does thread CLAUDE_PLUGIN_OPTION_* into statusLine processes (undocumented but possible), this doesn't apply.

(Same finding as the prior review passes on this PR — re-verified independently against the current diff and unresolved as of this HEAD, so repeating it rather than treating it as stale.)

What checked out clean

  • hook::is_enabled/hook::check_enabled split (lib/hook-utils.sh:39-46) — pure, behavior-preserving refactor. The indirect expansion ${!var_name} builds the variable name from a fixed CLAUDE_PLUGIN_OPTION_ prefix plus a caller-supplied literal, never external input — no injection surface.
  • Synced plugin copies — all 16 plugins/*/hooks/hook-utils.sh copies changed in this PR carry the identical blob SHA (e88315e7...) as lib/hook-utils.sh per the diff metadata, so the sync is intact and no copy silently diverged.
  • plugins/source-control/.claude-plugin/plugin.json — adds the missing worktree_create_gate_enabled declaration with default: true; the hook already read this var, so this only closes the dead gate — no default-behavior or privilege change.
  • scripts/sync-plugin-options-docs.py — local, offline, read-manifest/write-README only. No subprocess, eval, or network access. Table-cell content from manifest descriptions is escaped for |/[/] before insertion, adequate for a markdown-table sink rendered by GitHub (not a shell or template context).
  • Generated README diffs (57 files) — mechanically identical structure; no embedded scripts, no unexpected URLs (checked for http://, inline <script, onerror=, javascript: — none found).
  • .github/workflows/ci.yml — new plugin-options-docs-gate job: SHA-pinned checkout, persist-credentials: false, no secrets, plain pull_request trigger, wired into the ci-status needs: graph. No trust-boundary or permission changes.

No injection surfaces (command/SQL/path/template), no secret-handling issues, and no GitHub Actions trust-boundary problems found in this PR's changed files, beyond the access-control gap above.
· branch feat/plugin-enable-toggles

Comment thread plugins/rate-limit-guard/scripts/statusline-tee.sh Outdated
Comment thread scripts/sync-plugin-options-docs.py
Comment thread scripts/sync-plugin-options-docs.py
@kyle-sexton
kyle-sexton enabled auto-merge (squash) August 11, 2026 03:21
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (git diff cd99f92..HEAD, matching this PR's changed-files list)
  • Review .github/workflows/ci.yml gate wiring (deferred to zizmor for supply-chain/permissions — nothing additional found)
  • Review lib/hook-utils.sh (hook::is_enabled/hook::check_enabled) + synced plugin copies
  • Review plugins/rate-limit-guard/scripts/statusline-tee.sh gating
  • Review plugins/source-control/.claude-plugin/plugin.json manifest change
  • Review scripts/sync-plugin-options-docs.py (new generator)
  • Spot-check generated README diffs for injected/unsafe content
  • Post findings

Re-verified against the current HEAD (d953610, a no-op merge of main into this branch — confirmed via git diff 1979381..d953610 that the only change since the last review pass is an unrelated doc file from main, not part of this PR's own diff). None of this PR's files changed since the last review round, so the prior finding stands unresolved.

IMPORTANT — statusline-tee's new kill switch is very likely a no-op on its real invocation path (confidence: high, unresolved from prior rounds)

plugins/rate-limit-guard/scripts/statusline-tee.sh#L266-L273 gates the snapshot write on hook::is_enabled "RATE_LIMIT_GUARD", which reads CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED (lib/hook-utils.sh#L39-L42, fallback true when unset). This script is launched by absolute path from the user's top-level statusLine.command setting — its own comment at lines 258-259 says so explicitly — not spawned by the plugin hook runner.

This repo's own docs/conventions/hook-config-delivery/README.md scopes the CLAUDE_PLUGIN_OPTION_<KEY> export to hook processes (fact 2, channel B), with no channel in the matrix for a statusLine-invoked script, matching the official plugins-reference doc's "exported to hook processes" language and its explicit carve-out that Monitor processes (the one other non-hook plugin-spawned process type it documents) do not receive these variables and must read a config file instead.

If that scoping holds for statusLine too — as the documentary evidence strongly suggests — then CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED is never set in this process's environment, hook::is_enabled always falls back to true, and setting rate_limit_guard_enabled=false most likely still leaves the tee writing ~/.claude/rate-limit-guard/rate-limits.json on every statusline refresh — the exact defect (a documented "off" switch for what's plausibly privacy-sensitive session/rate-limit data that doesn't actually turn the write off) this PR's description and CHANGELOG.md ([0.5.5]) assert is now fixed. No automated test closes the gap either: plugins/rate-limit-guard/scripts/statusline-tee.test.sh has zero diff in this PR; the only new coverage (lib/hook-utils.test.sh) exercises the predicate via manual env VAR=... bash -c injection, which proves the predicate's logic in isolation, not that this call site ever receives a real value from an actual statusLine launch.

This is a functional/access-control gap — a user-facing toggle silently not taking effect for the write it claims to suppress — rather than a memory-safety or injection bug; flagged IMPORTANT/high-confidence given the repeated documentary confirmation across review rounds, not CRITICAL, since no secrets or authorization boundary is crossed. Worth a maintainer confirming with an env dump from a real statusLine.command invocation before merge, or routing through one of this repo's own documented tamper-resistant channels (F/G in hook-config-delivery/README.md) instead of channel B, since the doc itself says B is unproven for this call site.

Fix this →

What checked out clean

  • hook::is_enabled/hook::check_enabled split (lib/hook-utils.sh:19-46) — pure, behavior-preserving refactor. The indirect expansion ${!var_name} builds the variable name from a fixed CLAUDE_PLUGIN_OPTION_ prefix plus a caller-supplied literal (never external input) — no injection surface.
  • plugins/source-control/.claude-plugin/plugin.json's new worktree_create_gate_enabled declaration — closes a dead gate the hook already read; default: true preserves prior (accidentally-always-on) behavior, no privilege or default-behavior change.
  • .github/workflows/ci.yml's new plugin-options-docs-gate job — SHA-pinned actions/checkout, persist-credentials: false, no secrets referenced, plain pull_request trigger, permissions: contents: read at workflow scope, correctly wired into the ci-status needs: graph. No dangerous trigger/secret pattern, no permission widening.
  • scripts/sync-plugin-options-docs.py (new) — local, offline, read-manifest/write-README only; no subprocess, eval, or network access. Manifest-derived description text is escaped for |/[/] before insertion into a generated markdown table cell, adequate for that sink.
  • Generated README diffs (57 files) — mechanically identical structure; scanned for <script, onerror=, javascript:, and bare http:// in the added lines — none found.

No injection surfaces (command/path/template), no secret-handling issues, and no GitHub Actions trust-boundary problems found beyond the access-control gap above, which is unchanged from the prior review rounds on this PR.
· branch feat/plugin-enable-toggles

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

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


Review complete

  • Gather context (diff against origin/main, confirmed HEAD d9536103 matches the merge commit; git diff 1979381 d9536103 shows only docs/conventions/hook-observability/README.md changed — no functional change to this PR's own files since the last review round)
  • Check CLAUDE.md / lane scoping (this pass, correctness/design/tests/docs — security is the dedicated claude-security-review lane, already reviewed above)
  • Review lib/hook-utils.sh refactor + tests
  • Review rate-limit-guard/scripts/statusline-tee.sh gating
  • Review source-control plugin.json gate declaration
  • Review scripts/sync-plugin-options-docs.py generator
  • Review .github/workflows/ci.yml gate wiring
  • Post findings (inline)

This is a re-review triggered by a new push, but that push (d953610) is a merge of main that touched only an unrelated doc — none of this PR's own commits changed since the last review pass (1979381). I re-read the relevant files myself this pass rather than restating prior claims, and the same three gaps are still present. Posted as inline comments (repeating a finding across passes to keep it visible until addressed, per this thread's own established pattern):

  1. plugins/rate-limit-guard/scripts/statusline-tee.sh:271 — the new gate most likely doesn't fire on the real invocation path. I independently re-read this repo's docs/conventions/hook-config-delivery/README.md this pass: its facts table and channel matrix scope CLAUDE_PLUGIN_OPTION_<KEY> delivery to hook processes only, with no channel for a statusLine-invoked script. No test exercises this call site (statusline-tee.test.sh has zero diff), and plugins/rate-limit-guard/CHANGELOG.md's [0.5.5] entry asserts this is ### Fixed without qualification — likely inaccurate as written. This is the third consecutive review pass to raise it unaddressed.
  2. scripts/sync-plugin-options-docs.py:58render() drops multiple/required/min/max. Self-contradictory within this PR: plugins/source-control/README.md's hand-written prose correctly calls babysit_watched_owners string (multiple); the generated block a few lines below renders it as plain string.
  3. scripts/sync-plugin-options-docs.py:151 — a plugin whose userConfig becomes empty never gets its stale generated README block removed, and --check won't catch it since it never reads that README in this branch.

What checked out

  • hook::is_enabled/hook::check_enabled split (lib/hook-utils.sh:39-46) — clean, behavior-preserving refactor with solid new test coverage.
  • source-control's worktree_create_gate_enabled (plugins/source-control/.claude-plugin/plugin.json) — correctly closes the dead-gate bug; the hook already read this var, default: true preserves prior (accidentally-always-on) behavior. Channel B (env) is the right choice here since this is a real plugin-hook invocation, unlike finding 1.
  • .github/workflows/ci.yml's new plugin-options-docs-gate job — correctly wired into the ci-status needs: graph.
  • Generated README diffs across the other 30 plugins — mechanically consistent, hand-written prose above the marker preserved untouched.

Not independently verified (author-claimed only, unchanged from prior passes): the PR description's test-suite/lint/validator output (hook-utils.test.sh 180/0, claude plugin validate, markdownlint-cli2, check-changelog-parity.sh).
· branch feat/plugin-enable-toggles

Comment thread plugins/rate-limit-guard/scripts/statusline-tee.sh Outdated
Comment thread scripts/sync-plugin-options-docs.py
Comment thread scripts/sync-plugin-options-docs.py
kyle-sexton and others added 2 commits August 10, 2026 23:35
Three defects the automated reviewers caught, all real.

The tee's kill switch was decorative. CLAUDE_PLUGIN_OPTION_<KEY> is exported to
hook processes only, and statusline-tee.sh is invoked by absolute path from the
user's statusLine setting -- so the variable was always unset, the :-true
fallback always won, and the gate never fired. It passed review only because the
tests injected the variable by hand. This is the same class of defect this PR
fixes in source-control: a gate reading a channel that never delivers. The tee
now reads the value from pluginConfigs in user settings, the sanctioned route
for a non-hook consumer, matching the marketplace by prefix so forks work, and
failing open on every error path.

Also NOT `// empty` in the jq: the alternative operator treats false as falsy,
which would discard the exact value the gate exists to detect. Caught by test.

The generator dropped multiple/required/min/max, so a repeated option rendered
as a plain scalar -- visibly contradicting source-control's own hand-written
prose in the same file, which documents 10 of them as "string (multiple)".

The generator also left a stale block when a plugin removed its last option, and
--check reported it as up to date because it returned before reading the README
-- the one path where the gate silently failed at its own job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

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


Review complete

  • Gather context (PR diff vs origin/main, CLAUDE.md/AGENTS.md, prior review threads)
  • Review lib/hook-utils.sh / hook::is_enabled refactor + tests
  • Review plugins/rate-limit-guard/scripts/statusline-tee.sh gating (new commit 1a0419f)
  • Review plugins/source-control/.claude-plugin/plugin.json gate declaration
  • Review scripts/sync-plugin-options-docs.py generator (multiple/stale-block fixes)
  • Review .github/workflows/ci.yml gate wiring
  • Spot-check generated README diffs for correctness
  • Post inline findings + summary

This lane is scoped to correctness/design/tests/docs; security reasoning is left to the dedicated security lane, which has already reviewed this PR extensively above.

This is a re-review triggered by the new commit 1a0419f ("address the review findings"), which rewrites the statusline-tee gate and fixes the generator's two documentation gaps that every prior round on this PR flagged. I re-verified each fix against the actual diff rather than restating prior claims.

What's now fixed

  • scripts/sync-plugin-options-docs.py's multiple/required/min/max gap — resolved. render() now appends (multiple) and a min/max/required bounds line to the Type column (scripts/sync-plugin-options-docs.py:58-70). Confirmed the previously-contradictory plugins/source-control/README.md now agrees with its own hand-written prose: all 10 babysit_* multiple: true options render as string (multiple).
  • The generator's stale-block-on-empty-userConfig gap — resolved. main() now strips an existing generated block (and fails --check, doesn't just silently pass) when a plugin's manifest no longer declares any options (scripts/sync-plugin-options-docs.py:161-178). Traced the control flow: the continue at line 178 only fires inside the marker-present branch, so a README with no prior block still correctly falls through to skipped += 1.
  • hook::is_enabled/hook::check_enabled split (lib/hook-utils.sh:19-46) — unchanged since the prior rounds' review, still a clean, behavior-preserving refactor.
  • source-control's worktree_create_gate_enabled — unchanged, still correctly closes the dead-gate bug.

Still open: the statusline-tee kill switch, now on its third implementation, has a narrower but real gap (posted inline)

The rewrite correctly diagnoses and fixes the root cause every prior round raised — statusLine-invoked scripts don't receive CLAUDE_PLUGIN_OPTION_*, so the old env-only gate was decorative. The new _rlg_tee_enabled() reads pluginConfigs from settings directly instead, which is the right general approach (this repo's own channel F). Two things about the replacement itself are worth fixing before this ships, posted as inline comments:

  1. statusline-tee.sh:282 — it only reads the user settings.json path, not managed settings, even though the same doc comment cites as justification (docs/conventions/hook-config-delivery/README.md fact 5) says pluginConfigs is read from "user settings, --settings, and managed settings." This repo's own channel-F exemplar, plugins/disk-hygiene/lib/killswitch_config.py, reads both. As written, an organization trying to force this off fleet-wide via managed settings — plausibly the actual use case for a privacy-sensitive kill switch like this one — would hit the same silent no-op this PR exists to close, just at a different settings layer.
  2. statusline-tee.sh:276statusline-tee.test.sh still has zero diff in this PR. No fixture-driven test exercises the new jq/settings-file logic (true/false/absent/malformed/no-jq cases), which is exactly the coverage gap every prior review round cited as the reason the first broken implementation shipped looking correct.

Nit (not filed inline)

plugins/rate-limit-guard/.claude-plugin/plugin.json's rate_limit_guard_enabled description is still "Master switch for the StopFailure detection hook" and doesn't mention the statusline tee, even though the generated options table (which is now this option's primary documentation) renders that description verbatim. Minor, but worth a one-line update alongside the tee fix above.

Not independently verified

The PR description's/CHANGELOG's test-suite and lint output (hook-utils.test.sh, claude plugin validate, markdownlint-cli2, check-changelog-parity.sh) are author-claimed; not re-run in this pass.
· branch feat/plugin-enable-toggles

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (this push, 1a0419f, is a targeted fix for the prior rounds' findings; diffed d953610..80df04f, which touches only statusline-tee.sh, sync-plugin-options-docs.py, and mechanically re-generated README tables)
  • Re-check plugins/rate-limit-guard/scripts/statusline-tee.sh — is the delivery-channel gap actually closed?
  • Re-check scripts/sync-plugin-options-docs.pymultiple attribute + stale-block-removal gaps
  • Scan the rest of this push's changed lines for new security issues
  • Post findings

Scoped to logic/access-control/trust-boundary reasoning per this repo's review-lane split; zizmor's static coverage (unpinned actions, dangerous triggers, permission widening) isn't re-litigated here, and this push doesn't touch .github/workflows/ci.yml anyway.

IMPORTANT — the new gate skips managed settings, so the precedence guarantee it's supposed to honor doesn't hold (confidence: high)

plugins/rate-limit-guard/scripts/statusline-tee.sh#L276-L294

This commit correctly fixes the previously-flagged defect for the common case: it stops relying solely on CLAUDE_PLUGIN_OPTION_RATE_LIMIT_GUARD_ENABLED (which this non-hook process never receives) and instead reads the value directly out of pluginConfigs in the user's settings.json via jq, matching this repo's own documented channel F ("Direct settings read") from docs/conventions/hook-config-delivery/README.md:60-69.

But channel F's own definition is: "the hook script reads pluginConfigs["<name>@<marketplace>"].options itself from the user settings.json plus fixed-path managed settings (and managed-settings.d/ drop-ins)" — and the doc explains why managed settings matters via the disk-hygiene exemplar it cites (plugins/disk-hygiene/lib/killswitch_config.py:41-52): "managed settings are the highest-precedence scope Claude Code honors for pluginConfigs and cannot be overridden by user/project/local settings ... so an organization can enforce audit-only mode there." Disk-hygiene's reader implements this: it checks a fixed, root-owned managed-settings.json path per platform (/etc/claude-code/managed-settings.json on Linux, etc.) in addition to the user file.

_rlg_tee_enabled() in this diff only builds one path — "${CLAUDE_CONFIG_DIR:-${HOME:-}/.claude}/settings.json" (line 282) — and never reads any managed-settings location. So an administrator who sets rate_limit_guard_enabled=false in managed settings specifically to force off the write of session/rate-limit data (the exact kind of org-level privacy/compliance control channel F exists to make possible) gets silently overridden: this code finds nothing at the managed path (it never looks there), falls through past the empty user-settings case, and _rlg_tee_enabled returns 0 (enabled) — the snapshot keeps writing regardless of the managed override. That inverts the "cannot be overridden" guarantee the channel is supposed to provide, for a plugin whose write target (~/.claude/rate-limit-guard/rate-limits.json, captured session/account fields) is plausibly exactly what such a policy would want to suppress.

This isn't a hypothetical edge case in the same way as the delivery-channel-existence question the last several rounds were debating — it's directly readable from the diff: no managed_settings_path-equivalent, no managed-settings.d/ scan, nowhere in the 19-line function. The header comment at lines 266-272 asserts the fix reads from "the sanctioned route for a non-hook consumer," but only implements half of what this repo's own channel-F contract (and its own cited exemplar) defines as that route.

Suggested fix direction: add the fixed per-platform managed-settings path(s) (and managed-settings.d/ drop-ins) to the jq read, with managed values taking precedence over the user-settings value, mirroring killswitch_config.py's probe()/resolve_effective() split. Fix this →

What checked out clean in this push

  • The main delivery-channel defect from prior review rounds is genuinely fixed for the primary path. The CLAUDE_PLUGIN_OPTION_* env read (which this non-hook process never received) is now only a fast-path check; the fallback correctly parses pluginConfigs["rate-limit-guard@*"].options.rate_limit_guard_enabled out of the user's real settings.json, using tostring (not // empty) specifically so a false value isn't discarded as falsy — verified this is correct jq behavior.
  • No new command/path injection: $cfg is built from CLAUDE_CONFIG_DIR/HOME and always double-quoted going into jq; the jq filter itself is a fixed literal with no interpolated untrusted data.
  • Fails open (tee still runs) on missing jq or an unreadable settings file, consistent with this script's pre-existing "no tee outcome ever alters the wrapped statusline" contract — not a security regression, matches documented intent.
  • scripts/sync-plugin-options-docs.py's two prior gaps (dropped multiple/required/min/max attributes; stale generated blocks surviving option removal) are both fixed with correct logic, and remain a local, offline, read-manifest/write-README script — no subprocess, eval, or network access, no new sink.
  • The re-generated README tables in this push are mechanically consistent with the new (multiple) rendering; no injected/unsafe content.
    · branch feat/plugin-enable-toggles

Comment thread plugins/rate-limit-guard/scripts/statusline-tee.sh Outdated
Comment thread plugins/rate-limit-guard/scripts/statusline-tee.sh
The 0.5.6 gate read only the user settings file, so a managed policy
setting rate_limit_guard_enabled: false was ignored — a policy bypass,
since managed settings are the highest-precedence scope and cannot be
overridden by any user or project scope (hook-config-delivery fact 5).

_rlg_tee_enabled now resolves managed → user settings → environment,
highest first, mirroring the channel-F exemplars: the fixed per-platform
root-owned managed paths selected by uname -s plus the managed-settings.d/
drop-ins, every resolved path re-checked as absolute. The env channel drops
below user settings because a repo env block owns an unconfigured key
(fact 4). Fails open on a missing file, missing jq, malformed JSON, or an
unrecognized platform; prefix marketplace match retained; no `// empty` on
the value.

statusline-tee.test.sh gains real coverage of the gate — unconfigured,
user false/true, fork marketplace, another plugin's identically-named
option, malformed JSON, missing jq, and managed false-over-true plus the
managed true-over-false mirror — each asserting the wrapped statusline's
stdout is unchanged, plus one unstubbed end-to-end case. The script gained
a main() behind the repo's existing BASH_SOURCE sourcing guard so the
unwritable managed path list can be stubbed; a direct run is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (PR diff, latest commit a7d44b2)
  • Review latest commit a7d44b2 — managed-settings fix for statusline-tee gate
  • Re-verify prior findings (delivery channel, managed settings) are resolved
  • Scan rest of diff for new security issues
  • Post findings

This pass is scoped to the delta since the last review round (80df04fa7d44b2), which touches only plugins/rate-limit-guard/scripts/statusline-tee.sh, its test file, and the plugin's CHANGELOG.md/plugin.json version bump. No other file in this PR changed, and everything else was already reviewed extensively in prior rounds on this thread.

No security issues found in this delta — it resolves the IMPORTANT finding raised repeatedly in earlier review rounds.

Previously flagged: statusline-tee kill switch didn't honor managed settings — now fixed

The last review round found _rlg_tee_enabled() (as of 80df04f) read only the user settings.json, silently ignoring a rate_limit_guard_enabled: false set via managed settings — the one scope an organization uses for a non-overridable policy. This commit closes that:

  • statusline-tee.sh#L303-L326 (_rlg_managed_settings_files) — resolves the managed-settings path(s) from a fixed, hardcoded, per-platform table (/etc/claude-code/... on Linux, /Library/Application Support/ClaudeCode/... on macOS, C:/Program Files/ClaudeCode/... on Windows), selected via uname -s, never from an environment variable. I compared these paths directly against this repo's existing channel-F exemplar, plugins/disk-hygiene/lib/killswitch_config.py::managed_settings_path() — they match exactly, including the Windows path being a literal hardcoded string rather than %ProgramFiles%-derived (the doc comment correctly notes why: an env-derived base would let a repo's env block redirect the highest-precedence scope). A defense-in-depth check (case "$primary" in /* | [A-Za-z]:[/\\]*) ;; *) return 0 ;; esac) also rejects a non-absolute path outright, so even a future edit that accidentally made this path derived couldn't be walked back to a cwd-relative, plantable file.
  • managed-settings.d/ drop-ins are enumerated and layered with "later file wins," matching the same exemplar's documented merge order and Claude Code's own precedence.
  • _rlg_tee_enabled() now checks managed → user settings → env var → default-enabled, in that order, so a managed verdict can no longer be out-voted by a user or repo-controlled scope. The env var was deliberately demoted below user settings, correctly reasoned in the code comment: an unconfigured key lets a repo's .claude/settings.json env block populate CLAUDE_PLUGIN_OPTION_* with no real provenance, so it must never outrank a value from an actual settings scope.
  • The jq filter still avoids // empty on the boolean value (which would have discarded a literal false) and uses tostring + explicit length == 0 instead — same correct pattern as before, now applied uniformly to both the managed and user reads via the shared _rlg_settings_option helper.
  • The jq filter itself remains a fixed literal string with no data interpolated into the program; the file path goes through bash redirection (<"$file"), not a jq command-line argument — no injection surface.
  • Fails open (tee still runs) on an unrecognized platform, missing/unreadable file, missing jq, or malformed JSON — consistent with the script's pre-existing "no tee outcome ever alters the wrapped statusline" contract, and correctly not applied to the managed-vs-user precedence itself (a missing managed file correctly falls through to the user scope rather than forcing enabled).

Previously flagged: no test exercised the gate — now fixed

statusline-tee.test.sh previously had zero coverage of _rlg_tee_enabled. This commit adds a main() function behind a [[ "${BASH_SOURCE[0]}" == "${0}" ]] sourcing guard specifically so the test can source the script and stub _rlg_managed_settings_files (the real paths are root-owned and correctly not writable/spoofable from a test) while driving the real end-to-end logic. I read through the new gate cases (statusline-tee.test.sh:397-536): unconfigured, user true/false, marketplace-prefix matching (including a same-prefix different-marketplace fork and a colliding-plugin-name negative case), malformed JSON (fails open), missing jq (fails open), and — the case that actually proves precedence rather than an or-of-falses — both managed false overrides user true and managed true overrides user false. There's also an unstubbed end-to-end case invoking the real script exactly as settings.json would. I was unable to execute the suite in this sandbox (running the test script requires interactive approval that wasn't available here), so this is verified by reading the test logic rather than a live pass/fail confirmation — worth a maintainer confirming CI is green on this job before merge.

What checked out clean

  • No new command/path/template injection surface introduced by this commit.
  • No secrets or credentials handled.
  • The one residual gap the code's own comments and CHANGELOG.md call out — the user-scope settings path is still located via ${CLAUDE_CONFIG_DIR:-$HOME/.claude}, which a repo env block could redirect — does not weaken the fix, since it only affects the user scope, not the managed scope that was the actual policy-bypass concern. This is explicitly documented as an accepted residual, consistent with the same residual already accepted in the channel-F exemplar this mirrors.
  • No workflow, permissions, or dependency-pin changes in this delta.
    · branch feat/plugin-enable-toggles

The manifest's option description and the README's Configuration section
still called rate_limit_guard_enabled the kill switch for the StopFailure
hook alone, and the README told operators that disabling the statusline
tee was their own settings edit — true before 0.5.5 gated the tee's write
on the same option, wrong since.

Both now name the hook and the tee's snapshot write, and the README states
where each surface reads the value from and that the tee's precedence is
managed → user settings → environment, so a managed value outranking a
user one is explicable rather than surprising. README options block
regenerated by scripts/sync-plugin-options-docs.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (git diff cd99f92..HEAD, matches this PR's full changed-files list; HEAD is 2b46c68)
  • Determine what's new since the last reviewed commit (a7d44b2) — only 2b46c68, which is documentation-only (manifest description + README prose), no code/logic change
  • Review .github/workflows/ci.yml change
  • Review lib/hook-utils.sh (hook::is_enabled/hook::check_enabled)
  • Review plugins/rate-limit-guard/scripts/statusline-tee.sh gating (managed-settings fix)
  • Review plugins/source-control/.claude-plugin/plugin.json manifest change
  • Review scripts/sync-plugin-options-docs.py generator
  • Post findings

No security issues found. This pass re-verifies the whole PR against the current HEAD by reading the files directly (not just trusting prior review rounds' conclusions), scoped to logic/access-control/trust-boundary reasoning per this repo's lane split — zizmor's static coverage (unpinned actions, dangerous triggers, permission widening) isn't re-litigated here.

What's new since the last reviewed commit (a7d44b2): 2b46c68 touches only plugins/rate-limit-guard/.claude-plugin/plugin.json's option description, plugins/rate-limit-guard/CHANGELOG.md, and plugins/rate-limit-guard/README.md — all prose describing that rate_limit_guard_enabled now governs both the hook and the tee's write, and where each surface reads it from. No code, no logic, no new sink. Not a security-relevant change.

Full-PR re-verification, independent of the prior rounds' conclusions:

  • .github/workflows/ci.yml — new plugin-options-docs-gate job: actions/checkout pinned to a full commit SHA, persist-credentials: false, no secrets, plain pull_request trigger, runs python3 scripts/sync-plugin-options-docs.py --check (read-only), wired into the ci-status needs: graph. No trust-boundary or permission change.
  • lib/hook-utils.sh#L19-L46hook::is_enabled/hook::check_enabled split is a pure, behavior-preserving refactor; confirmed the old check_enabled body is logically identical to hook::is_enabled "$1" || exit 0. The indirect expansion ${!var_name} builds the variable name from a fixed prefix plus a caller-supplied literal, never external input — no injection surface.
  • plugins/rate-limit-guard/scripts/statusline-tee.sh#L266-L448 — this is the file with the most review history on this PR (three implementations across the thread). Read the final form directly rather than trusting the prior "looks fixed" conclusion:
    • The two IMPORTANT findings from earlier rounds (delivery channel never reaching the statusLine-invoked process; managed settings silently skipped) are both closed. _rlg_managed_settings_files resolves fixed, hardcoded, root-owned per-platform paths via uname -s (never $OSTYPE/env-derived), rejects a non-absolute path outright (defense-in-depth against a redirected highest-precedence scope), and layers managed-settings.d/ drop-ins. _rlg_tee_enabled checks managed → user settings → env var, in that precedence order, with the env var correctly demoted last since an unconfigured key can be populated with no real provenance by a repo's own env block.
    • _rlg_settings_option's jq filter avoids // empty on the boolean (which would discard a literal false) — uses tostring + explicit length == 0 instead. The filter itself is a fixed literal string with no interpolated data; the settings file is opened via bash redirection (<"$file"), not passed as a jq argument — no command/argument injection surface.
    • Fails open (tee still runs) on an unrecognized platform, missing/unreadable file, missing jq, or malformed JSON — matches the script's pre-existing "no tee outcome ever alters the wrapped statusline" contract; this fail-open direction is appropriate here since only the plugin's own snapshot write is gated, not an authorization decision.
    • The for f in "$dropin"/*.json glob has no nullglob, but the unmatched-glob case is caught by the subsequent [[ -f "$f" ]] guard — no bug.
    • statusline-tee.test.sh now stubs _rlg_managed_settings_files for its fixture-driven cases (the real paths are root-owned, correctly not writable/spoofable from a test) while driving the real end-to-end logic through a main() gated by a [[ "${BASH_SOURCE[0]}" == "${0}" ]] sourcing guard — closes the "no test exercises the real call site" gap raised earlier in this thread.
  • plugins/source-control/.claude-plugin/plugin.json — adds the missing worktree_create_gate_enabled declaration; the hook already read this var, default: true preserves prior (accidentally-always-on) behavior. No privilege or default-behavior change.
  • scripts/sync-plugin-options-docs.py — local, offline, read-manifest/write-README only. No subprocess, eval, or network access. Manifest-derived description text is escaped for |/[/] before insertion into a generated markdown table cell, adequate for that sink. Both prior gaps (dropped multiple/required/min/max; stale block surviving a removed option) are fixed with correct logic (lines 64–70, 161–180).
  • Generated README diffs (57 files) — mechanically identical structure; scanned the diff for <script, onerror=, javascript:, and unexpected http:// — none found.

No injection surfaces (command/path/template), no secret-handling issues, and no GitHub Actions trust-boundary problems found in this PR's changed files as of the current HEAD.
· branch feat/plugin-enable-toggles

@kyle-sexton
kyle-sexton merged commit 2fb6901 into main Aug 11, 2026
34 checks passed
@kyle-sexton
kyle-sexton deleted the feat/plugin-enable-toggles branch August 11, 2026 05:04
kyle-sexton added a commit that referenced this pull request Aug 12, 2026
… and its opt-out honest (#2309)

## Summary

Two issues in one file pair, both about what a stuck user actually sees
when a `WorktreeCreate` fails.

**#2209 — the failure output.** `status=$?` sat inside the body of `if !
path="$(bash "$helper" …)"`, where `$?` is the status of the *negated
compound* — `0` exactly when the command failed. Every failure therefore
reported `exited 0`. Proven directly:

```
$ bash -c 'f(){ return 7; }; if ! out="$(f)"; then status=$?; echo "status=$status"; fi'
status=0
```

That constant is what produced the "a hook exited 0 while failing"
theory a whole verification pass had to unwind. The assignment now
stands alone, and the helper's documented `0/2/3/4` taxonomy is
translated into distinct messages instead of being discarded — "not a
repository", "no `worktree_root` configured" and "illegal branch name"
were previously one indistinguishable line. Every refusal now leads with
a **remedy** and follows with the diagnosis. `hook::buffer_stdin`'s
status is checked, so an empty/unbufferable payload is no longer
misreported as "the payload carried no `.name`".

**Corrected mechanism.** #2209 was filed on the premise that the
transcript surfaces only the **first** stderr line. Measured on 2.1.228,
a failing hook's stderr is surfaced **in full**:

```
Error creating worktree: WorktreeCreate hook failed: bash "…/fail-hook.sh": FIRST-STDERR-LINE
SECOND-STDERR-LINE
```

Remedy-first still holds — it is the line a reader acts on — but as a
readability argument, not a truncation one. The CHANGELOG states the
corrected version.

**#2211 — the opt-out asserted an unmeasured harness behavior, and it
was false.** The issue said one `claude --worktree` in a scratch repo
would settle it. It was run — four arms, on Claude Code **2.1.228**:

| Arm | Hook | Result |
|---|---|---|
| 1 | *(none — control)* | created at `<repo>/.claude/worktrees/probe0`,
branch `worktree-probe0`, **locked** (`claude session probe0 (pid
29884)`) |
| 2 | `exit 0`, no stdout | **creation FAILS**, CLI exit 1, nothing
created |
| 3 | `exit 3`, two stderr lines | creation fails; **both** lines
surfaced |
| 4 | prints a path it did not create | creation fails — the directory
must exist |

```
$ claude -p '…' --worktree probe1 --settings <hook: exit 0, no stdout>
Error creating worktree: WorktreeCreate hook failed: hook succeeded but returned no worktree path
(command: echo the path to stdout; http/callback: return hookSpecificOutput.worktreePath)
$ git worktree list   # nothing created
```

So `worktree-create-gate.sh:46-47`'s header ("Disabled means 'let Claude
Code use its own default', which is exit 0 with an empty stdout") and
the two suite assertions at `:131-132` were **both false**. The old
exit-0 path produced the *same* outcome as a refusal while suppressing
every explanation, because an exit-0 hook's stderr is dropped — arm 2's
stderr marker is absent from the harness output while arm 3's two lines
are present. The option only became reachable at 0.51.7 (#2193 declared
it in `userConfig`), so this is the first release anyone could hit it.

The disabled path now **exits non-zero** and names the real stand-downs:
`worktree.bgIsolation: "none"`, or disabling the plugin. `plugin.json`
and `README.md` say the same.

**The docs also agree now**, contrary to what #2211 recorded.
Raw-markdown fetch of `https://code.claude.com/docs/en/hooks.md`
(2026-08-11) carries "Hook failure or **missing path** fails creation"
and "If the hook fails or produces **no path**, worktree creation fails
with an error." #2211 recorded those sentences as unreachable; at the
current revision they are reachable and they match the measurement.

**The probe is now a recorded, runnable fixture** —
`skills/worktree/fixtures/worktree-create-hook-probe.sh` plus a
`README.md` carrying the outcome, verbatim harness strings, doc quotes,
an as-of stamp (2026-08-11, 2.1.228) and a recheck trigger, composed
with `docs/conventions/upstream-drift/`. A recheck is one command, not a
re-derivation.

**Not changed, deliberately: `hooks.json` registration scope (#2211's
`SC-F1`).** `WorktreeCreate` supports no matcher — "no matcher support /
always fires on every occurrence" — so registration is the only seam and
there is nothing narrower to register. Per AD-4 the non-repo outcome is
upstream (the harness skips isolation and edits in place), and the issue
itself notes a change here would not be fleet-complete since other
installed plugins may register the same event. Filed-not-fixed with that
reason, not dropped.

**Also fixed, in files already being edited:** both worktree suites set
a throwaway git identity but not `commit.gpgsign false`, so on a machine
that signs by default every fixture commit failed and the suites
reported their *creation* cases as failures while their refusal cases
still passed — a shape that reads as a regression rather than an
unrunnable fixture. `worktree-create.test.sh` goes 94/154 → 154/154.

**Security review:** no new trust surface. No new hook, grant, network
read, or file write outside existing paths. The change is strictly
message text plus one exit-status correction, and it moves the disabled
path from exit 0 to exit 1 — i.e. **more** fail-closed, never less. The
new fixture script is opt-in and run by hand.

## Test plan

New assertions fail against the pre-change code and pass after —
verified by checking the merge-base (`a0abaf81`) copies of both scripts
back in and re-running:

```
$ bash plugins/source-control/hooks/worktree-create-gate.test.sh   # OLD code, NEW tests
FAIL: [16] disabled refuses non-zero, because exit 0 without a path fails creation anyway — exit expected 1 got 0
FAIL: [20] disabled names the real harness-side stand-down — expected worktree.bgIsolation in: worktree-create-gate: disabled by worktree_create_gate_enabled=false; Claude Code will place this worktree at its own default, which may be inside the repository
FAIL: [21] disabled states plainly that the option cannot hand placement back
FAIL: [22] the remedy leads — a reader acts on the first line, so it must not be the diagnosis
FAIL: [23] a non-repository is named as such, not as an opaque exit code
FAIL: [24] a non-repository names the harness-side stand-down as the remedy
FAIL: [25] the constant-zero exit status is gone — forbidden "exited 0" present
FAIL: [28] an illegal branch name is reported as a name problem
FAIL: [31] an empty payload is reported as an empty payload — got: "the WorktreeCreate payload carried no .name"
FAIL: [33] a nameless payload is reported as a nameless payload
```

After the change:

```
$ bash plugins/source-control/hooks/worktree-create-gate.test.sh
PASS: [1] a configured root creates the worktree
… (34 cases)
PASS: [22] the remedy leads — a reader acts on the first line, so it must not be the diagnosis
PASS: [23] a non-repository is named as such, not as an opaque exit code
PASS: [25] the constant-zero exit status is gone
PASS: [30] an empty payload refuses
PASS: [32] an empty payload is NOT misreported as a missing .name field
PASS: [34] field order does not matter
# 34 PASS, 0 FAIL, rc 0

$ bash plugins/source-control/scripts/worktree-create.test.sh
PASS=154 FAIL=0 SKIP=0    # rc 0  (was 94/154 on this machine before the gpgsign fix)
```

Lint and repo hygiene:

```
$ shellcheck -x -f gcc <the four touched shell files>     # rc 0, no findings
$ npx markdownlint-cli2 <the three touched markdown files>
  Summary: 0 issues in 0 files
$ bash scripts/check-silent-skips.sh
  No silent prerequisite skips found in hook entry scripts.
$ bash scripts/check-orphaned-fixtures.sh                 # rc 0
$ bash scripts/check-changelog-parity.sh --check          # rc 0
$ 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/check-changelog-parity.sh --check-order    # rc 0
```

The `silent-skip-ok:` marker was removed along with the exit-0 skip it
justified; `check-silent-skips.sh` confirms nothing now needs one.

## Related

Closes #2209
Closes #2211

Inbox item:
`20260811-021645-plugin-audit-four-components-and-guard-deadlock-ownership`
(ledger
`.work/handoff-inbox-batch-4/ledgers/I9-021645-four-components.md`, §
"Lane D — `source-control` / `worktree-create-gate`").

Settles **DQ-6** from `.work/handoff-inbox-batch-4/RECONCILE.md`: the
one `claude --worktree` command was run, and exit-0-with-no-path fails
creation.

Adjacent, not touched here: #2146 (`hook::require_jq` fail-open in the
same utility file — different function), #1971 (upstream coverage of
nested-worktree rule inheritance), #2208 (the upstream draft for the
non-repo isolation divergence).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.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.

Plugin options are undiscoverable, and two enablement paths do not work

1 participant