From 157ae63704ad2bfa55a81963bf6390879eb3ec38 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:20:54 -0400 Subject: [PATCH 1/9] feat(guardrails): default-off the two behavioral injectors and narrow the -m gate to real newlines Guardrails slice of #2021's remediation plan (proposed-work lines 1-2) plus the two cosmetic dangling items. Version 0.19.3 -> 0.20.0. - flag-commit-pr-skill-bypass and workflow-resilience-check default OFF: behavioral-class prose injectors, config-disabled per the instruction-economy evidence gate (scripts kept; opt back in via the existing userConfig switch). plugin.json defaults flip to false and each script uses an explicit ${VAR:-false} opt-in test, since hook::check_enabled's unset fallback is "true". - block-noncanonical-commit narrowed: only a -m/--message value that actually contains a newline blocks (the cross-shell mangling hazard). Single-line -m, bare git commit, and repeated single-line -m pass; exemptions and fail-closed refusals unchanged; PowerShell here-string -m still fails closed (uninspectable, multi-line by form). Test suite respelled in both directions. - hooks.json: merged the two identical Bash|PowerShell PreToolUse groups into one (behavior identical per the current hooks reference). - Fixed three stale "Triggered on Bash" headers (wired Bash|PowerShell). Tests: block-noncanonical-commit 181/0, block-hook-bypass 240/0, flag-commit-pr-skill-bypass 29/0, workflow-resilience-check 16/0; shellcheck clean; repo gates (silent-skips, changelog parity, shell portability, manifest keys, markdownlint) pass. Co-Authored-By: Claude Fable 5 --- docs/CATALOG.md | 2 +- plugins/guardrails/.claude-plugin/plugin.json | 16 +- plugins/guardrails/CHANGELOG.md | 69 +++++++ plugins/guardrails/README.md | 15 +- plugins/guardrails/hooks/block-hook-bypass.sh | 2 +- .../hooks/block-noncanonical-commit.sh | 87 ++++++-- .../hooks/block-noncanonical-commit.test.sh | 195 +++++++++++------- .../hooks/flag-commit-pr-skill-bypass.sh | 13 +- .../hooks/flag-commit-pr-skill-bypass.test.sh | 13 ++ plugins/guardrails/hooks/hooks.json | 7 +- .../hooks/workflow-resilience-check.sh | 11 +- .../hooks/workflow-resilience-check.test.sh | 10 + 12 files changed, 314 insertions(+), 126 deletions(-) diff --git a/docs/CATALOG.md b/docs/CATALOG.md index ed325d2478..0498ea9931 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -86,7 +86,7 @@ plugin manifests and kept in sync by CI — never hand-edit it; the category voc ## Security -- [`guardrails`](../plugins/guardrails) — Twelve safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (advisory) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable. +- [`guardrails`](../plugins/guardrails) — Twelve safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, multi-line `git commit -m` messages (an actual-newline `-m` mangles across shells; single-line `-m` passes), commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (advisory, opt-in) un-throttled Workflow fan-out that risks burst 529s, and (advisory, opt-in) direct gh pr create calls bypassing this marketplace's own pull-request skill — each independently toggleable. ## Workflow diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 15f80237b0..0cd7138307 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "guardrails", - "version": "0.19.3", - "description": "Twelve safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (advisory) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable.", + "version": "0.20.0", + "description": "Twelve safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, multi-line `git commit -m` messages (an actual-newline `-m` mangles across shells; single-line `-m` passes), commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (advisory, opt-in) un-throttled Workflow fan-out that risks burst 529s, and (advisory, opt-in) direct gh pr create calls bypassing this marketplace's own pull-request skill — each independently toggleable.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" @@ -53,7 +53,7 @@ "block_noncanonical_commit_enabled": { "type": "boolean", "title": "block-noncanonical-commit guard", - "description": "Block `git commit` that does not pipe its message via `-F -`; --amend, -C/-c, --fixup/--squash, -F , and an in-progress merge/rebase are exempt", + "description": "Block `git commit -m` when the message actually contains a newline (multi-line `-m` mangles across shells — pipe it via `-F -` instead; single-line `-m` passes); --amend, -C/-c, --fixup/--squash, -F , and an in-progress merge/rebase are exempt", "default": true }, "block_convention_gate_enabled": { @@ -83,14 +83,14 @@ "workflow_resilience_check_enabled": { "type": "boolean", "title": "workflow-resilience-check guard", - "description": "Advise on un-throttled Workflow fan-out (never blocks)", - "default": true + "description": "Advise on un-throttled Workflow fan-out (never blocks). Default off since 0.20.0: a behavioral-class prose injector, config-disabled per the instruction-economy evidence gate (#2021) — set true to opt back in", + "default": false }, "flag_commit_pr_skill_bypass_enabled": { "type": "boolean", "title": "flag-commit-pr-skill-bypass guard", - "description": "Advise when direct git commit / gh pr create bypasses the source-control skills (never blocks)", - "default": true + "description": "Advise when a direct gh pr create bypasses the source-control pull-request skill (never blocks). Default off since 0.20.0: a behavioral-class prose injector, config-disabled per the instruction-economy evidence gate (#2021) — set true to opt back in", + "default": false }, "cli_flag_verify_bins": { "type": "string", @@ -113,7 +113,7 @@ "block_noncanonical_commit_allow": { "type": "string", "title": "block-noncanonical-commit allow-list", - "description": "Comma-separated form tokens to allow (currently: message-flag, which permits a bare `-m`)", + "description": "Comma-separated form tokens to allow (currently: message-flag, which permits `-m` even when the message contains a newline)", "default": "" }, "block_no_verify_hook_manager_prefixes": { diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index e610852d4f..571cd1dcec 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,75 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.20.0] + +### Changed + +- **The two behavioral-class advisory injectors now default OFF: `flag-commit-pr-skill-bypass` and + `workflow-resilience-check`.** Issue #2021's hook-surface classification found these are the + plugin's only two clean behavioral-class context injectors — fixed prose that consults no external + ground truth (`flag-commit-pr-skill-bypass` emits a static nudge toward `/pull-request create`; + `workflow-resilience-check` runs two greps and emits a fixed ~120-word checklist asserting nothing + the model cannot derive). Per `docs/PLUGIN-PHILOSOPHY.md` "Instruction economy", a hook that + corrects model behavior is an ablation candidate, and the evidence-gated order is **config-disable + first where a kill switch exists** — so the scripts and their wiring stay, and a consumer opts back + in by setting the existing `flag_commit_pr_skill_bypass_enabled` / `workflow_resilience_check_enabled` + userConfig option to `true`. Deletion, if ever, is a separate change gated on ablation evidence. + + **Mechanism, stated because the shared helper's fallback points the other way:** + `hook::check_enabled` reads the `CLAUDE_PLUGIN_OPTION__ENABLED` process mirror with an + UNSET-means-true fallback, which would silently re-enable a default-off hook anywhere the harness + does not materialize userConfig defaults into the environment. Both hooks therefore switch to an + explicit opt-in test (`[[ "${VAR:-false}" == "true" ]] || exit 0` — the same shape session-flow's + default-off `observer-arm` uses), and the `plugin.json` defaults flip to `false` so the + configuration dialog and `${user_config.*}` agree. Per the current plugins reference + (, fetched 2026-08-08), `default` is the + "Value used when the user provides nothing" and options are "exported to hook processes as + `CLAUDE_PLUGIN_OPTION_`"; the script-side default is what makes the OFF posture hold when + that export is absent. Each test suite now exports the switch ON for its behavior cases and pins + the unset-switch no-op as its own case. + +- **`block-noncanonical-commit` narrowed to the actual hazard: only a `-m` message that REALLY + contains a newline blocks.** The guard used to deny every `git commit` that was not the `-F -` + stdin form — `git commit -m "fix: typo"` included — which #2021 classified hybrid: the multi-line + `-m` cross-shell mangling is a policy-grade hazard, but the blanket width policed style. Now: + + - a single-line `-m` passes; a `-m`/`--message` value carrying an actual newline blocks, in every + spelling the argv scan sees — separated (`-m `), attached (`-m""`), `--message=`, + and a short-option cluster ending in `m` (`-am `); + - bare `git commit` / `git commit -a` (no message source; the old block) now pass — no `-m`, no + mangling hazard; + - repeated single-line `-m` flags pass: git itself joins them as paragraphs, no shell newline is + involved; + - the exemptions are unchanged (`--amend`, `-C`/`-c`, `--fixup`/`--squash`, `-F`, in-progress + sequencer), as are the fail-closed structural refusals (`--config-env` alias shape, + alias-traversal budget, unparsable PowerShell); + - on the PowerShell tool a here-string `-m` value still blocks: the classifier blanks the body to + a placeholder, so its content — multi-line by construction of the form — cannot be inspected, + and the guard fails closed on it. A single-line literal PowerShell `-m` passes; + - the `block_noncanonical_commit_allow` token `message-flag` now means "permit `-m` even with a + newline"; the kill switch is unchanged. + + **Accepted residual, fail-OPEN and documented in the hook header:** a message attached to a + short-option cluster (`-am"multiline"`) is not recognized — which cluster letters take values + is per-option knowledge the scan does not model — consistent with the guard's friction-not-sandbox + posture. The test suite is respelled in both directions: every alias/wrapper/traversal fixture + that asserted a block now carries a real-newline `-m` payload (so it still pins the machinery it + was written for), and new cases pin the allowed single-line forms. + +- **`hooks.json`: the two structurally separate PreToolUse groups carrying the identical + `Bash|PowerShell` matcher are merged into one six-hook group.** Pure wiring cleanup flagged by + #2021 — behavior is identical: per the current hooks reference + (, fetched 2026-08-08), all matching hooks run in parallel, + and same-matcher groups are separate entries that each fire independently, so one group of six and + two groups of four-plus-two schedule the same work. + +### Fixed + +- **Three stale hook headers said "Triggered on Bash tool calls" while wired `Bash|PowerShell`:** + `block-hook-bypass.sh`, `block-noncanonical-commit.sh`, and `flag-commit-pr-skill-bypass.sh` now + say Bash and PowerShell (cosmetic; the wiring itself was already correct). + ## [0.19.3] ### Fixed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 831673e551..049d5b9814 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -14,10 +14,10 @@ Each guard is independently toggleable, so you run exactly the subset you want. | **block-dangerous-git** | PreToolUse · Bash | **Blocks** (exit 2) | Irreversible git operations: `push --force`/`-f` plus the equivalent leading-`+` refspec and `--mirror` forms, and the unsafe `--force-with-lease` spellings, in the two kinds git itself treats differently. **No expected value** (bare `--force-with-lease` or `=`) leases against the remote-tracking ref, which git documents as "trivially defeated" by a background fetch — blocked unless `--force-if-includes` is present, which git documents as the mitigation for exactly this form. **A movable `=:`** — `origin/main`, `HEAD`, a tag, an *abbreviated* object id, or hex of the wrong width for this repository's hash format, all of which git resolves at push time, and gitrevisions resolves a short hex word as a ref before trying it as an object-id prefix — is blocked unconditionally, because git declares `--force-if-includes` a no-op alongside an explicit `:`. A lease passes only when `` is immutable: an object id of the pushed repository's own hash width (40 hex under SHA-1, 64 under SHA-256, read from `git rev-parse --show-object-format` with the command's own `-C`/`--git-dir`/`--work-tree`/`--namespace` replayed onto it; undeterminable fails closed) or the empty string asserting the ref must not exist. The other width is a ref name there, not an object id — git ignores a ref whose name is full-width hex for its own format, but resolves one of the other width like any name. git scopes a pin to its own ref, so a bare fallback alongside a pinned entry still governs every other ref being updated; where the same ref carries several lease entries, git consults the first, and so does this guard. A trailing `--no-force-with-lease` cancels every previous lease, and a push dry-run disarms the check. Also blocked: `reset --hard`, `clean` with a force flag (any dry-run flag disarms), worktree-wide `checkout`/`restore` pathspecs (`.`, `:/`, `:(top…)` — path-scoped forms and `restore --staged .` pass), and forced `checkout -f` / `switch --discard-changes`. Accepted unique-prefix abbreviations of the blocked long options match too. `branch -D` is deliberately not blocked (reflog-recoverable; sanctioned skill flows issue it). Per-repo/per-user allow-list via the `block_dangerous_git_allow` userConfig option (comma list, any subset of `push-force,push-lease-unsafe,reset-hard,clean-force,checkout-dot,restore-dot,checkout-force`). | | **block-hook-bypass** | PreToolUse · Bash | **Blocks** (exit 2) | Bash file-write workarounds that circumvent the Write/Edit hook gates — `cat > file`, `echo … > file`, and `python3 -c` with file-write indicators. Executable-token detection ignores quoted prose/commit text that merely mentions the pattern. | | **cli-flag-verify** | PostToolUse · Write \| Edit | **Advisory** (exit 0) | Hallucinated CLI flags — a `--flag` written as a command that does not exist in the binary's actual `--help` output. Surfaces via `additionalContext`, never blocks. | -| **workflow-resilience-check** | PreToolUse · Workflow | **Advisory** (exit 0) | Un-throttled Workflow fan-out — a script calling `parallel()` / `pipeline()` with no wave-cap throttle (`inWaves` / `inWavesPipeline`) and no retry wrapper (`agentRetry`), which risks a burst 529 under wide Opus fan-out. Surfaces a resilience checklist via `additionalContext`, never blocks. | -| **block-noncanonical-commit** | PreToolUse · Bash | **Blocks** (exit 2) | `git commit` that does not pipe its message via `-F -` / `--file -` — `-m` flattens newlines unpredictably across shells. Exempt: `--amend`, `-C`/`-c`/`--reuse-message`/`--reedit-message`, `--fixup`/`--squash`, `-F `, and any commit taken while a merge/rebase/cherry-pick/revert is in progress. Resolves `bash -lc` wrappers and git aliases (inline `-c` and persisted config alike). | +| **workflow-resilience-check** | PreToolUse · Workflow | **Advisory** (exit 0) | Un-throttled Workflow fan-out — a script calling `parallel()` / `pipeline()` with no wave-cap throttle (`inWaves` / `inWavesPipeline`) and no retry wrapper (`agentRetry`), which risks a burst 529 under wide Opus fan-out. Surfaces a resilience checklist via `additionalContext`, never blocks. **Opt-in — default off since 0.20.0** (behavioral-class injector config-disabled per #2021; set `workflow_resilience_check_enabled=true` to enable). | +| **block-noncanonical-commit** | PreToolUse · Bash | **Blocks** (exit 2) | `git commit -m` whose message actually contains a newline — a multi-line `-m` flattens newlines unpredictably across shells; pipe it via `-F -` / `--file -` instead (narrowed in 0.20.0 per #2021: single-line `-m`, bare `git commit`, and repeated single-line `-m` paragraphs all pass). On the PowerShell tool a here-string `-m` value blocks too — its content is uninspectable and multi-line by construction of the form. Exempt: `--amend`, `-C`/`-c`/`--reuse-message`/`--reedit-message`, `--fixup`/`--squash`, `-F `, and any commit taken while a merge/rebase/cherry-pick/revert is in progress. Resolves `bash -lc` wrappers and git aliases (inline `-c` and persisted config alike). | | **block-convention-violation** | PreToolUse · Bash | **Blocks** (exit 2) | A commit subject or `gh pr create --title` that violates the team-tracked convention pattern declared in `.claude/source-control.md`. No tracked pattern means no enforcement. Same exemptions as `block-noncanonical-commit`. | -| **flag-commit-pr-skill-bypass** | PreToolUse · Bash | **Advisory** (exit 0) | Any `gh pr create`, bypassing this marketplace's own `/pull-request create` skill. Only fires when the consuming project's own `.claude/settings.json` enables the `source-control` plugin — silent otherwise. Surfaces via `additionalContext`, never blocks. | +| **flag-commit-pr-skill-bypass** | PreToolUse · Bash | **Advisory** (exit 0) | Any `gh pr create`, bypassing this marketplace's own `/pull-request create` skill. Only fires when the consuming project's own `.claude/settings.json` enables the `source-control` plugin — silent otherwise. Surfaces via `additionalContext`, never blocks. **Opt-in — default off since 0.20.0** (behavioral-class injector config-disabled per #2021; set `flag_commit_pr_skill_bypass_enabled=true` to enable). | | **skill-reference-verify** | PostToolUse · Write \| Edit | **Advisory** (exit 0) | A `` `/plugin:skill` `` reference in markdown that does not resolve. Only fires inside a marketplace repo, and only for a plugin that repo's own manifests own — a reference to another marketplace is left alone. Resolves through manifest and frontmatter `name`, so a renamed directory still matches. Surfaces via `additionalContext`, never blocks. | | **stale-path-verify** | PostToolUse · Write \| Edit | **Advisory** (exit 0) | A repo-relative path cited in a markdown inline code span that this repo's own history shows was **deleted** and that is gone from the working tree. The gate is provenance, not absence: the exact path must appear in `git log HEAD --no-renames --diff-filter=D --name-only`, so a path belonging to a consuming project's tree, an example, or a plan is never adjudicated. Names the surviving file when exactly one tracked path now carries that basename. Link destinations are out of scope. Surfaces via `additionalContext`, never blocks. | @@ -107,9 +107,12 @@ out of scope until such a signal exists. ## Per-hook kill switches -Each guard is toggled by its own `userConfig` boolean (default **on**; set to -`false` for a clean no-op). This per-hook control is the bundle's core -contract — disable one guard without touching the others. +Each guard is toggled by its own `userConfig` boolean (default **on**, except +the two behavioral-class advisories `workflow-resilience-check` and +`flag-commit-pr-skill-bypass`, default **off** since 0.20.0 per #2021 — set to +`true` to opt in; set any switch to `false` for a clean no-op). This per-hook +control is the bundle's core contract — disable one guard without touching the +others. | Guard | Option | | ----- | ------ | diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index 157d75e6b6..63452a4ee4 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # PreToolUse hook: block Bash workarounds that bypass Write/Edit hook gates. -# Triggered on Bash tool calls. +# Triggered on Bash and PowerShell tool calls. # # Catches common file-write bypass patterns: # cat > path diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.sh b/plugins/guardrails/hooks/block-noncanonical-commit.sh index 5507c80c9f..b0e4f785f2 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.sh @@ -1,12 +1,23 @@ #!/usr/bin/env bash -# PreToolUse hook: block `git commit` that does not feed its message via stdin. -# Triggered on Bash tool calls. +# PreToolUse hook: block `git commit -m` whose message actually contains a +# newline. Triggered on Bash and PowerShell tool calls. # -# WHAT IT ENFORCES — the mechanic, not the ritual: -# `git commit -F -` (or `--file -`), the form the /commit skill emits. The -# failure mode it prevents is real and silent: `git commit -m ""` -# flattens newlines unpredictably across shells, so a body that looked right -# in the tool call lands mangled in history. +# WHAT IT ENFORCES — the hazard, not the ritual: +# The failure mode it prevents is real and silent: `git commit -m +# ""` flattens newlines unpredictably across shells, so a body +# that looked right in the tool call lands mangled in history. A multi-line +# message belongs on stdin — `git commit -F -` (or `--file -`), the form the +# /commit skill emits. +# +# NARROWED in 0.20.0 (#2021): the guard used to deny EVERY `git commit` that +# was not the stdin form, single-line `-m "fix: typo"` included — a hybrid +# whose extra width policed only style. Only the actual-newline `-m` is the +# mangling hazard, so only it blocks now: a single-line `-m`, a bare `git +# commit` (editor), and repeated single-line `-m` flags (git itself joins +# them as paragraphs; no shell mangling is involved) all pass. On the +# PowerShell tool a here-string `-m` value blocks too: the classifier blanks +# the body to a placeholder, so its content — multi-line by construction of +# the form — cannot be inspected, and the guard fails closed on it. # # WHY NOT `--trailer`: the trailer is POLICY, not mechanic. /commit itself # omits it when the resolved trailer_policy is `none`, and a repo whose @@ -20,8 +31,8 @@ # actually available, and is the better target anyway: it enforces the outcome # a reviewer can verify in `git log`, not the ceremony that produced it. # -# NOT BLOCKED (no message-on-stdin form exists for these, and gating them would -# break conflict resolution and history rewriting): +# EXEMPT even when a multi-line `-m` co-occurs (gating these would break +# conflict resolution and history rewriting): # --amend reusing an existing message # (--no-edit alone is NOT exempt: `git commit --no-edit -m x` is an # ordinary commit. Amending is covered by --amend; a merge's --no-edit is @@ -29,15 +40,16 @@ # -C / --reuse-message " # -c / --reedit-message " # --fixup / --squash message derived from another commit -# -F / --file mechanic satisfied, just not via stdin +# -F - / --file - the stdin form itself +# -F / --file the message rides in a file, not on argv # -m during an in-progress sequencer (merge / rebase / cherry-pick / revert): # conflict resolution and rebase continuation # must never be gated # # Per-repo/per-user allow-list: the `block_noncanonical_commit_allow` userConfig # option is a comma-separated list of form tokens (currently just -# "message-flag", which allows a bare `-m`). Set it with -# `/plugin configure guardrails` or headless via `claude plugin install +# "message-flag", which allows `-m` even with a newline in the message). Set it +# with `/plugin configure guardrails` or headless via `claude plugin install # --config`; read from the CLAUDE_PLUGIN_OPTION_BLOCK_NONCANONICAL_COMMIT_ALLOW # process mirror. Kill switch: `block_noncanonical_commit_enabled` set to false. # @@ -51,9 +63,15 @@ # this code in place of the expansion, and separating them needs a hook-utils # change that block-dangerous-git shares. Static matching over the literal # command string only: shell variable / command substitution is not evaluated. +# A message ATTACHED to a short-option cluster (`-am"multiline"`) is not +# recognized either: which cluster letters take values is per-option knowledge +# the scan does not model, so that spelling fails open (allowed) — same +# friction-guard posture as the substitution residual. The separated cluster +# (`-am ""`) IS scanned. # This is a friction guard against the accidental anti-pattern, not a sandbox. # -# BLOCKING: exits 2 when a commit would take its message off the command line. +# BLOCKING: exits 2 when a `git commit -m` message actually carries a newline +# (or, on PowerShell, a blanked here-string whose content cannot be inspected). set -uo pipefail @@ -530,6 +548,10 @@ alias_reexpand_admit() { check_segment() { local -a w=() local gi sub sub_idx nseg k word next stdin_form=0 exempt=0 saw_commit=0 + # Set when a `-m`/`--message` value carries an actual newline — the mangling + # hazard this guard blocks — or is a blanked PowerShell here-string (content + # multi-line by construction of the form, uninspectable here: fail closed). + local msg_newline=0 local inline_alias_handled=0 # This segment's effective repo directory, resolved at most once per frame and # only where a path is actually needed — a segment carrying no alias and no @@ -759,21 +781,48 @@ check_segment() { -C* | -c*) exempt=1 ;; + -m | --message) + [[ "$next" == *$'\n'* || "$next" == "$PS_HERESTRING_PLACEHOLDER" ]] && msg_newline=1 + ((k++)) + ;; + --message=*) + word="${word#--message=}" + [[ "$word" == *$'\n'* || "$word" == "$PS_HERESTRING_PLACEHOLDER" ]] && msg_newline=1 + ;; + -m*) + # Attached value (`-m"multiline"` tokenizes to one -m-prefixed word). + word="${word#-m}" + [[ "$word" == *$'\n'* || "$word" == "$PS_HERESTRING_PLACEHOLDER" ]] && msg_newline=1 + ;; + -[!-]*m) + # Short-option cluster whose LAST letter is m (`-am`, `-sam`): git binds + # the NEXT word as the message. Which earlier cluster letters themselves + # take values is per-option knowledge this scan does not model; misreading + # one costs at most a newline probe of the following word. + [[ "$next" == *$'\n'* || "$next" == "$PS_HERESTRING_PLACEHOLDER" ]] && msg_newline=1 + ((k++)) + ;; *) - # Any other word (paths, -a, -S, --cleanup, the -m payload) is not a - # message-source marker and needs no handling here. + # Any other word (paths, -a, -S, --cleanup) is not a message-source + # marker and needs no handling here. ;; esac done ((saw_commit)) || return 0 ((stdin_form || exempt)) && return 0 + # The narrowed verdict (#2021): only a `-m` whose message ACTUALLY carries a + # newline is the mangling hazard. A single-line `-m`, a bare `git commit`, + # and repeated single-line `-m` paragraphs (git joins them itself) all pass. + ((msg_newline)) || return 0 allowed "message-flag" && return 0 [[ -n "$seg_dir" ]] || seg_dir="$(effective_dir ${wrapper_cd[@]+"${wrapper_cd[@]}"} "${w[@]:gi:sub_idx-gi}")" sequencer_in_progress "$seg_dir" "$(explicit_git_dir "${w[@]:gi:sub_idx-gi}")" && return 0 - echo "BLOCKED: \`git commit\` without \`-F -\` — the message must be piped via stdin." >&2 - echo "Use the /commit skill (source-control plugin), or its canonical form directly:" >&2 + echo "BLOCKED: \`git commit -m\` with a multi-line message — a \`-m\` newline flattens" >&2 + echo "unpredictably across shells, so the body lands mangled in history." >&2 + echo "Pipe the message via stdin instead: use the /commit skill (source-control" >&2 + echo "plugin), or its canonical form directly:" >&2 if [[ "$TOOL_NAME" == "PowerShell" ]]; then echo " @'" >&2 echo " " >&2 @@ -783,8 +832,8 @@ check_segment() { echo " " >&2 echo " EOF" >&2 fi - echo "A \`-m\` message flattens newlines unpredictably across shells. --amend, -C/-c," >&2 - echo "--fixup/--squash, -F , and an in-progress merge/rebase are exempt." >&2 + echo "Single-line \`-m\` messages are allowed. --amend, -C/-c, --fixup/--squash," >&2 + echo "-F , and an in-progress merge/rebase are exempt." >&2 emit_tel "blocked" "message-flag" exit 2 } diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh index 23d6087bc3..114bce62ad 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh @@ -31,12 +31,27 @@ run() { } # --- the anti-pattern this guard exists for ----------------------------------- -run "git commit -m (blocked)" "git commit -m 'feat: x'" 2 -run "git commit -am (bundled, blocked)" "git commit -am 'feat: x'" 2 -run "git commit -m with --trailer (still blocked — trailer is not the mechanic)" \ - "git commit -m 'feat: x' --trailer 'Co-Authored-By: X '" 2 -run "bare git commit (opens EDITOR, blocked)" "git commit" 2 -run "git commit -a (no message source, blocked)" "git commit -a" 2 +# NARROWED (0.20.0, #2021): only a `-m` whose message ACTUALLY carries a newline +# blocks — that is the cross-shell mangling hazard. Single-line `-m`, bare +# `git commit`, and repeated single-line `-m` paragraphs all pass. `NL` embeds a +# real newline where the fixture's own quoting cannot use the $'…' spelling. +NL=$'\n' +run "git commit -m multi-line (blocked)" "git commit -m 'feat: x${NL}body'" 2 +run "git commit -m multi-line via \$'…' (blocked — tokenizer decodes ANSI-C)" \ + "git commit -m \$'feat: x\nbody'" 2 +run "git commit -m single-line (allowed — no newline, no mangling hazard)" \ + "git commit -m 'feat: x'" 0 +run "git commit -am multi-line (bundled cluster, blocked)" "git commit -am 'feat: x${NL}body'" 2 +run "git commit -am single-line (allowed)" "git commit -am 'feat: x'" 0 +run "git commit -m multi-line (blocked)" "git commit -m'feat: x${NL}body'" 2 +run "git commit --message= multi-line (blocked)" \ + "git commit --message='feat: x${NL}body'" 2 +run "git commit -m multi-line with --trailer (still blocked — trailer is not the mechanic)" \ + "git commit -m 'feat: x${NL}body' --trailer 'Co-Authored-By: X '" 2 +run "repeated single-line -m (allowed — git itself joins the paragraphs)" \ + "git commit -m 'feat: x' -m 'body'" 0 +run "bare git commit (no -m, allowed — opens EDITOR, no mangling hazard)" "git commit" 0 +run "git commit -a (no message source, allowed)" "git commit -a" 0 # --- the canonical form ------------------------------------------------------- run "git commit -F - (allowed)" "git commit -F - --cleanup=verbatim" 0 @@ -52,10 +67,13 @@ run "git commit -F - without --trailer (allowed — trailer_policy none)" \ # --- exempt operations (no message-on-stdin form exists) ---------------------- run "git commit --amend --no-edit (allowed)" "git commit --amend --no-edit" 0 # --no-edit is NOT an exemption on its own: git accepts it for an ordinary -# commit, so exempting it unconditionally would let `--no-edit -m` straight past. -run "git commit --no-edit -m (blocked — --no-edit is not an amend)" \ - "git commit --no-edit -m subject" 2 +# commit, so exempting it unconditionally would let a multi-line `--no-edit -m` +# straight past. +run "git commit --no-edit -m multi-line (blocked — --no-edit is not an amend)" \ + "git commit --no-edit -m 'subject${NL}body'" 2 run "git commit --amend (allowed)" "git commit --amend" 0 +run "git commit --amend -m multi-line (allowed — amending is exempt)" \ + "git commit --amend -m 'subject${NL}body'" 0 run "git commit -C HEAD (allowed)" "git commit -C HEAD" 0 run "git commit --reuse-message=HEAD (allowed)" "git commit --reuse-message=HEAD" 0 run "git commit --fixup HEAD (allowed)" "git commit --fixup HEAD" 0 @@ -65,20 +83,25 @@ run "git commit --file=msg.txt (path, allowed)" "git commit --file=msg.txt" 0 # --- top-level git options must not be confused with commit options ---------- # `-c` BEFORE the subcommand is config; only after `commit` is it --reedit. -run "git -c user.name=x commit -m (config, still blocked)" \ - "git -c user.name=x commit -m 'feat: x'" 2 +run "git -c user.name=x commit -m multi-line (config, still blocked)" \ + "git -c user.name=x commit -m 'feat: x${NL}body'" 2 run "git -c user.name=x commit -F - (config, allowed)" \ "git -c user.name=x commit -F -" 0 # --- inline git aliases are expanded before the subcommand verdict ----------- # `git -c alias.c=commit c -m x` commits. Without expansion the subcommand reads -# as `c`, not `commit`, and the guard waves the whole thing through. -run "inline git alias to commit -m (blocked)" \ - "git -c alias.c=commit c -m bypass" 2 -run "inline git alias carrying -m in the expansion (blocked)" \ - "git -c alias.ci='commit -m x' ci" 2 -run "inline shell alias (leading !) to commit -m (blocked)" \ - "git -c alias.sh='!git commit -m x' sh" 2 +# as `c`, not `commit`, and the guard waves the whole thing through. Blocked +# terminals carry a REAL newline in the `-m` message (the narrowed hazard); +# expansions embed it inside double quotes, since the alias splitter does not +# decode \$'…'. +run "inline git alias to a multi-line commit -m (blocked)" \ + "git -c alias.c=commit c -m 'bypass${NL}body'" 2 +run "inline git alias carrying a multi-line -m in the expansion (blocked)" \ + "git -c alias.ci='commit -m \"x${NL}y\"' ci" 2 +run "inline shell alias (leading !) to a multi-line commit -m (blocked)" \ + "git -c alias.sh='!git commit -m \"x${NL}y\"' sh" 2 +run "inline git alias to a single-line commit -m (allowed)" \ + "git -c alias.c=commit c -m bypass" 0 run "inline git alias to a canonical commit (allowed)" \ "git -c alias.c=commit c -F -" 0 run "inline alias to an unrelated subcommand (allowed)" \ @@ -86,23 +109,23 @@ run "inline alias to an unrelated subcommand (allowed)" \ # git applies the LAST -c value for a key; taking the first would let a decoy # earlier value expanding to a harmless subcommand mask the real one. run "last inline alias value wins (blocked)" \ - "git -c alias.c=status -c alias.c=commit c -m x" 2 + "git -c alias.c=status -c alias.c=commit c -m 'x${NL}y'" 2 run "last inline alias value wins (allowed when the last is harmless)" \ - "git -c alias.c=commit -c alias.c=status c -m x" 0 + "git -c alias.c=commit -c alias.c=status c -m 'x${NL}y'" 0 # --- #964: git chains aliases — re-expansion recurses to the commit ----------- # git expands an alias whose first word is itself an alias, so a non-canonical # commit reached through a SECOND hop must still block. Command-line globals ride # into each hop (so a second-hop --config-env alias is refused by shape), and the # recursion stops on git's own alias-loop. -run "#964 case C: two-hop inline chain to commit -m (blocked)" \ - "git -c alias.c=x -c alias.x='commit -m bypass' c" 2 +run "#964 case C: two-hop inline chain to a multi-line commit -m (blocked)" \ + "git -c alias.c=x -c alias.x='commit -m \"bypass${NL}b\"' c" 2 run "#964 H1: inline first hop, --config-env second hop (blocked by shape)" \ "git -c alias.c=x --config-env=alias.x=AV c" 2 "AV=commit" -run "#964 three-hop inline chain to commit -m (blocked)" \ - "git -c alias.a=b -c alias.b=c -c alias.c='commit -m bypass' a" 2 -run "#964 .command-spelled second hop to commit -m (blocked)" \ - "git -c alias.c=x -c alias.x.command='commit -m bypass' c" 2 +run "#964 three-hop inline chain to a multi-line commit -m (blocked)" \ + "git -c alias.a=b -c alias.b=c -c alias.c='commit -m \"bypass${NL}b\"' a" 2 +run "#964 .command-spelled second hop to a multi-line commit -m (blocked)" \ + "git -c alias.c=x -c alias.x.command='commit -m \"bypass${NL}b\"' c" 2 # Benign controls — a two-hop chain to the canonical -F - form still ALLOWS, and # an alias cycle terminates (git's alias-loop stop) and allows without hanging. run "#964 benign two-hop chain to canonical commit -F - (allowed)" \ @@ -113,7 +136,7 @@ run "#964 alias cycle terminates and allows (no hang)" \ # empty, so a body that re-invokes a name from the outer chain is re-expanded # there — the reparse must not inherit the outer chain's seen-set. run "#964 shell-alias body re-invoking the outer chain name (blocked)" \ - "git -c alias.a='!git -c alias.a=\"commit --allow-empty -m bypass\" a' a" 2 + "git -c alias.a='!git -c alias.a=\"commit --allow-empty -m \\\"bypass${NL}b\\\"\" a' a" 2 run "#964 shell-alias re-invocation, canonical -F - twin (allowed)" \ "git -c alias.a='!git -c alias.a=\"commit -F -\" a' a" 0 @@ -170,8 +193,8 @@ run_bounded "traversal: 20-hop dual-spelling chain to a canonical commit (allowe # Coverage is not what the collapse trades away: the same depth still reaches the # non-canonical commit and blocks. (This one always returned fast — the walk exits # on the first path that finds the commit — so it asserts reach, not runtime.) -run_bounded "traversal: 20-hop dual-spelling chain to commit -m (blocked, bounded)" \ - "$(alias_chain 20 'commit -m bypass')" 2 30 +run_bounded "traversal: 20-hop dual-spelling chain to a multi-line commit -m (blocked, bounded)" \ + "$(alias_chain 20 "commit -m \"bypass${NL}b\"")" 2 30 # A long chain that does NOT branch (one spelling per hop) must stay allowed — # the budget bounds branching, not depth. Ceiling-guarded too: a regression that # made this one branch would otherwise hang the suite rather than fail it. @@ -229,24 +252,24 @@ run "injection-shaped config-env env name (allowed — never evaluated)" \ assert_file_absent "config-env injection: no exec for a shell-metachar env name" "$TEST_TMPDIR/pwned-nc" # --- case-insensitive alias resolution (git folds config names) -------------- -run "inline alias, uppercase subcommand (blocked)" "git -c alias.c=commit C -m x" 2 -run "inline alias, uppercase alias key (blocked)" "git -c alias.C=commit c -m x" 2 +run "inline alias, uppercase subcommand (blocked)" "git -c alias.c=commit C -m 'x${NL}y'" 2 +run "inline alias, uppercase alias key (blocked)" "git -c alias.C=commit c -m 'x${NL}y'" 2 run "inline alias, uppercase both, to canonical form (allowed)" "git -c alias.C=commit C -F -" 0 # --- `alias..command` subkey is an alias definition too ------------------ # git reads the `alias..command` subkey as the alias (`git -c alias.c.command=commit # c -m x` commits non-canonically); the guard classifies that spelling inline and by shape. -run "inline .command-subkey alias to commit -m (blocked)" "git -c alias.c.command=commit c -m bypass" 2 +run "inline .command-subkey alias to a multi-line commit -m (blocked)" "git -c alias.c.command=commit c -m 'bypass${NL}b'" 2 run "config-env .command-subkey alias for the invoked sub (blocked by shape)" "git --config-env=alias.c.command=AV c" 2 -run ".command-subkey alias, case-folded key (blocked)" "git -c alias.C.command=commit c -m x" 2 +run ".command-subkey alias, case-folded key (blocked)" "git -c alias.C.command=commit c -m 'x${NL}y'" 2 # A non-`command` alias subkey is not an alias to git, so it must not be blocked. -run "non-command alias subkey is not an alias (allowed)" "git -c alias.c.nope=commit c -m bypass" 0 +run "non-command alias subkey is not an alias (allowed)" "git -c alias.c.nope=commit c -m 'bypass${NL}b'" 0 # MAX-DANGER UNION: which spelling git runs when both are set is version-dependent, so a # benign value in one spelling must never mask a commit alias in the other — the guard # blocks if EITHER spelling commits non-canonically, and allows only when BOTH are benign. -run "commit plain masked by a benign .command (blocked by union)" "git -c alias.c=commit -c alias.c.command=status c -m x" 2 -run "commit .command masked by a benign plain (blocked by union)" "git -c alias.c=status -c alias.c.command=commit c -m x" 2 -run "commit plain, benign .command decoy first (blocked by union)" "git -c alias.c.command=status -c alias.c=commit c -m x" 2 +run "commit plain masked by a benign .command (blocked by union)" "git -c alias.c=commit -c alias.c.command=status c -m 'x${NL}y'" 2 +run "commit .command masked by a benign plain (blocked by union)" "git -c alias.c=status -c alias.c.command=commit c -m 'x${NL}y'" 2 +run "commit plain, benign .command decoy first (blocked by union)" "git -c alias.c.command=status -c alias.c=commit c -m 'x${NL}y'" 2 run "both spellings benign non-commit (allowed)" "git -c alias.c=status -c alias.c.command=log c" 0 # Union on the --config-env shape path: an env spelling refuses even when the sibling # inline spelling is benign (both command-line orders). @@ -256,7 +279,7 @@ run "env .command spelling refuses despite a benign inline plain (blocked)" "git # --- other subcommands are untouched ----------------------------------------- run "git log (allowed)" "git log --oneline -5" 0 run "git push (allowed)" "git push origin main" 0 -run "non-git command (allowed)" "echo git commit -m nope" 0 +run "non-git command (allowed)" "echo git commit -m 'no${NL}pe'" 0 # --- prose containing the anti-pattern is not a false positive --------------- run "quoted mention in a heredoc body (allowed)" \ @@ -265,18 +288,18 @@ docs: explain why git commit -m 'x' is wrong EOF" 0 # --- shell -c wrappers are re-parsed ----------------------------------------- -run "bash -lc wrapped -m (blocked)" "bash -lc \"git commit -m 'feat: x'\"" 2 +run "bash -lc wrapped multi-line -m (blocked)" "bash -lc \"git commit -m 'feat: x${NL}body'\"" 2 run "bash -lc wrapped -F - (allowed)" "bash -lc 'git commit -F -'" 0 # --- control operators: a later segment is still checked --------------------- -run "second segment carries -m (blocked)" "git add -A && git commit -m 'x'" 2 +run "second segment carries a multi-line -m (blocked)" "git add -A && git commit -m 'x${NL}y'" 2 # --- kill switch and allow-list ---------------------------------------------- -run "kill switch disables the guard" "git commit -m 'feat: x'" 0 \ +run "kill switch disables the guard" "git commit -m 'feat: x${NL}body'" 0 \ CLAUDE_PLUGIN_OPTION_BLOCK_NONCANONICAL_COMMIT_ENABLED=false -run "allow-list message-flag permits -m" "git commit -m 'feat: x'" 0 \ +run "allow-list message-flag permits a multi-line -m" "git commit -m 'feat: x${NL}body'" 0 \ CLAUDE_PLUGIN_OPTION_BLOCK_NONCANONICAL_COMMIT_ALLOW=message-flag -run "unrelated allow token does not permit -m" "git commit -m 'feat: x'" 2 \ +run "unrelated allow token does not permit a multi-line -m" "git commit -m 'feat: x${NL}body'" 2 \ CLAUDE_PLUGIN_OPTION_BLOCK_NONCANONICAL_COMMIT_ALLOW=something-else # --- in-progress sequencer is exempt ----------------------------------------- @@ -298,14 +321,14 @@ GITDIR="$SEQ/.git" if [[ -d "$GITDIR" ]]; then : >"$GITDIR/MERGE_HEAD" rc=0 - MSYS_NO_PATHCONV=1 jq -n --arg c "git commit -m 'merge fix'" --arg d "$SEQ" \ + MSYS_NO_PATHCONV=1 jq -n --arg c "git commit -m 'merge${NL}fix'" --arg d "$SEQ" \ '{tool_name:"Bash",tool_input:{command:$c},cwd:$d}' | bash "$HOOK" >/dev/null 2>&1 rc=$? - assert_exit "in-progress merge is exempt" 0 "$rc" + assert_exit "in-progress merge is exempt (even with a multi-line -m)" 0 "$rc" rm -f "$GITDIR/MERGE_HEAD" - MSYS_NO_PATHCONV=1 jq -n --arg c "git commit -m 'not a merge'" --arg d "$SEQ" \ + MSYS_NO_PATHCONV=1 jq -n --arg c "git commit -m 'not a${NL}merge'" --arg d "$SEQ" \ '{tool_name:"Bash",tool_input:{command:$c},cwd:$d}' | bash "$HOOK" >/dev/null 2>&1 rc=$? @@ -320,13 +343,13 @@ fi # state, not the session cwd's. if [[ -d "$GITDIR" ]]; then : >"$GITDIR/MERGE_HEAD" - MSYS_NO_PATHCONV=1 jq -n --arg c "git -C $SEQ commit -m 'merge fix'" \ + MSYS_NO_PATHCONV=1 jq -n --arg c "git -C $SEQ commit -m 'merge${NL}fix'" \ '{tool_name:"Bash",tool_input:{command:$c},cwd:"/"}' | bash "$HOOK" >/dev/null 2>&1 assert_exit "git -C honors the target repo's in-progress merge" 0 $? rm -f "$GITDIR/MERGE_HEAD" - MSYS_NO_PATHCONV=1 jq -n --arg c "git -C $SEQ commit -m 'not a merge'" \ + MSYS_NO_PATHCONV=1 jq -n --arg c "git -C $SEQ commit -m 'not a${NL}merge'" \ '{tool_name:"Bash",tool_input:{command:$c},cwd:"/"}' | bash "$HOOK" >/dev/null 2>&1 assert_exit "git -C with no sequencer state in the target repo is blocked" 2 $? @@ -335,13 +358,13 @@ fi # --- explicit --git-dir names the repo whose sequencer state matters --------- if [[ -d "$GITDIR" ]]; then : >"$GITDIR/MERGE_HEAD" - MSYS_NO_PATHCONV=1 jq -n --arg c "git --git-dir=$GITDIR --work-tree=$SEQ commit -m x" \ + MSYS_NO_PATHCONV=1 jq -n --arg c "git --git-dir=$GITDIR --work-tree=$SEQ commit -m 'x${NL}y'" \ '{tool_name:"Bash",tool_input:{command:$c},cwd:"/"}' | bash "$HOOK" >/dev/null 2>&1 assert_exit "--git-dir honors that repo's in-progress merge" 0 $? rm -f "$GITDIR/MERGE_HEAD" - MSYS_NO_PATHCONV=1 jq -n --arg c "git --git-dir=$GITDIR --work-tree=$SEQ commit -m x" \ + MSYS_NO_PATHCONV=1 jq -n --arg c "git --git-dir=$GITDIR --work-tree=$SEQ commit -m 'x${NL}y'" \ '{tool_name:"Bash",tool_input:{command:$c},cwd:"/"}' | bash "$HOOK" >/dev/null 2>&1 assert_exit "--git-dir with no sequencer state is blocked" 2 $? @@ -361,7 +384,9 @@ mkdir -p "$PCFG" ) >/dev/null 2>&1 if [[ -d "$PCFG/.git" ]]; then - for spec in "git c -m bypass:2" "git c -F -:0"; do + # The $'…' spelling keeps each spec single-line; the hook's tokenizer decodes + # it to a real newline in the -m message (the narrowed blocking hazard). + for spec in "git c -m \$'bypass\nb':2" "git c -F -:0"; do cmd="${spec%:*}" want="${spec##*:}" MSYS_NO_PATHCONV=1 jq -n --arg c "$cmd" --arg d "$PCFG" \ @@ -386,7 +411,7 @@ mkdir -p "$PCHAIN" ) >/dev/null 2>&1 if [[ -d "$PCHAIN/.git" ]]; then - for spec in "git c -m bypass:2" "git c -F -:0"; do + for spec in "git c -m \$'bypass\nb':2" "git c -F -:0"; do cmd="${spec%:*}" want="${spec##*:}" MSYS_NO_PATHCONV=1 jq -n --arg c "$cmd" --arg d "$PCHAIN" \ @@ -402,7 +427,7 @@ if [[ -d "$PCHAIN/.git" ]]; then mixed="git" for ((i = 1; i < 12; i++)); do mixed+=" -c alias.a$i=a$((i + 1)) -c alias.a$i.command=a$((i + 1))"; done mixed+=" -c alias.a12=c -c alias.a12.command=c a1" - for spec in "-m bypass:2" "-F -:0"; do + for spec in "-m \$'bypass\nb':2" "-F -:0"; do args="${spec%:*}" want="${spec##*:}" MSYS_NO_PATHCONV=1 jq -n --arg c "$mixed $args" --arg d "$PCHAIN" \ @@ -434,7 +459,7 @@ mkdir -p "$PSHELL" ) >/dev/null 2>&1 if [[ -d "$PSHELL/.git" ]]; then - for spec in "git sc -m bypass:2" "git sc -F -:0" "git a:0" "git ma:0"; do + for spec in "git sc -m \$'bypass\nb':2" "git sc -F -:0" "git a:0" "git ma:0"; do cmd="${spec%:*}" want="${spec##*:}" MSYS_NO_PATHCONV=1 jq -n --arg c "$cmd" --arg d "$PSHELL" \ @@ -445,9 +470,10 @@ if [[ -d "$PSHELL/.git" ]]; then fi # --- the block message names the fix ----------------------------------------- -out=$(bash "$HOOK" <<<"$(command_json "git commit -m 'feat: x'")" 2>&1) +out=$(bash "$HOOK" <<<"$(command_json "git commit -m 'feat: x${NL}body'")" 2>&1) assert_contains "block message names -F -" "$out" '-F -' assert_contains "block message names the skill" "$out" '/commit' +assert_contains "block message names the multi-line hazard" "$out" 'multi-line' # --- persisted `!` hops across NESTED repositories ---------------------------- # One persisted alias text can mean a different hop in each repository it appears @@ -474,7 +500,7 @@ if [[ -d "$NESTED/a/.git" && -d "$NESTED/a/child/child/.git" ]]; then # Descent to a NON-canonical commit must block; the canonical twin must not. git -C "$NESTED/a" config alias.a '!git -C child a' git -C "$NESTED/a/child" config alias.a '!git -C child a' - git -C "$NESTED/a/child/child" config alias.a 'commit --allow-empty -m bypass' + git -C "$NESTED/a/child/child" config alias.a $'commit --allow-empty -m "bypass\nb"' git -C "$NESTED/b" config alias.a '!git -C child a' git -C "$NESTED/b/child" config alias.a '!git -C child a' git -C "$NESTED/b/child/child" config alias.a 'commit -F -' @@ -484,13 +510,13 @@ if [[ -d "$NESTED/a/.git" && -d "$NESTED/a/child/child/.git" ]]; then # normalization this minted a fresh key per hop and ran for 34s. git -C "$NESTED/dot" config alias.selfdot '!git -C . selfdot' git -C "$NESTED/dot" config alias.viadot '!git -C . realcommit' - git -C "$NESTED/dot" config alias.realcommit 'commit --allow-empty -m bypass' + git -C "$NESTED/dot" config alias.realcommit $'commit --allow-empty -m "bypass\nb"' # The INLINE `!` site composes the directory too, and a `!` body is reparsed as # its own command — the outer `-c` globals do not ride along — so the hop it # descends into resolves against PERSISTED config in the child. That crosses # inline -> persisted at a directory boundary, which neither branch's own cases # reach on their own. - git -C "$NESTED/a/child" config alias.z2 'commit --allow-empty -m bypass' + git -C "$NESTED/a/child" config alias.z2 $'commit --allow-empty -m "bypass\nb"' git -C "$NESTED/b/child" config alias.z2 'commit -F -' for spec in \ @@ -531,7 +557,7 @@ if [[ -d "$WRAP/outer/child/.git" && -d "$WRAP/outer/git/child/.git" ]]; then # The repository git ACTUALLY reaches carries the non-canonical commit; the # decoy a wrongly-sliced parser would reach carries the canonical one, so a # bypass shows up as a wrongly-allowed exit 0. - git -C "$WRAP/outer/child" config alias.p 'commit --allow-empty -m bypass' + git -C "$WRAP/outer/child" config alias.p $'commit --allow-empty -m "bypass\nb"' git -C "$WRAP/outer/git/child" config alias.p 'commit -F -' # `env -u -C git`: -u consumes `-C` as the variable name, so `git` is the @@ -546,7 +572,7 @@ if [[ -d "$WRAP/outer/child/.git" && -d "$WRAP/outer/git/child/.git" ]]; then # git's OWN -C must keep working — the fix narrows the slice, it does not # stop honouring a relocation git really performs. - git -C "$WRAP/outer/git/child" config alias.q 'commit --allow-empty -m bypass' + git -C "$WRAP/outer/git/child" config alias.q $'commit --allow-empty -m "bypass\nb"' MSYS_NO_PATHCONV=1 jq -n --arg c "git -C git -c alias.a='!git -C child q' a" --arg d "$WRAP/outer" \ '{tool_name:"Bash",tool_input:{command:$c},cwd:$d}' | timeout 30 bash "$HOOK" >/dev/null 2>&1 @@ -578,12 +604,12 @@ if [[ -d "$WRAP/outer/other/.git" ]]; then # `other` (where git lands) is non-canonical under `a` and canonical under `k`; # the payload cwd `outer` is the reverse. So a dropped chdir shows up as exit 0 # on `a`, and an over-applied one as exit 2 on `k`. - git -C "$WRAP/outer/other" config alias.a 'commit --allow-empty -m bypass' + git -C "$WRAP/outer/other" config alias.a $'commit --allow-empty -m "bypass\nb"' git -C "$WRAP/outer/other" config alias.k 'commit -F -' git -C "$WRAP/outer" config alias.a 'commit -F -' - git -C "$WRAP/outer" config alias.k 'commit --allow-empty -m bypass' + git -C "$WRAP/outer" config alias.k $'commit --allow-empty -m "bypass\nb"' [[ -d "$WRAP/outer/has space/.git" ]] && - git -C "$WRAP/outer/has space" config alias.a 'commit --allow-empty -m bypass' + git -C "$WRAP/outer/has space" config alias.a $'commit --allow-empty -m "bypass\nb"' wrapper_cd_case "env -C moves git, so the alias lookup follows" \ "env -C other git a" 2 @@ -656,7 +682,7 @@ fi # at all — and reading no alias there allows. The canonical twin pins the arrival # point: exit 0 is only reachable from `other/child` itself. if [[ -d "$WRAP/outer/other/child/.git" ]]; then - git -C "$WRAP/outer/other/child" config alias.a 'commit --allow-empty -m bypass' + git -C "$WRAP/outer/other/child" config alias.a $'commit --allow-empty -m "bypass\nb"' git -C "$WRAP/outer/other/child" config alias.c 'commit -F -' wrapper_cd_case "env -C composes ahead of git's own -C" \ @@ -682,7 +708,7 @@ fi if [[ -d "$WRAP/outer/other/child/child/.git" ]]; then git -C "$WRAP/outer/other" config alias.p '!git -C child p' git -C "$WRAP/outer/other/child" config alias.p '!git -C child p' - git -C "$WRAP/outer/other/child/child" config alias.p 'commit --allow-empty -m bypass' + git -C "$WRAP/outer/other/child/child" config alias.p $'commit --allow-empty -m "bypass\nb"' git -C "$WRAP/outer/other" config alias.s '!git -C child s' git -C "$WRAP/outer/other/child" config alias.s '!git -C child s' git -C "$WRAP/outer/other/child/child" config alias.s 'commit -F -' @@ -714,7 +740,7 @@ mkdir -p "$TOPLEVEL/canon/sub" if [[ -d "$TOPLEVEL/outer/child/.git" ]]; then git -C "$TOPLEVEL/outer" config alias.a '!git -C child a' - git -C "$TOPLEVEL/outer/child" config alias.a 'commit --allow-empty -m bypass' + git -C "$TOPLEVEL/outer/child" config alias.a $'commit --allow-empty -m "bypass\nb"' git -C "$TOPLEVEL/canon" config alias.a '!git -C child a' git -C "$TOPLEVEL/canon/child" config alias.a 'commit -F -' for spec in \ @@ -747,7 +773,7 @@ if [[ -L "$SYMDIR/base/link" ]]; then sym_top=$(git -C "$SYMDIR/base/link/.." rev-parse --show-toplevel 2>/dev/null | tr -d '\r') fi if [[ -n "$sym_top" ]] && [[ "$(cd -P "$SYMDIR/target" && pwd -P)" == "$(cd -P "$sym_top" && pwd -P)" ]]; then - git -C "$SYMDIR/target" config alias.a 'commit --allow-empty -m bypass' + git -C "$SYMDIR/target" config alias.a $'commit --allow-empty -m "bypass\nb"' git -C "$SYMDIR/target" config alias.c 'commit -F -' for spec in "git -C link/.. a|2|symlinked parent: guard follows git into the link target" \ "git -C link/.. c|0|symlinked parent: canonical commit there is allowed"; do @@ -780,9 +806,9 @@ mkdir -p "$DOTS/sub" if [[ -d "$DOTS/.git" ]]; then git -C "$DOTS" config alias.selfdeep '!git -C ./././. selfdeep' git -C "$DOTS" config alias.livedot '!git -C . realdot' - git -C "$DOTS" config alias.realdot 'commit --allow-empty -m bypass' + git -C "$DOTS" config alias.realdot $'commit --allow-empty -m "bypass\nb"' git -C "$DOTS" config alias.up '!git -C sub/.. realup' - git -C "$DOTS" config alias.realup 'commit --allow-empty -m bypass' + git -C "$DOTS" config alias.realup $'commit --allow-empty -m "bypass\nb"' git -C "$DOTS" config alias.upcanon '!git -C sub/.. canonup' git -C "$DOTS" config alias.canonup 'commit -F -' for spec in \ @@ -811,7 +837,7 @@ nested_repo "$TRAIL/cur" nested_repo "$TRAIL/safe" if [[ -d "$TRAIL/cur/.git" && -d "$TRAIL/safe/.git" ]]; then - git -C "$TRAIL/cur" config alias.b 'commit --allow-empty -m bypass' + git -C "$TRAIL/cur" config alias.b $'commit --allow-empty -m "bypass\nb"' # A trailing `-C` also lands in the commit-argument scan, where `-C` is # `--reuse-message` and exempts — so the prefix slice has no independently # observable effect on this guard's verdicts today, and there is deliberately no @@ -842,12 +868,15 @@ mkdir -p "$GLOB/outside" nested_repo "$GLOB/repo" if [[ -d "$GLOB/repo/.git" ]]; then + # The alias body is embedded DOUBLE-quoted in the command so the blocked twin + # can spell its newline as \$'x\ny' (the tokenizer decodes it in the ! body + # reparse; a single-quoted embedding would end at the \$' quote instead). for spec in \ "!git commit -F -|0|--git-dir/--work-tree: canonical commit through a ! alias is allowed" \ - "!git commit -m x|2|--git-dir/--work-tree: -m commit through a ! alias still blocks"; do + "!git commit -m \$'x\ny'|2|--git-dir/--work-tree: multi-line -m through a ! alias still blocks"; do IFS='|' read -r body want label <<<"$spec" MSYS_NO_PATHCONV=1 jq -n \ - --arg c "git --git-dir=$GLOB/repo/.git --work-tree=$GLOB/repo -c alias.a='$body' a" \ + --arg c "git --git-dir=$GLOB/repo/.git --work-tree=$GLOB/repo -c alias.a=\"$body\" a" \ --arg d "$GLOB/outside" \ '{tool_name:"Bash",tool_input:{command:$c},cwd:$d}' | timeout 30 bash "$HOOK" >/dev/null 2>&1 @@ -881,8 +910,8 @@ launch_tree() { # git -C "$1/work/child" config alias.p "$3" git -C "$1/work/sub/child" config alias.p "$4" } -launch_tree "$LAUNCH/a" 'commit --allow-empty -m bypass' 'commit -F -' 'commit --allow-empty -m bypass' -launch_tree "$LAUNCH/b" 'commit -F -' 'commit --allow-empty -m bypass' 'commit -F -' +launch_tree "$LAUNCH/a" $'commit --allow-empty -m "bypass\nb"' 'commit -F -' $'commit --allow-empty -m "bypass\nb"' +launch_tree "$LAUNCH/b" 'commit -F -' $'commit --allow-empty -m "bypass\nb"' 'commit -F -' if [[ -d "$LAUNCH/a/out/child/.git" && -d "$LAUNCH/b/out/child/.git" ]]; then launch_body='!unset GIT_DIR GIT_WORK_TREE; git -C child p' @@ -905,9 +934,11 @@ fi # --- PowerShell tool coverage ------------------------------------------------ # The canonical PowerShell commit form (a here-string piped to `git commit -F -`) -# must be allowed exactly as the Bash `-F -` form is; a `-m` PowerShell commit -# must be blocked; commit-shaped PowerShell the classifier cannot parse is -# DEFERRED here (#1858) and blocked by `block-dangerous-git`, asserted below. +# must be allowed exactly as the Bash `-F -` form is; a here-string `-m` value +# must be blocked (uninspectable, multi-line by construction of the form) while +# a single-line literal `-m` passes; commit-shaped PowerShell the classifier +# cannot parse is DEFERRED here (#1858) and blocked by `block-dangerous-git`, +# asserted below. run_pwsh() { local label="$1" command="$2" expected="$3" rc bash "$HOOK" <<<"$(pwsh_command_json "$command")" >/dev/null 2>&1 @@ -916,9 +947,12 @@ run_pwsh() { } run_pwsh "PS: canonical here-string | git commit -F - (allowed)" \ "$(printf '%s\n%s\n%s' "@'" "feat: x" "'@ | git commit -F -")" 0 -run_pwsh "PS: git commit -m here-string (blocked — not the stdin form)" \ +# A here-string `-m` VALUE blanks to a placeholder whose content the guard +# cannot inspect — multi-line by construction of the form — so it fails closed. +run_pwsh "PS: git commit -m here-string (blocked — uninspectable, multi-line by form)" \ "$(printf '%s\n%s\n%s' "git commit -m @'" "feat: x" "'@")" 2 -run_pwsh "PS: git commit -m literal (blocked)" "git commit -m 'feat: x'" 2 +run_pwsh "PS: git commit -m single-line literal (allowed — no newline)" \ + "git commit -m 'feat: x'" 0 run_pwsh "PS: git commit --amend (allowed — exempt)" "git commit --amend" 0 run_pwsh "PS: git status (allowed — not a commit)" "git status" 0 @@ -962,7 +996,8 @@ for command in "$PS_UNPARSABLE_BACKTICK" "$PS_UNPARSABLE_HERESTRING"; do done # The PowerShell block message shows the here-string form, not a Bash heredoc. -psout=$(bash "$HOOK" <<<"$(pwsh_command_json "git commit -m 'x'")" 2>&1) +# (The blocking fixture is the -m here-string form — single-line -m no longer blocks.) +psout=$(bash "$HOOK" <<<"$(pwsh_command_json "$(printf '%s\n%s\n%s' "git commit -m @'" "feat: x" "'@")")" 2>&1) assert_contains "PS block message shows the here-string form" "$psout" "'@ | git commit -F -" assert_absent "PS block message omits the Bash heredoc" "$psout" "<<'EOF'" diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh index 4145fb02cf..cb79d2a4ba 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh @@ -2,7 +2,7 @@ # PreToolUse hook: advisory guard for direct `gh pr create` calls that bypass # this marketplace's own canonical `/pull-request create` skill # (source-control plugin). -# Triggered on Bash tool calls. +# Triggered on Bash and PowerShell tool calls. # # SCOPE — only fires when the source-control plugin is actually enabled for this # session. Claude Code merges `enabledPlugins` across scopes, so enablement is @@ -44,14 +44,21 @@ # substitution, and a determined author can construct a form that evades the # match. This is a nudge toward the canonical skills, not an enforcement gate. # -# Kill switch: flag_commit_pr_skill_bypass_enabled userConfig option +# Opt-in switch: flag_commit_pr_skill_bypass_enabled userConfig option. +# DEFAULT OFF since 0.20.0: issue #2021's hook-surface classification found this +# is a behavioral-class context injector — a fixed prose nudge that consults no +# external ground truth — and PLUGIN-PHILOSOPHY.md's instruction-economy +# evidence gate ablates that class config-off first (the script stays; a +# consumer opts back in by setting the option to true). set -uo pipefail # shellcheck source=hook-utils.sh source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" -hook::check_enabled "FLAG_COMMIT_PR_SKILL_BYPASS" +# Explicit opt-in (NOT hook::check_enabled, whose unset-var fallback is "true"): +# an unset switch must read as the plugin.json default, which is false. +[[ "${CLAUDE_PLUGIN_OPTION_FLAG_COMMIT_PR_SKILL_BYPASS_ENABLED:-false}" == "true" ]] || exit 0 # Bundled PowerShell-command classifier — this guard is matched on both the Bash # and the (opt-in) PowerShell tool. Resolved under the plugin root (CC sets diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh index 21f7479070..0c7269d97c 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.test.sh @@ -18,6 +18,11 @@ trap 'rm -rf "$TEST_TMPDIR"' EXIT # shellcheck source=guardrails-test-helpers.sh source "$HOOK_DIR/guardrails-test-helpers.sh" +# The advisory is OPT-IN since 0.20.0 (default off — behavioral-class injector, +# config-disabled per #2021). Every behavior case below runs with the switch on; +# the default-off posture has its own case at the end. +export CLAUDE_PLUGIN_OPTION_FLAG_COMMIT_PR_SKILL_BYPASS_ENABLED=true + # make_project [settings.local.json-value] -> project dir # Writes a fixture consuming project with .claude/settings.json (and an # optional settings.local.json override) so the hook resolves source-control @@ -175,6 +180,14 @@ out=$(run_hook "$(command_json 'gh pr create --title x --body y')" "$ENABLED_PRO CLAUDE_PLUGIN_OPTION_FLAG_COMMIT_PR_SKILL_BYPASS_ENABLED=false) assert_silent "kill switch off → no-op despite bypass shape" "$out" +# --- default posture is OFF (0.20.0) — an UNSET switch is a clean no-op ------ +# Not via run_hook: env stops option parsing at the first NAME=value operand, so +# a trailing `-u` there would be read as the command, not an option. +out=$(env -u CLAUDE_CONFIG_DIR -u CLAUDE_PLUGIN_OPTION_FLAG_COMMIT_PR_SKILL_BYPASS_ENABLED \ + CLAUDE_PROJECT_DIR="$ENABLED_PROJECT" HOME="$HERMETIC_HOME" \ + bash "$HOOK" <<<"$(command_json 'gh pr create --title x --body y')" 2>&1) +assert_silent "unset switch → no-op (advisory is opt-in by default)" "$out" + # --- empty stdin → silent (graceful no-op) ----------------------------------- out=$(env CLAUDE_PROJECT_DIR="$ENABLED_PROJECT" bash "$HOOK" <<<"" 2>&1) assert_silent "empty stdin is a no-op" "$out" diff --git a/plugins/guardrails/hooks/hooks.json b/plugins/guardrails/hooks/hooks.json index 399e51403b..d86549e362 100644 --- a/plugins/guardrails/hooks/hooks.json +++ b/plugins/guardrails/hooks/hooks.json @@ -44,12 +44,7 @@ "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/flag-commit-pr-skill-bypass.sh", "timeout": 60, "statusMessage": "Checking commit/PR skill usage..." - } - ] - }, - { - "matcher": "Bash|PowerShell", - "hooks": [ + }, { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/block-noncanonical-commit.sh", diff --git a/plugins/guardrails/hooks/workflow-resilience-check.sh b/plugins/guardrails/hooks/workflow-resilience-check.sh index 65ed6a7cd8..a64c3784e0 100755 --- a/plugins/guardrails/hooks/workflow-resilience-check.sh +++ b/plugins/guardrails/hooks/workflow-resilience-check.sh @@ -9,14 +9,21 @@ # fan-out trips server-side 529 (a 27-item Opus pipeline lost 22/27 agents this # way). Closes the inline-authoring gap that no file glob can reach. # -# Kill switch: workflow_resilience_check_enabled userConfig option +# Opt-in switch: workflow_resilience_check_enabled userConfig option. +# DEFAULT OFF since 0.20.0: issue #2021's hook-surface classification found this +# is a behavioral-class context injector — two greps and a fixed checklist that +# asserts nothing the model cannot derive — and PLUGIN-PHILOSOPHY.md's +# instruction-economy evidence gate ablates that class config-off first (the +# script stays; a consumer opts back in by setting the option to true). set -uo pipefail # shellcheck source=hook-utils.sh source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" -hook::check_enabled "WORKFLOW_RESILIENCE_CHECK" +# Explicit opt-in (NOT hook::check_enabled, whose unset-var fallback is "true"): +# an unset switch must read as the plugin.json default, which is false. +[[ "${CLAUDE_PLUGIN_OPTION_WORKFLOW_RESILIENCE_CHECK_ENABLED:-false}" == "true" ]] || exit 0 # High-res start stamp for the telemetry envelope. EPOCHREALTIME is Bash 5.0+; # on older bash it is unset, so default to empty and skip telemetry. diff --git a/plugins/guardrails/hooks/workflow-resilience-check.test.sh b/plugins/guardrails/hooks/workflow-resilience-check.test.sh index cac4ec931d..58c1144825 100755 --- a/plugins/guardrails/hooks/workflow-resilience-check.test.sh +++ b/plugins/guardrails/hooks/workflow-resilience-check.test.sh @@ -16,6 +16,11 @@ trap 'rm -rf "$TEST_TMPDIR"' EXIT # shellcheck source=guardrails-test-helpers.sh source "$HOOK_DIR/guardrails-test-helpers.sh" +# The advisory is OPT-IN since 0.20.0 (default off — behavioral-class injector, +# config-disabled per #2021). Every behavior case below runs with the switch on; +# the default-off posture has its own case (CASE 7b). +export CLAUDE_PLUGIN_OPTION_WORKFLOW_RESILIENCE_CHECK_ENABLED=true + # PreToolUse Workflow payload builders. workflow_script_json() { jq -nc --arg s "$1" '{tool_name:"Workflow",tool_input:{script:$s}}'; } workflow_path_json() { jq -nc --arg p "$1" '{tool_name:"Workflow",tool_input:{scriptPath:$p}}'; } @@ -55,6 +60,11 @@ assert_silent "no fan-out stays silent" "$out" out=$(run_hook "$(workflow_script_json 'await pipeline(FILES, s1)')" CLAUDE_PLUGIN_OPTION_WORKFLOW_RESILIENCE_CHECK_ENABLED=false) assert_silent "kill switch silences hook" "$out" +# CASE 7b: default posture is OFF (0.20.0) — an UNSET switch is a clean no-op. +out=$(env -u CLAUDE_PLUGIN_OPTION_WORKFLOW_RESILIENCE_CHECK_ENABLED \ + bash "$HOOK" <<<"$(workflow_script_json 'await pipeline(FILES, s1)')" 2>&1) +assert_silent "unset switch → no-op (advisory is opt-in by default)" "$out" + # CASE 8: saved scriptPath with un-throttled fan-out → advisory fires (reads file). SAVED="$TEST_TMPDIR/engine.js" printf 'export const meta = {}\nawait pipeline(FILES, s1, s2)\n' >"$SAVED" From 3996770d57e8ce0697d076aa38dbe678ae6fd232 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:29:28 -0400 Subject: [PATCH 2/9] fix(guardrails): reword two hook headers to satisfy the comment-hygiene tracker-ref rule "issue #2021" in a code comment matches the org comment-hygiene issue-reference pattern; the bare "#2021" spelling (already used elsewhere in these hooks) does not. Comment-only change. Co-Authored-By: Claude Fable 5 --- plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh | 2 +- plugins/guardrails/hooks/workflow-resilience-check.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh index cb79d2a4ba..83258a98ec 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh @@ -45,7 +45,7 @@ # match. This is a nudge toward the canonical skills, not an enforcement gate. # # Opt-in switch: flag_commit_pr_skill_bypass_enabled userConfig option. -# DEFAULT OFF since 0.20.0: issue #2021's hook-surface classification found this +# DEFAULT OFF since 0.20.0: the #2021 hook-surface classification found this # is a behavioral-class context injector — a fixed prose nudge that consults no # external ground truth — and PLUGIN-PHILOSOPHY.md's instruction-economy # evidence gate ablates that class config-off first (the script stays; a diff --git a/plugins/guardrails/hooks/workflow-resilience-check.sh b/plugins/guardrails/hooks/workflow-resilience-check.sh index a64c3784e0..f5f90d8b26 100755 --- a/plugins/guardrails/hooks/workflow-resilience-check.sh +++ b/plugins/guardrails/hooks/workflow-resilience-check.sh @@ -10,7 +10,7 @@ # way). Closes the inline-authoring gap that no file glob can reach. # # Opt-in switch: workflow_resilience_check_enabled userConfig option. -# DEFAULT OFF since 0.20.0: issue #2021's hook-surface classification found this +# DEFAULT OFF since 0.20.0: the #2021 hook-surface classification found this # is a behavioral-class context injector — two greps and a fixed checklist that # asserts nothing the model cannot derive — and PLUGIN-PHILOSOPHY.md's # instruction-economy evidence gate ablates that class config-off first (the From 8a9482f26b3c9c7e50712fb2be6b30d7d66c1796 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:52:58 -0400 Subject: [PATCH 3/9] chore(guardrails): empty commit to re-trigger dropped pull_request CI events The synchronize event for 3996770d and the reopen event both fired only the pull_request_target workflows; ci/pr-title never started. No file changes. Co-Authored-By: Claude Fable 5 From 1f69d1b40e5f208dffa79423ddd9c37186bda3e3 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:20:55 -0400 Subject: [PATCH 4/9] fix(guardrails): restore the blank line before the 0.19.4 changelog heading The merge resolution glued the 0.20.0 section's last bullet to the ## [0.19.4] heading (MD032/MD022). Co-Authored-By: Claude Fable 5 --- plugins/guardrails/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 6ed879cded..fdc9e7d163 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -71,6 +71,7 @@ All notable changes to the `guardrails` plugin are documented here. Format follo - **Three stale hook headers said "Triggered on Bash tool calls" while wired `Bash|PowerShell`:** `block-hook-bypass.sh`, `block-noncanonical-commit.sh`, and `flag-commit-pr-skill-bypass.sh` now say Bash and PowerShell (cosmetic; the wiring itself was already correct). + ## [0.19.4] ### Fixed From 1cca9dffab014eb8642f4b83e9d3de9bae121e71 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:09:33 -0400 Subject: [PATCH 5/9] fix(guardrails): gate abbreviated --message spellings and pin the separated form Review follow-ups on the -m narrowing: - git's parse-options accepts any unique long-option prefix, and --message is git commit's only m-initial long option, so --m through --messag all parse as --message (verified on git 2.55: `git commit --dry-run --mess=x` and each shorter prefix parse; --mainline errors). The scan now recognizes every abbreviation in both the =-attached and the separated form, so `git commit --mess="multiline"` no longer bypasses the newline gate. - New test cases pin the separated `--message ` form and the abbreviated attached/separated forms, multi-line (deny) and single-line (allow) each, plus the shortest `--m=` spelling. Suite: 188 passed, 0 failed. Co-Authored-By: Claude Fable 5 --- plugins/guardrails/CHANGELOG.md | 5 ++++- .../hooks/block-noncanonical-commit.sh | 12 +++++++++--- .../hooks/block-noncanonical-commit.test.sh | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index fdc9e7d163..274cda2345 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -38,7 +38,10 @@ All notable changes to the `guardrails` plugin are documented here. Format follo - a single-line `-m` passes; a `-m`/`--message` value carrying an actual newline blocks, in every spelling the argv scan sees — separated (`-m `), attached (`-m""`), `--message=`, - and a short-option cluster ending in `m` (`-am `); + every accepted unique abbreviation of `--message` (`--m` through `--messag`, separated or + `=`-attached — git's parse-options accepts any unique long-option prefix and `--message` is + git commit's only `m`-initial long option; verified on git 2.55), and a short-option cluster + ending in `m` (`-am `); - bare `git commit` / `git commit -a` (no message source; the old block) now pass — no `-m`, no mangling hazard; - repeated single-line `-m` flags pass: git itself joins them as paragraphs, no shell newline is diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.sh b/plugins/guardrails/hooks/block-noncanonical-commit.sh index b0e4f785f2..57609e8fb8 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.sh @@ -781,12 +781,18 @@ check_segment() { -C* | -c*) exempt=1 ;; - -m | --message) + -m | --m | --me | --mes | --mess | --messa | --messag | --message) + # git's parse-options accepts any UNIQUE prefix of a long option, and + # --message is git commit's only long option starting with "m", so every + # prefix --m..--messag is accepted as --message. Verified empirically on + # git 2.55: `git commit --dry-run --mess=x` (and each shorter prefix) + # parses, while a non-option like --mainline errors — so an abbreviated + # spelling must hit this gate exactly as the full one does. [[ "$next" == *$'\n'* || "$next" == "$PS_HERESTRING_PLACEHOLDER" ]] && msg_newline=1 ((k++)) ;; - --message=*) - word="${word#--message=}" + --m=* | --me=* | --mes=* | --mess=* | --messa=* | --messag=* | --message=*) + word="${word#*=}" [[ "$word" == *$'\n'* || "$word" == "$PS_HERESTRING_PLACEHOLDER" ]] && msg_newline=1 ;; -m*) diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh index 114bce62ad..17006220a3 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh @@ -46,6 +46,24 @@ run "git commit -am single-line (allowed)" "git commit -am 'feat: x'" 0 run "git commit -m multi-line (blocked)" "git commit -m'feat: x${NL}body'" 2 run "git commit --message= multi-line (blocked)" \ "git commit --message='feat: x${NL}body'" 2 +run "git commit --message multi-line (blocked)" \ + "git commit --message 'feat: x${NL}body'" 2 +run "git commit --message single-line (allowed)" \ + "git commit --message 'feat: x'" 0 +# git's parse-options accepts any UNIQUE prefix of a long option, and --message +# is git commit's only m-initial long option, so --m..--messag all parse as +# --message (verified on git 2.55) — the abbreviated spellings must hit the +# same gate in both the =-attached and the separated form. +run "git commit --mess= multi-line (blocked)" \ + "git commit --mess='feat: x${NL}body'" 2 +run "git commit --mess= single-line (allowed)" \ + "git commit --mess='feat: x'" 0 +run "git commit --mess multi-line (blocked)" \ + "git commit --mess 'feat: x${NL}body'" 2 +run "git commit --mess single-line (allowed)" \ + "git commit --mess 'feat: x'" 0 +run "git commit --m= multi-line (blocked)" \ + "git commit --m='feat: x${NL}body'" 2 run "git commit -m multi-line with --trailer (still blocked — trailer is not the mechanic)" \ "git commit -m 'feat: x${NL}body' --trailer 'Co-Authored-By: X '" 2 run "repeated single-line -m (allowed — git itself joins the paragraphs)" \ From 43ace1f141a95ef6065cf37a29d0f9c431d8e5d1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:20:08 -0400 Subject: [PATCH 6/9] fix(guardrails): silence the spell-checker on the abbreviated --message patterns The literal one-letter-short option prefix in the two case arms trips the typos gate ("should be message"). The case patterns keep the literal behind the config-blessed spellchecker:disable-line pragma; the three prose mentions are reworded to describe the prefix range instead of spelling it. No behavior change (spot-checked both directions; typos --config _typos.toml now clean on the plugin). Co-Authored-By: Claude Fable 5 --- plugins/guardrails/CHANGELOG.md | 8 ++++---- .../guardrails/hooks/block-noncanonical-commit.sh | 13 +++++++------ .../hooks/block-noncanonical-commit.test.sh | 7 ++++--- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 274cda2345..029075a536 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -38,10 +38,10 @@ All notable changes to the `guardrails` plugin are documented here. Format follo - a single-line `-m` passes; a `-m`/`--message` value carrying an actual newline blocks, in every spelling the argv scan sees — separated (`-m `), attached (`-m""`), `--message=`, - every accepted unique abbreviation of `--message` (`--m` through `--messag`, separated or - `=`-attached — git's parse-options accepts any unique long-option prefix and `--message` is - git commit's only `m`-initial long option; verified on git 2.55), and a short-option cluster - ending in `m` (`-am `); + every accepted unique abbreviation of `--message` (any prefix from `--m` up to one letter + short of the full spelling, separated or `=`-attached — git's parse-options accepts any + unique long-option prefix and `--message` is git commit's only `m`-initial long option; + verified on git 2.55), and a short-option cluster ending in `m` (`-am `); - bare `git commit` / `git commit -a` (no message source; the old block) now pass — no `-m`, no mangling hazard; - repeated single-line `-m` flags pass: git itself joins them as paragraphs, no shell newline is diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.sh b/plugins/guardrails/hooks/block-noncanonical-commit.sh index 57609e8fb8..df3e7b4e2b 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.sh @@ -781,17 +781,18 @@ check_segment() { -C* | -c*) exempt=1 ;; - -m | --m | --me | --mes | --mess | --messa | --messag | --message) + -m | --m | --me | --mes | --mess | --messa | --messag | --message) # spellchecker:disable-line # git's parse-options accepts any UNIQUE prefix of a long option, and # --message is git commit's only long option starting with "m", so every - # prefix --m..--messag is accepted as --message. Verified empirically on - # git 2.55: `git commit --dry-run --mess=x` (and each shorter prefix) - # parses, while a non-option like --mainline errors — so an abbreviated - # spelling must hit this gate exactly as the full one does. + # prefix from --m up to one letter short of the full spelling is accepted + # as --message. Verified empirically on git 2.55: `git commit --dry-run + # --mess=x` (and each shorter prefix) parses, while a non-option like + # --mainline errors — so an abbreviated spelling must hit this gate + # exactly as the full one does. [[ "$next" == *$'\n'* || "$next" == "$PS_HERESTRING_PLACEHOLDER" ]] && msg_newline=1 ((k++)) ;; - --m=* | --me=* | --mes=* | --mess=* | --messa=* | --messag=* | --message=*) + --m=* | --me=* | --mes=* | --mess=* | --messa=* | --messag=* | --message=*) # spellchecker:disable-line word="${word#*=}" [[ "$word" == *$'\n'* || "$word" == "$PS_HERESTRING_PLACEHOLDER" ]] && msg_newline=1 ;; diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh index 17006220a3..90440091ef 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh @@ -51,9 +51,10 @@ run "git commit --message multi-line (blocked)" \ run "git commit --message single-line (allowed)" \ "git commit --message 'feat: x'" 0 # git's parse-options accepts any UNIQUE prefix of a long option, and --message -# is git commit's only m-initial long option, so --m..--messag all parse as -# --message (verified on git 2.55) — the abbreviated spellings must hit the -# same gate in both the =-attached and the separated form. +# is git commit's only m-initial long option, so every prefix from --m up to +# one letter short of the full spelling parses as --message (verified on git +# 2.55) — the abbreviated spellings must hit the same gate in both the +# =-attached and the separated form. run "git commit --mess= multi-line (blocked)" \ "git commit --mess='feat: x${NL}body'" 2 run "git commit --mess= single-line (allowed)" \ From 3dd7edc2a2360fc9cf03ca23fc587d39fddd5194 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:17:36 -0400 Subject: [PATCH 7/9] test(guardrails): pin the separated form of the shortest --message abbreviation --m was the only abbreviation covered in one direction only; the separated form now has multi-line (deny) and single-line (allow) cases like every other spelling. Suite: 190 passed, 0 failed. Co-Authored-By: Claude Fable 5 --- plugins/guardrails/hooks/block-noncanonical-commit.test.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh index 90440091ef..ebce7f303b 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh @@ -65,6 +65,10 @@ run "git commit --mess single-line (allowed)" \ "git commit --mess 'feat: x'" 0 run "git commit --m= multi-line (blocked)" \ "git commit --m='feat: x${NL}body'" 2 +run "git commit --m multi-line (blocked)" \ + "git commit --m 'feat: x${NL}body'" 2 +run "git commit --m single-line (allowed)" \ + "git commit --m 'feat: x'" 0 run "git commit -m multi-line with --trailer (still blocked — trailer is not the mechanic)" \ "git commit -m 'feat: x${NL}body' --trailer 'Co-Authored-By: X '" 2 run "repeated single-line -m (allowed — git itself joins the paragraphs)" \ From f34aff30d0c809592870340f3e87dd1088e032db Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:58:21 -0400 Subject: [PATCH 8/9] test(guardrails): add the --m= single-line control case The attached shortest abbreviation was pinned only in the blocked direction; it now has the allow control like every sibling pair. Suite: 191 passed, 0 failed. Co-Authored-By: Claude Fable 5 --- plugins/guardrails/hooks/block-noncanonical-commit.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh index ebce7f303b..cca083455e 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.test.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.test.sh @@ -65,6 +65,8 @@ run "git commit --mess single-line (allowed)" \ "git commit --mess 'feat: x'" 0 run "git commit --m= multi-line (blocked)" \ "git commit --m='feat: x${NL}body'" 2 +run "git commit --m= single-line (allowed)" \ + "git commit --m='feat: x'" 0 run "git commit --m multi-line (blocked)" \ "git commit --m 'feat: x${NL}body'" 2 run "git commit --m single-line (allowed)" \ From c2176a8d2f0e9caae81f96fd56e328c8847967d3 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:57:44 -0400 Subject: [PATCH 9/9] fix: restore the plugin.json payload the merge resolution reverted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The origin/main merge resolved plugins/guardrails/.claude-plugin/plugin.json by taking main's copy and re-applying only the version bump — silently reverting this PR's payload: the two injector defaults (back to true) and the three description updates. Restored from the pre-merge commit; caught by fresh-context verification of the PR tree against the changelog. Co-Authored-By: Claude Fable 5 --- docs/CATALOG.md | 2 +- plugins/guardrails/.claude-plugin/plugin.json | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/CATALOG.md b/docs/CATALOG.md index c3fe459050..3d60dcffe7 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -86,7 +86,7 @@ plugin manifests and kept in sync by CI — never hand-edit it; the category voc ## Security -- [`guardrails`](../plugins/guardrails) — Twelve safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (advisory) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable. +- [`guardrails`](../plugins/guardrails) — Twelve safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, multi-line `git commit -m` messages (an actual-newline `-m` mangles across shells; single-line `-m` passes), commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (advisory, opt-in) un-throttled Workflow fan-out that risks burst 529s, and (advisory, opt-in) direct gh pr create calls bypassing this marketplace's own pull-request skill — each independently toggleable. ## Workflow diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 7a1f0c451e..0cd7138307 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "guardrails", "version": "0.20.0", - "description": "Twelve safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (advisory) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable.", + "description": "Twelve safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, multi-line `git commit -m` messages (an actual-newline `-m` mangles across shells; single-line `-m` passes), commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (advisory, opt-in) un-throttled Workflow fan-out that risks burst 529s, and (advisory, opt-in) direct gh pr create calls bypassing this marketplace's own pull-request skill — each independently toggleable.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" @@ -53,7 +53,7 @@ "block_noncanonical_commit_enabled": { "type": "boolean", "title": "block-noncanonical-commit guard", - "description": "Block `git commit` that does not pipe its message via `-F -`; --amend, -C/-c, --fixup/--squash, -F , and an in-progress merge/rebase are exempt", + "description": "Block `git commit -m` when the message actually contains a newline (multi-line `-m` mangles across shells — pipe it via `-F -` instead; single-line `-m` passes); --amend, -C/-c, --fixup/--squash, -F , and an in-progress merge/rebase are exempt", "default": true }, "block_convention_gate_enabled": { @@ -83,14 +83,14 @@ "workflow_resilience_check_enabled": { "type": "boolean", "title": "workflow-resilience-check guard", - "description": "Advise on un-throttled Workflow fan-out (never blocks)", - "default": true + "description": "Advise on un-throttled Workflow fan-out (never blocks). Default off since 0.20.0: a behavioral-class prose injector, config-disabled per the instruction-economy evidence gate (#2021) — set true to opt back in", + "default": false }, "flag_commit_pr_skill_bypass_enabled": { "type": "boolean", "title": "flag-commit-pr-skill-bypass guard", - "description": "Advise when direct git commit / gh pr create bypasses the source-control skills (never blocks)", - "default": true + "description": "Advise when a direct gh pr create bypasses the source-control pull-request skill (never blocks). Default off since 0.20.0: a behavioral-class prose injector, config-disabled per the instruction-economy evidence gate (#2021) — set true to opt back in", + "default": false }, "cli_flag_verify_bins": { "type": "string", @@ -113,7 +113,7 @@ "block_noncanonical_commit_allow": { "type": "string", "title": "block-noncanonical-commit allow-list", - "description": "Comma-separated form tokens to allow (currently: message-flag, which permits a bare `-m`)", + "description": "Comma-separated form tokens to allow (currently: message-flag, which permits `-m` even when the message contains a newline)", "default": "" }, "block_no_verify_hook_manager_prefixes": {