feat(procedures): one off-switch per gate, on by default, recorded when used - #10
feat(procedures): one off-switch per gate, on by default, recorded when used#10drewdrewthis wants to merge 7 commits into
Conversation
…en used Installed as a plugin, the only way to silence a gate was uninstalling the plugin — coarser than anyone wants, and it takes the skills with it. One named boolean per gate, declared as userConfig and read by that gate alone: enable_how_do_i_gate -> how-do-i-gate.sh enable_am_i_done_gate -> am-i-done-gate.sh enable_frontmatter_check -> enforce-frontmatter.sh No wildcard, no comma-spec, and deliberately no central list of the switches: an enumerated registry is the ts_reset bug in ADR-016, and a parsed spec puts a typo in the same drawer as a real failure — every gate here fails open, so one bad character would disarm all three. Either CLAUDE_PLUGIN_OPTION_ENABLE_<KEY> or PROCEDURES_ENABLE_<KEY> saying false turns a gate off; neither wins. Precedence would make the plain var dead on an installed plugin: the option carries "default": true, so the harness exports it as true on every hook invocation, and a plain override losing to it could only ever be observed in a bare checkout — the case that doesn't need it. Recorded to its own gate-escape.jsonl, never gate-failopen.jsonl. A switched gate is not a blind release and would destroy the fail-open rate that log carries; but an unrecorded off-switch is invisible, and nothing would separate an owner debugging for an hour from a child session spawned as `PROCEDURES_ENABLE_AM_I_DONE_GATE=false claude -p …` to skip review. Both reviewers flagged that gap independently. Boy scout: enforce-frontmatter now resolves SCRIPT_DIR without `dirname`, like the two gates. The escape check runs before the jq check, and that path is exercised with PATH emptied. Sites enumerated with: grep -rn "gate_failopen\|ga_binds_main" plugins/procedures/hooks/ grep -rn "FRONTMATTER_GATE\|ge_released\|DISABLE_GATES" hooks/ README.md docs/ Tests: 19 new in hooks/tests/gate-escape.bats; suite 160 ok / 0 not ok. Falsified by reverting the wiring — the four outcome tests go red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 115 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThe procedures plugin version is updated to ChangesProcedures gate controls
Estimated code review effort: 4 (Complex) | ~45 minutes Mergeability Score: ⚪ Minimal · up to The change adds configurable per-gate escape switches with recording behavior, and no actionable merge-blocking risk remains beyond a localized provenance follow-up. Possibly related issues
Possibly related PRs
Sequence Diagram(s)sequenceDiagram
participant Claude
participant GateHook
participant gate_escape.sh
participant gate-escape.jsonl
participant gate-failopen.jsonl
Claude->>GateHook: invoke enforcing hook
GateHook->>gate_escape.sh: evaluate gate option
alt gate explicitly disabled
gate_escape.sh->>gate-escape.jsonl: record gate release
gate_escape.sh-->>GateHook: exit successfully
else gate enabled
gate_escape.sh->>gate-failopen.jsonl: record fail-open release when needed
gate_escape.sh-->>GateHook: continue enforcement or fail open
end
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
⚔️ Resolve merge conflicts 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…ends on security-reviewer: the "a cloned repo cannot disarm your gates" property holds only from Claude Code v2.1.207, where pluginConfigs stopped being read from project-level settings. The manifest cannot express a version floor and nothing checks it at runtime, so the docs are the enforcement. It was stated only in a comment inside a sourced lib — not where someone installing the plugin looks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
drewdrewthis
left a comment
There was a problem hiding this comment.
Own-PR review fan-out: principles, hygiene, security, test-reviewer, plus 4 devils-advocate personas (Uncle Bob, Metz/Beck, Fowler, design-soundness). Every blocking finding below was reproduced against the real hooks before being posted — the reproduction command is in each thread. Verdict comment follows.
Review verdict: READYReviewed at: No blocking concerns. All review threads resolved at this SHA, and the final scoped gate returned NO BLOCKING DEFECTS, having mutation-tested each fix on a copy. READY here means no unresolved blocking review threads. It is not merge-readiness, and one substantive gap is declared below rather than closed. What the rounds foundRound 1 raised five blocking threads and one New AC. Round 2 found the round-1 fix had not reached two sites of the same defect class:
Round 3 pinned the fallback shim (deleting it survived all 165 tests while breaking the degraded path — an undefined function returns 127 and execution falls through into the deny) and corrected two hook comments that had become false. Round 4 — CodeRabbit's first pass. It skips drafts (#13), so marking this PR ready is what finally got it a read. Two findings, both valid, both fixed:
Merged current main in
The prior "version conflict with #9" item is now moot: #9 merged on 2026-08-07 ( The declared gap — unchanged, and still the thing to weighThe Everything measured runs through Non-blocking (Decide / New Issue)
Evidence at this SHA
Verdict is prose, not a GitHub approval. Scope: review findings only — READY means no unresolved blocking review threads at this SHA. It is not a merge-readiness signal; that is |
…e docs Addresses the five blocking threads and the New AC on #10. RECORD AT THE RELEASE POINT. The switch was evaluated before each hook knew whether it would have acted, so gate-escape.jsonl counted hook invocations: a subagent call the gate never binds wrote a row, as did a Write to an unrelated .txt. An orchestrator exporting the var once to debug produces thousands of identical rows, which is the "gate stays off for months" failure the log was added to prevent. The lib is still sourced early; the CALL moved below the audience/allowlist checks (how-do-i), below the activity check (am-i-done), and below the scope filters (frontmatter). THE THREAT MODEL WAS FALSE. README, ADR-001 and the lib all claimed a cloned repo could not disarm the gates, citing v2.1.207's pluginConfigs isolation. That holds for CLAUDE_PLUGIN_OPTION_* and is irrelevant to the plain var: per code.claude.com/docs/en/settings, a project's .claude/settings.json `env` block applies "to every session and to subprocesses Claude Code spawns from it" — a hook is such a subprocess, on every version. All three sites now say only the userConfig channel carries the isolation property and name PROCEDURES_ENABLE_* as untrusted ambient config. NOT TAMPER-EVIDENT, now stated. GATE_ESCAPE_LOG is a plain env var and the append is best-effort, so whoever sets a switch can also send the record to /dev/null (verified: gate released, exit 0, no record anywhere). The log is for finding a gate you left off, not for catching an adversary. TESTS. `every declared enable_* option is read by a hook` passed with the whole userConfig block deleted — a `for` over an empty key list runs zero assertions; now asserts non-empty first. Adds the reverse sweep (a hook key absent from the manifest fails), per-hook record content checks (a copy-paste bug hardcoding one key would have passed), the unreadable-lib ARMED property that three hook comments asserted and nothing pinned, and a release-vs-invocation test. Suite 164 ok / 0 not ok. Both new behaviours falsified on a COPY of the tree (never the working tree — that is how this worktree got wrecked mid-review): restoring the early placement reddens the release-vs-invocation test, and deleting userConfig reddens tests 18/19/20. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… directions
Two Must-Fixes from the scoped re-review. Both are the round-1 defect class
("the log counts invocations, not releases") surviving at sites the first fix
did not reach.
FRONTMATTER: the switch sat above the record-store predicate. The filters
before it are only `*.md`, `-f`, and under-$ROOT; whether a file is a RECORD is
decided inside lint-frontmatter.sh, which runs later. So with the check off,
every CLAUDE.md, README.md, agents/*.md, SKILL.md and template under the root
logged a release nothing was going to block. Measured before the fix:
$ROOT/README.md armed -> 0 rows, switch off -> 1 row. The call now lives inside
the violation branch, the one place the check would actually have fired.
FAIL-OPEN PATHS: gate_failopen never returns, so in every degraded state (no
jq, unreadable lib, unwired reset hook) the switch was unreachable and a
deliberate release was filed as a BLIND fail-open — one contaminating row per
tool call for a whole session, in the numerator that log exists to protect.
ADR-001, README and the lib header all asserted the opposite. New
ge_release_or_failopen() asks the switch first and falls through to
gate_failopen otherwise; both arms exit. Measured after: gate off + no .turn
marker -> 1 escape row, 0 fail-open rows. Armed, same state -> unchanged.
A missing escape lib must not turn a fail-open into a fall-through, so each
gate defines a one-line fallback shim when the classifier is absent.
TESTS. `an unreadable escape lib leaves every gate ARMED` claimed three gates
and exercised two — removing the guard from enforce-frontmatter left all tests
green. Third leg added. Its scratch dir moves under TURN_STATE_DIR so teardown
reclaims it (a RETURN trap is unusable: bats runs with functrace, so it fires
on the first helper's return). Adds a $ROOT/README.md case and a degraded-path
classification test.
Suite 165 ok / 0 not ok. Both fixes falsified on a COPY: reverting either
reddens its test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ents Final gate returned NO BLOCKING DEFECTS; these are its Should-Improves. - The shim was entirely unpinned: deleting it (or its `shift`) survived all 165 tests, while deleting it actually breaks the degraded path — an undefined function returns 127 and execution FALLS THROUGH into the deny, so a gate denies exactly where fail-open must release. Test 24 now covers it with the escape lib chmod 000; removing the shim reddens it. Test 22 could never catch this because it asserts the deny. - Centralized the shim into gate-failopen.sh behind a `declare -F` guard instead of duplicating it in two hooks. Safe because gate-escape.sh is sourced first in both gates, so the real definition always wins; the guard only covers the state where that lib was unreadable. - `ge_release_or_failopen` called `gate_failopen` unguarded, breaking its own never-returns contract in a state that exists in-tree: enforce-frontmatter sources gate-escape.sh but not gate-failopen.sh. Guarded. - Both gate comments claimed the switch is evaluated "only once this gate knows it would otherwise have denied/blocked". False since round 2: on no-jq and lib-unreadable paths it is asked before audience and allowlist filtering, so a subagent call can write a row. The code cannot do better — without jq there is no audience to filter on — so the comments now say that, and say those rows are not audience-filtered. Suite 165 ok / 0 not ok. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@plugins/procedures/hooks/lib/gate-failopen.sh`:
- Around line 76-82: Add a literal “# PLUGIN ADAPTATION: <why>” comment
immediately before the fallback ge_release_or_failopen shim, identifying it as
plugin-specific behavior absent upstream. Preserve the existing guard and
function implementation unchanged.
In `@plugins/procedures/hooks/tests/gate-escape.bats`:
- Around line 267-268: Update the KEYS extraction in the hook-to-manifest
contract test to avoid grep’s non-portable -P option and work with BSD and GNU
toolchains. Preserve the existing behavior of extracting uppercase keys from
ge_enabled declarations across the hook scripts, sorting them, and validating
that the result is non-empty.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 8b379afb-ae6c-46c7-9267-d5e4982572d0
📒 Files selected for processing (9)
README.mddocs/adrs/001-procedural-knowledge-system.mdplugins/procedures/.claude-plugin/plugin.jsonplugins/procedures/hooks/am-i-done-gate.shplugins/procedures/hooks/enforce-frontmatter.shplugins/procedures/hooks/how-do-i-gate.shplugins/procedures/hooks/lib/gate-escape.shplugins/procedures/hooks/lib/gate-failopen.shplugins/procedures/hooks/tests/gate-escape.bats
…extraction Two findings from the first CodeRabbit pass (it skips drafts, so this is its first read of this branch): 1. gate-failopen.sh — the ge_release_or_failopen shim is new behavior in a vendored file with no upstream counterpart, but carried only an explanatory comment, not the literal '# PLUGIN ADAPTATION:' marker the repo's vendoring rule requires. A future vendor sync could not tell it from upstream code. 2. gate-escape.bats — the manifest contract test extracted gate keys with grep -ohP. PCRE is a GNU extension; BSD grep rejects -P, KEYS comes back empty, and the non-empty guard then fails the test for a toolchain reason while reporting a contract breach. Extraction output verified byte-identical to the PCRE form, and a mutation probe (hook reads an undeclared gate key) still fails the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main moved 12 commits (0.2.0 -> 0.3.1) while this branch sat in review, so
the PR went DIRTY. Three conflicts, all resolved additively:
- plugin.json — took main's release-please-managed 0.3.1 and its expanded
author block, kept the three userConfig booleans. Deliberately NOT
hand-bumped to 0.4.0: main versions this file via release-please, so the
feature bump belongs to the release commit, not to this branch.
- README.md — kept BOTH adaptation bullets (main's fork-skill model pin and
this branch's configuration surface) and main's count-free "a further"
wording, which no longer needs an ordinal now that the list grows.
- how-do-i-gate.sh — took main's newer deny-message wording ("File reads and
read-only shell inspection stay available") and kept the switch guard above
it. The rest of the escape-hatch wiring auto-merged.
Suite: 228 ok / 0 not ok on the merge result — up from 165, since main brought
its own tests along.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #16
What
An escape hatch for the procedures plugin's three enforcing hooks: one named boolean per gate, on by default, recorded when a gate is actually released.
enable_how_do_i_gatehow-do-i-gate.sh(PreToolUse)enable_am_i_done_gateam-i-done-gate.sh(Stop)enable_frontmatter_checkenforce-frontmatter.sh(PostToolUse)Each reaches the hook as
CLAUDE_PLUGIN_OPTION_ENABLE_<KEY>;PROCEDURES_ENABLE_<KEY>is the plain-env equivalent for one-off invocations.Why
Installed as a plugin you cannot edit a hook to silence it. The platform has no targeted alternative — verified against the shipped 2.1.224 binary and the docs:
disableAllHooksis global (it also kills statusLine and/goal),claude plugin disabletakes the skills and commands with it, and "There is no way to disable an individual hook while keeping it in the configuration." NotablyskillOverridesexists for per-skill disable with no hook analogue.Design notes
No wildcard, no comma-spec, no central registry. An enumerated list is the
ts_resetbug ADR-016 documents. A parsed spec is worse here specifically: every gate fails open, so a malformed spec lands in the same drawer aslib-unreadable:*and one typo disarms all three. A per-gate boolean is fail-safe by construction.Neither channel has precedence — either saying
falseturns the gate off. With precedence the plain var would be dead on an installed plugin: the option carries"default": true, so the harness exports it astrueon every invocation.The record is written at the release point. Earlier revisions checked the switch before the hook knew whether it would act, so the log counted invocations — a subagent call the gate never binds wrote a row, as did a
Writeto an unrelated.txt.Only the userConfig channel is trusted. See the security section below.
Human verification (for if you really don't trust me)
cd plugins/procedures && bats hooks/tests— 164 pass.cp -r plugins/procedures $(mktemp -d)/rev, revert thege_enabledcall inrev/hooks/how-do-i-gate.shto the top of the file, re-run —a released gate is recorded only when it would otherwise have firedgoes red.jq 'del(.userConfig)' .claude-plugin/plugin.json— tests 18, 19 and 20 all go red.chmod 000the escape lib on the copy and check a gate still denies.claude plugin install procedures@<marketplace> --config enable_how_do_i_gate=falsethen inspectpluginConfigsin~/.claude/settings.json.How I can prove I was successful
-
-
-
-
-
-
Backend-only: shell hooks, a JSON manifest, and docs. No UI and no rendered surface; the terminal output above is the whole observable behaviour.Suite green at this head — proven.
The wiring is load-bearing, not decorative — proven (falsified on a copy). Restoring the early switch placement reddens
a released gate is recorded only when it would otherwise have fired; deleting.userConfigreddens tests 18/19/20. Both mutations were run againstcp -rcopies, never the working tree.The log now counts releases, not invocations — proven. Before the fix, a subagent payload wrote 1 row and a
Writeto/tmp/not-a-record.txtwrote 1 row carrying{"gate":"FRONTMATTER_CHECK",...}. Both are now pinned at zero rows by test 23.The audit log is defeatable — proven, and now documented rather than claimed otherwise.
userConfig storage works — proven.
All three options are recognised and
--configstores a real boolean.The
CLAUDE_PLUGIN_OPTION_ENABLE_*delivery path — NOT verified, and the reason is now pinned down. This is the channel that closes No way to silence one gate of the procedures plugin without uninstalling it #16 (the plain env var requires wrapping every launch), so it is the weakest point of the PR and I could not close it.What I tried: installed this branch into a throwaway
CLAUDE_CONFIG_DIRwith--config enable_how_do_i_gate=false, instrumented the cachedhow-do-i-gate.shto dumpCLAUDE_PLUGIN_OPTION_*to a unique marker file on entry, and drove a real interactive TUI session (tmux) — not print mode — until it executed a Bash tool call.Result: the tool call ran; the marker file was never written. The hook did not execute.
This is not a plugin-loading failure, and I checked that specifically rather than assuming — in the same session:
Skills load; hooks do not fire. Same null in print mode earlier.
enabledPlugins,pluginConfigsand the cachedhooks/hooks.jsonwere all verified present in that config.So: I cannot demonstrate the option reaching a hook in a redirected-config environment, and I have not isolated whether that is a
CLAUDE_CONFIG_DIRlimitation or something else. Everything measured in this PR runs throughPROCEDURES_ENABLE_*against the real hook scripts. design-soundness confirmed from the shipped 2.1.224 binary that the manifest key →CLAUDE_PLUGIN_OPTION_<KEY>transform is byte-for-byte whatgate-escape.shreads — but that is a static trace, not a runtime observation.What would close it: set the option in the real
~/.claude/settings.jsonpluginConfigson a machine where these hooks already demonstrably fire, and observe a gate release. I did not do that because it mutates the live gating config of this box.Security
The docs previously claimed a cloned repo could not disarm the host's gates, citing v2.1.207's
pluginConfigsisolation. That was false for the channel this PR adds — a project's.claude/settings.jsonenvblock applies "to every session and to subprocesses Claude Code spawns from it", and a hook is such a subprocess, on every version. README, ADR-001 and the lib header now state that onlyCLAUDE_PLUGIN_OPTION_*carries the isolation property and thatPROCEDURES_ENABLE_*is untrusted ambient config.Neither channel is tamper-evident: whoever can set a switch can also point
GATE_ESCAPE_LOGelsewhere. The log exists to show you a gate you left off months ago, not to catch an adversary — now said plainly in all three places rather than oversold.Review
Reviewed by the standard own-PR fan-out: principles, hygiene, security, test-reviewer, plus four devils-advocate personas (Uncle Bob, Metz/Beck, Fowler, design-soundness). Five blocking threads and one New AC were raised; all are addressed in
3bf267c. See the verdict comment.Summary by CodeRabbit
New Features
Documentation
Tests