Skip to content

feat(guardrails): block non-canonical git commit; drop --trailer conjunct - #736

Merged
kyle-sexton merged 6 commits into
mainfrom
feat/731-block-noncanonical-commit
Jul 20, 2026
Merged

feat(guardrails): block non-canonical git commit; drop --trailer conjunct#736
kyle-sexton merged 6 commits into
mainfrom
feat/731-block-noncanonical-commit

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Closes #731

Summary

Turns the git commit half of flag-commit-pr-skill-bypass into a blocking guard, and fixes a latent false positive that would have made blocking impossible.

Fix

New block-noncanonical-commit.sh (blocking). Denies git commit that does not pipe its message via -F -/--file -. That form prevents a real, silent failure: git commit -m "<multi-line>" flattens newlines unpredictably across shells, so a body that looked right in the tool call lands mangled in history.

Exempt, because no message-on-stdin form exists for them and gating them would strand real work:

  • --amend / --no-edit, -C / -c / --reuse-message / --reedit-message, --fixup / --squash
  • -F <path> — mechanic satisfied, just not via stdin
  • any commit taken while a merge, rebase, cherry-pick, or revert is in progress

Kill switch block_noncanonical_commit_enabled; allow-list block_noncanonical_commit_allow (message-flag permits a bare -m), following the block_dangerous_git_allow idiom. Detection reuses the shared argv-grammar parser, so bash -lc wrappers and git aliases resolve, and a commit body mentioning git commit -m never fires.

Dropped the --trailer conjunct — this was a latent bug. The old condition required -F - and --trailer:

if ! [[ "$COMMAND_LC" =~ (-f|--file)[[:space:]]+- ]] ||
   ! [[ "$COMMAND_LC" =~ --trailer ]]; then

But /commit omits --trailer when the resolved trailer_policy is none (plugins/source-control/skills/commit/SKILL.md:58,66-70,106). So a repo whose convention forbids a co-author trailer saw the skill's own conformant output flagged on every commit.

This had to be settled before blocking on the same condition — requiring --trailer to pass would have permanently blocked /commit in that configuration. The trailer is policy; only the stdin form is mechanic, and only the mechanic belongs in a gate.

flag-commit-pr-skill-bypass is now gh pr create-only, so the two never double-fire on one command.

Why gh pr create stays advisory

Not a preference — it cannot be otherwise. /pull-request create issues that exact command itself, and anthropics/claude-code#22655 (expose skill_name to hook payloads) is closed as not planned. A hook cannot distinguish a skill-driven Bash call from an ad hoc one, so blocking gh pr create would deadlock the skill it is advertising.

Likewise, no hook can force skill invocation — hooks block tools, they do not direct the model. Gating on command shape is what is actually available, and is the better target anyway: it enforces an outcome verifiable in git log rather than the ceremony that produced it.

Verification

New hook — 34/34, covering both directions plus the config surface:

$ bash plugins/guardrails/hooks/block-noncanonical-commit.test.sh
ok: git commit -m (blocked) (exit 2)
ok: git commit -m with --trailer (still blocked — trailer is not the mechanic) (exit 2)
ok: bare git commit (opens EDITOR, blocked) (exit 2)
ok: git commit -F - without --trailer (allowed — trailer_policy none) (exit 0)
ok: git commit --amend --no-edit (allowed) (exit 0)
ok: git -c user.name=x commit -m (config, still blocked) (exit 2)
ok: quoted mention in a heredoc body (allowed) (exit 0)
ok: bash -lc wrapped -m (blocked) (exit 2)
ok: in-progress merge is exempt (exit 0)
ok: same repo with no sequencer state is blocked (exit 2)
...
passed: 34   failed: 0

The sequencer cases run against a real git init fixture with MERGE_HEAD present and then removed, so the exemption is proven to key on actual repo state rather than on the flag.

Advisory test rescoped to gh pr create — 17/17, including a case asserting git commit -m is now silent here, and one asserting -F - without --trailer is silent (the regression this PR fixes).

shellcheck on all changed scripts — clean. scripts/validate-plugin-contracts.mjs — 33 setup skills, 1825 plugin files checked, pass. Both new scripts committed 100755. Version 0.8.00.9.0 with a matching ## [0.9.0] heading.

This PR's own commit was made with git commit -F - --trailer ... --trailer ... and the advisory did not fire — which is also the direct evidence that the pre-existing advisory had been correct all along about the commits that did trip it.

Related

…unct

The advisory covering `git commit` was overridden 11 times in a single
session. An advisory that is always overridden trains the reader to
filter it out, so the commit half becomes a blocking guard.

It gates the MECHANIC, not the ritual. `git commit -m "<multi-line>"`
flattens newlines unpredictably across shells; `-F -` is what prevents
that. Gating on "did you invoke /commit" is not available anyway —
anthropics/claude-code#22655 (expose skill_name to hooks) is closed as
not planned — and shape is the better target regardless: it enforces an
outcome a reviewer can verify in git log.

Exempt, because no message-on-stdin form exists for them: --amend,
-C/-c/--reuse-message/--reedit-message, --fixup/--squash, -F <path>, and
any commit taken mid-merge/rebase/cherry-pick/revert. Gating those would
strand a conflict resolution.

The old condition also required --trailer, which was a latent false
positive: /commit omits the trailer under trailer_policy `none`, so a
repo forbidding co-author trailers saw its own conformant commits
flagged every time. That had to be settled before blocking on the same
condition — requiring --trailer to pass would have permanently blocked
the skill in that configuration.

gh pr create stays advisory. /pull-request create issues that exact
command itself and no shape distinguishes them, so blocking it would
deadlock the skill.

Closes #731

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

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

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


Code Review — PR #736

  • Gather context and understand the request
  • Review block-noncanonical-commit.sh — logic, security, edge cases
  • Review block-noncanonical-commit.test.sh — coverage and correctness
  • Review flag-commit-pr-skill-bypass.sh — narrowing to gh pr create
  • Review flag-commit-pr-skill-bypass.test.sh — rescoped tests
  • Review plugin.json — version bump and hook registration
  • Review hooks.json — new hook wiring
  • Review CHANGELOG.md — accuracy and format

The overall design is sound. The separation of mechanic (stdin-form gate) from policy (trailer), the fail-closed posture on timeout, and the exemption logic for sequencer states are all well-reasoned. Four issues worth addressing before merge, ranging from a stale string to a minor coverage gap.


Issues

1. Stale userConfig description for flag_commit_pr_skill_bypass_enabled

plugin.json:74 still reads:

"description": "Advise when direct git commit / gh pr create bypasses the source-control skills (never blocks)"

git commit is no longer this hook's concern — it moved to block-noncanonical-commit. A user reading this to decide whether to toggle the kill switch will get the wrong picture.

Suggested fix: "Advise when direct \gh pr create` bypasses the /pull-request create skill (never blocks)"`. Fix this →


2. Duplicate test case inflates the count by one

block-noncanonical-commit.test.sh:44 is byte-for-byte identical to line 37:

# line 37
run "git commit -F - (allowed)" "git commit -F - --cleanup=verbatim" 0

# line 44
run "git commit -F - without --trailer (allowed — trailer_policy none)" \
  "git commit -F - --cleanup=verbatim" 0

Both exercise the same command. The trailer_policy none regression is worth documenting, but the test should use a distinct command — e.g., "git commit -F -" without --cleanup=verbatim — to actually prove the flag-absence doesn't matter. As written, the PR's "34 tests" count includes a non-distinct case. Fix this →


3. Only MERGE_HEAD is exercised in the sequencer fixture

block-noncanonical-commit.test.sh:108-125: The production code at block-noncanonical-commit.sh:111 checks five sequencer markers:

for f in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply

The test only creates and removes MERGE_HEAD, leaving CHERRY_PICK_HEAD, REVERT_HEAD, rebase-merge, and rebase-apply uncovered. If rebase-merge or rebase-apply is ever checked incorrectly (e.g., the -d "$dir/$f" vs -e distinction for the directories), a test wouldn't catch it. Low probability, but the PR makes a point of the sequencer fixtures being "proven against real git state" — the claim doesn't fully hold for the directory-based markers. Adding one more mkdir "$GITDIR/rebase-merge" / rmdir iteration would close this gap. Fix this →


4. Telemetry data schema diverges between the two hooks

block-noncanonical-commit.sh:86-87 emits form (singular string):

'{tool:"Bash",subject:$subject,form:$form}'

flag-commit-pr-skill-bypass.sh:96-97 emits forms (array):

'{tool:"Bash",subject:$subject,forms:$forms}'

The blocking hook only ever emits a single form value ("message-flag" or ""), so an array isn't strictly necessary, but any downstream consumer aggregating telemetry across hooks will need to handle two different shapes. Using forms (array, consistent with the advisory hook) or documenting the distinction is preferable to a silent schema fork. Not a blocker but worth a note.


Observations (no action needed)

block-noncanonical-commit.sh in its own hooks.json group. Having the blocking guard in a separate PreToolUse Bash entry (rather than adding it to the existing group) is defensively correct: it prevents a blocking exit from one group's sequential execution semantics from suppressing the advisory hook's additional context in the same group. The two hooks have orthogonal concerns so independent groups are the right call.

sequencer_in_progress fail-closed direction is correct. On git rev-parse failure (not a repo, no HOOK_CWD) the function returns 1 → "not in sequencer" → the commit is still subject to the block. This is the right call: an uncertain answer should not open the gate.

Bundled short-option form git commit -aF - is not handled. -aF won't match -F in the case statement, so the guard would (incorrectly) block it. The canonical form taught in the deny message (-F -) is always two tokens, so real-world impact is minimal, and the current comment accurately labels the * catch-all as "not a message-source marker." Worth a one-line note in the header if this gap is ever surprising.

--trailer conjunct removal rationale is solid. The original condition was a latent always-fire bug for trailer_policy: none repos. Dropping it before the blocking port was the correct prerequisite ordering.


Summary

One stale user-visible string in plugin.json (issue 1) should be fixed before merging. Issues 2 and 3 are test-quality improvements the author may want to make given the verification claims in the PR body. Issue 4 is a low-priority schema hygiene note. The core blocking logic, exemption coverage, and architectural split between the two hooks are correct.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 94ccfd18c7

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh
comment-hygiene flagged the bare upstream issue number as a repo-issue
tracker ref. The comment keeps the constraint it was explaining — the
hook payload has no originating-skill field — and the citation stays in
the CHANGELOG, where a reader can follow it.

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

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

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


Code Review — PR #736 (updated after cbf559b)

  • Inspect the new commit (cbf559b) — comment hygiene only; no issue fixes
  • Verify status of prior review issues (1–4)
  • Evaluate Codex finding on inline git alias expansion

The new commit drops the bare tracker reference from the header comment in block-noncanonical-commit.sh:18-19 and replaces it with a prose description of the constraint. That change is correct and clean.

None of the four issues from the previous review were addressed. Status below:


Open Issues

1. Stale userConfig description for flag_commit_pr_skill_bypass_enabled — NOT FIXED

plugin.json:74 still reads "Advise when direct git commit / gh pr create bypasses the source-control skills (never blocks)". git commit is no longer this hook's concern — it moved to block-noncanonical-commit. A user toggling this kill switch will get the wrong mental model.

Suggested: "Advise when direct \gh pr create` bypasses the /pull-request create skill (never blocks)"`. Fix this →


2. Duplicate test case inflates the count — NOT FIXED

block-noncanonical-commit.test.sh:37 and line 44 both run "git commit -F - --cleanup=verbatim". The trailer_policy none regression is worth documenting but needs a distinct command — e.g., just "git commit -F -" — to actually prove the flag-absence makes no difference. Fix this →


3. Sequencer fixture covers only MERGE_HEAD — NOT FIXED

The production guard at block-noncanonical-commit.sh:111 checks five markers (MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD, rebase-merge, rebase-apply). Tests at lines 108–125 only exercise MERGE_HEAD. Adding a mkdir "$GITDIR/rebase-merge" / rmdir cycle would cover the directory-type markers. Fix this →


4. Inline git alias bypass — new finding (from Codex review, confirmed) — NOT FIXED

block-dangerous-git.sh expands inline aliases by inspecting HOOK_GIT_CONFIG_VALUES after hook::git_resolve_subcommand returns (the alias.${sub}= check at lines 217–242 of that file). block-noncanonical-commit.sh does not: after calling hook::git_resolve_subcommand at line 134, it immediately checks [[ "$sub" == "commit" ]] || return 0 at line 137 without resolving inline aliases.

Concrete bypass: git -c alias.c=commit c -m 'bypass' — the resolved subcommand is c, the check [[ "c" == "commit" ]] fails, and check_segment exits 0 (allowed) even though git expands c to commit at runtime.

This is a narrow, contrived form in practice, and the header comment accurately notes "Static matching over the literal command string only," but the PR description's claim that "git aliases resolve" is overstated for this form. The same alias expansion logic from block-dangerous-git.sh should be ported here for consistency. This is a blocker if the PR's correctness claim is taken at face value.


5. Telemetry schema diverges — unchanged, low priority

block-noncanonical-commit.sh:86 emits form (scalar); flag-commit-pr-skill-bypass.sh:96 emits forms (array). Downstream consumers aggregating across both hooks need to handle two shapes. Not a blocker, but worth aligning.


Summary

Issue 1 (stale user-visible description in plugin.json) is the clearest merge blocker. Issue 4 (inline alias bypass — new, confirmed from the Codex review) is also a blocker if the "git aliases resolve" claim in the PR body is intended to hold for inline -c aliases. Issues 2 and 3 are test-quality improvements against the PR's own verification claims. The comment-hygiene fix in cbf559b is correct; the core blocking logic and architectural split remain sound.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cbf559b89d

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh Outdated
Comment thread plugins/guardrails/.claude-plugin/plugin.json
`git -c alias.c=commit c -m bypass` resolved to subcommand `c`, not
`commit`, so the guard returned early and allowed a noncanonical commit.
Same for `alias.ci='commit -m x'` and the shell-alias form. Reproduced
all three at exit 0 before the fix.

The header already claimed aliases were resolved, which made this worse
than a plain gap — the documented contract and the code disagreed.

Ports the expansion block from block-dangerous-git verbatim, including
the HOOK_NO_ALIAS one-level guard, and runs it BEFORE the `sub == commit`
check rather than after. A canonical commit reached through an alias
still passes.

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

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

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


Code Review — PR #736 (updated after eb214a4)

  • Gather context and understand the request
  • Inspect new commit (eb214a4) — inline alias expansion fix
  • Verify status of all prior review issues (1–5)
  • Review git -C sequencer finding (Codex P2)
  • Review README/docs stale count finding (Codex P2)
  • Report updated status

What Changed in eb214a4

The commit ports the alias expansion block from block-dangerous-git.sh verbatim and places it before the [[ "$sub" == "commit" ]] check at block-noncanonical-commit.sh:168. This correctly closes the bypass where git -c alias.c=commit c -m bypass resolved to subcommand c and passed through. Five new tests cover all three alias forms (git alias, alias carrying -m in expansion, shell ! alias) plus the allowed directions (alias to canonical commit, alias to unrelated subcommand). The fix is mechanically sound.


Issue Status

Issue 4 — Inline git alias bypass: FIXED

The expansion block runs before the commit verdict. HOOK_NO_ALIAS guards against recursive expansion (one level only). For shell aliases (!), hook::bash_parse_segments re-parses the expansion as a full shell command. For git aliases, the expanded words splice in place and recurse with HOOK_NO_ALIAS=1. break after the first matching alias is correct — git only expands one alias level. The CHANGELOG.md entry now accurately reflects this. This was the blocker; it's resolved.


Remaining Open Issues

1. Stale plugin.json description — merge blocker — NOT FIXED

plugin.json:74 still reads:

"description": "Advise when direct git commit / gh pr create bypasses the source-control skills (never blocks)"

git commit is no longer this hook's concern. A user toggling this kill switch gets the wrong mental model.

Suggested: "Advise when direct \gh pr create` bypasses the /pull-request create skill (never blocks)"`. Fix this →


2. README is stale in three places — merge blocker — NEW (confirmed from Codex P2)

README.md:3: "A Claude Code plugin bundling eight safety guards" — should be nine.

README.md:20: "The five blocking guards … The three advisory guards" — should be six blocking, three advisory.

README.md:18: The flag-commit-pr-skill-bypass row still describes git commit detection (missing the canonical -F - stdin form + --trailer Co-Authored-By line) alongside gh pr create. That half of the description no longer applies.

The guards table is missing a block-noncanonical-commit row entirely. The kill-switches table at lines 72–82 is missing block_noncanonical_commit_enabled. Consumers cannot discover the new blocker or its kill switch from the documented plugin surface.

Fix this →


3. Duplicate test case — test quality — NOT FIXED

block-noncanonical-commit.test.sh:37 and line 44 both run "git commit -F - --cleanup=verbatim". The trailer-absence regression is worth a dedicated test, but the command must differ. Change line 44's command to just "git commit -F -" (drop --cleanup=verbatim) so the two cases exercise different inputs. Fix this →


4. Sequencer fixture covers only MERGE_HEAD — test quality — NOT FIXED

The production guard at block-noncanonical-commit.sh:111 checks five markers:

for f in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply

rebase-merge and rebase-apply are directories, checked via [[ -e ]]. The tests at lines 121–135 only exercise MERGE_HEAD. A mkdir "$GITDIR/rebase-merge" / rmdir cycle would cover the directory markers and validate that [[ -e ]] works for both files and directories in this context. Fix this →


5. git -C sequencer gap — edge case — NEW (confirmed from Codex P2)

block-noncanonical-commit.sh:109:

dir=$(git -C "${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}" rev-parse --absolute-git-dir 2>/dev/null) || return 1

When the command includes a -C /path global option (git -C /other/repo commit -m msg), the sequencer lookup uses the payload's cwd field — Claude's process cwd — not /other/repo. If MERGE_HEAD lives in /other/repo/.git/, the exemption is missed and the merge continuation is blocked. The hook::git_resolve_index call does parse git global options but does not update HOOK_CWD with any -C target.

Real-world impact is limited (Claude uncommonly targets a different repo with -C during a mid-merge), but the sequencer exemption specifically exists to avoid stranding conflict resolutions, and this gap defeats it for the -C case. Not a blocker on its own, but worth tracking.


6. Telemetry schema diverges — low priority — NOT FIXED

block-noncanonical-commit.sh:86 emits form (scalar string); flag-commit-pr-skill-bypass.sh:96 emits forms (array). Downstream consumers aggregating across both hooks need to handle two shapes. Not a blocker.


Summary

The primary blocker from the previous review cycle (inline alias bypass, issue 4) is resolved in eb214a4. Two merge blockers remain: issue 1 (stale plugin.json user-visible description) and finding 2 (README stale guard count, stale row descriptions, missing kill-switch table entry). Issues 3 and 4 are test-quality improvements; issue 5 and the git -C gap are low-priority. The core blocking logic, exemption coverage, and architectural split between the two hooks remain sound.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb214a4617

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh Outdated
Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh Outdated
…h guard

--no-edit was exempt unconditionally, but git accepts it for an ordinary
commit — `git commit --no-edit -m subject` created exactly the
noncanonical commit the guard exists to stop. Amending is already covered
by --amend and a merge's --no-edit by the sequencer branch, so the
exemption is simply wrong and is removed.

Aliases persisted in .git/config or a global config were invisible: the
parser only captures inline `-c` values, so `git config alias.c commit`
followed by `git c -m x` passed. The hook now asks git to resolve the
alias when no inline one matched, letting git's own precedence apply.

Sequencer probing ignored `git -C <path>`, so a conflict resolution
driven at another repo read the session cwd's state instead of the
target's — exempting the wrong repo in one direction and gating a real
merge in the other.

README advertised eight guards, five blocking, and named the advisory as
the owner of direct `git commit`; all three are now stale.

All four reproduced before fixing and covered by tests (44 assertions,
including real git fixtures for the persisted alias and the -C target).

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

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

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


Code Review — PR #736 (updated after 5ca0266)

  • Gather context and understand the request
  • Inspect new commit (5ca0266) — "close three commit-guard bypasses; document the ninth guard"
  • Verify status of all prior review issues (1–6 + Codex P1/P2)
  • Report updated status

What Changed in 5ca0266

The commit closes four gaps: removes the --no-edit unconditional exemption, adds persisted-config alias lookup, adds effective_dir() to honour git -C in sequencer probes, and updates README. Each was reproduced before fixing. Four corresponding test cases were added (two real-git fixtures), bringing the total to 44 assertions. None of these changes touch plugin.json.


Issues Fixed in 5ca0266

Codex P2 — --no-edit without --amend: FIXED

--no-edit is gone from the case exempt branch. git commit --no-edit -m subject now hits the *) catch-all (no stdin_form or exempt set) and is blocked. git commit --amend --no-edit reaches --amend first (exempt=1) and is still allowed. Test at line 56–57 confirms both directions.

Persisted config alias bypass (Codex P1 follow-up): FIXED

When no inline alias matched, the hook now calls git -C "$(effective_dir ...)" config --get "alias.$sub" and expands the result the same way it does for inline aliases. Shell aliases (!) re-parse via hook::bash_parse_segments; git aliases splice in place. Real-git fixture at lines 169–188 (git repo with alias.c = commit) covers both the blocked and allowed directions.

Issue 5 — git -C sequencer gap: FIXED

effective_dir() at lines 108–123 scans the full argv for -C <path> global options (last wins; relative paths join onto the base). sequencer_in_progress now receives the correct repo root. Real-git fixture at lines 149–163 passes cwd="/" and a -C $SEQ command, confirms the target repo's state is read.

Issue 2 — README stale: FIXED

README now reads "nine safety guards", "six blocking guards", has a block-noncanonical-commit row, the flag-commit-pr-skill-bypass row is narrowed to gh pr create, and block_noncanonical_commit_enabled appears in the kill-switch table.


Remaining Open Issues

1. plugin.json top-level description says "Eight" — merge blocker — NOT FIXED

plugin.json:5 still reads:

"description": "Eight safety guards that block … (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable."

Two problems: "Eight" should be "Nine", and git commit is now blocking, not advisory. The top-level description is what the marketplace and /plugin install surface show. Fix this →


2. plugin.json:74 flag_commit_pr_skill_bypass_enabled description is stale — merge blocker — NOT FIXED (carried from every prior review cycle)

plugin.json:74 still reads:

"description": "Advise when direct git commit / gh pr create bypasses the source-control skills (never blocks)"

git commit is no longer this guard's concern — it moved to block-noncanonical-commit. A user toggling this kill switch via /plugin configure guardrails gets the wrong mental model.

Suggested: "Advise when direct \gh pr create` bypasses the /pull-request create skill (never blocks)"`. Fix this →


3. CHANGELOG [0.9.0] Added still lists --no-edit as exempt — documentation bug — NEW

CHANGELOG.md:14-15 says:

…gating them would strand real work: `--amend`/`--no-edit`, `-C`/`-c`/…

But 5ca0266 specifically removed --no-edit as a standalone exemption. A consumer reading the changelog will believe --no-edit is still exempt when it is not. The "Added" section wasn't updated when the bypass was closed. Change --amend/--no-edit to just --amend, and add a note that --no-edit alone is not exempt. Fix this →


4. Duplicate test case — test quality — NOT FIXED

block-noncanonical-commit.test.sh:42 and lines 49–50 both run "git commit -F - --cleanup=verbatim". The trailer_policy none regression is worth documenting but needs a distinct command to actually prove something different. Drop --cleanup=verbatim from line 49–50. Fix this →


5. Sequencer fixture covers only MERGE_HEAD — test quality — NOT FIXED

The guard at block-noncanonical-commit.sh:138 checks five markers (MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD, rebase-merge, rebase-apply). Only MERGE_HEAD is exercised in the fixture. A mkdir "$GITDIR/rebase-merge" / rmdir cycle would validate the directory-marker path ([[ -e ]] vs. -f). Fix this →


6. Telemetry schema diverges — low priority — NOT FIXED

block-noncanonical-commit.sh:91 emits form (scalar); flag-commit-pr-skill-bypass.sh:96 emits forms (array). Not a blocker.


Summary

5ca0266 resolves the three correctness bypasses (--no-edit, persisted aliases, git -C sequencer) and fixes the README. Two merge blockers remain: the plugin.json top-level description still says "Eight" and lists git commit as advisory (issue 1), and the flag_commit_pr_skill_bypass_enabled user-facing description still names git commit (issue 2) — neither plugin.json change touched in this commit. Issue 3 (CHANGELOG contradiction on --no-edit exemption) is a new documentation accuracy concern. Issues 4 and 5 are test quality carry-overs. The core blocking logic, alias expansion, and sequencer handling are sound.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5ca0266b6a

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh
Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh Outdated
Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh Outdated
git uses the LAST -c value for a key, so
`-c alias.c=status -c alias.c=commit c -m x` commits. Breaking on the
first match let a decoy earlier value expanding to a harmless subcommand
mask the real one — verified exit 0 before the fix, and the earlier
persisted-alias test had masked it by blocking for an unrelated reason.

Also stops the persisted-config lookup from re-running after an inline
alias already matched.

--config-env is left unresolved and documented as a residual rather than
half-fixed: the shared parser records its value undifferentiated from
-c, so the environment variable NAME arrives in place of the expansion.
Separating them is a hook-utils change block-dangerous-git shares, filed
as its own issue.

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

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

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


Code Review — PR #736 (updated after c2552ee)

  • Gather context and understand the request
  • Inspect new commit (c2552ee) — last-value rule for inline alias expansion
  • Verify status of all prior review issues (1–6)
  • Evaluate new Codex findings on --config-env and persisted-lookup false positive
  • Report updated status

What Changed in c2552ee

The commit fixes two issues that were raised in the Codex P1/P2 review of 5ca0266:

Last inline alias value wins — FIXED

block-noncanonical-commit.sh:187–203: The old loop broke on the first matching -c alias.x= entry. Git uses the last value, so git -c alias.c=status -c alias.c=commit c -m bypass would have passed (decoy status value matched first, alias resolved as harmless, commit guard bypassed). The fix accumulates through all entries and acts on the final exp. Two new tests at lines 88–91 cover both the blocked and allowed directions. Fix is correct.

Skip persisted lookup after inline override — FIXED

block-noncanonical-commit.sh:210: The inline_alias_handled flag prevents the persisted git config lookup from running when an inline alias was already expanded. This closes the false-positive where git -c alias.c=status c would have been allowed by the inline expansion but then blocked by the persisted alias.c=commit in .git/config. Guard is correct.

--config-env bypass — documented residual (acceptable)

block-noncanonical-commit.sh:49–54 and CHANGELOG.md:22–24: The shared parser stores the --config-env environment variable name rather than the resolved value, making resolution impossible without a hook-utils change that block-dangerous-git also needs. The header and CHANGELOG both document this clearly as a tracked residual. Acceptable scoping.


Remaining Open Issues

1. plugin.json:5 top-level description — merge blocker — NOT FIXED

plugin.json:5 still reads:

"description": "Eight safety guards that block … (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable."

Two problems: "Eight" should be "Nine", and git commit is now blocking (not advisory). This is what the marketplace and /plugin install display. Fix this →


2. plugin.json:74 flag_commit_pr_skill_bypass_enabled description — merge blocker — NOT FIXED (carried from every prior review cycle)

plugin.json:74 still reads:

"description": "Advise when direct git commit / gh pr create bypasses the source-control skills (never blocks)"

git commit is no longer this guard's concern — it moved to block-noncanonical-commit. A user toggling this kill switch via /plugin configure guardrails gets the wrong mental model.

Suggested: "Advise when direct \gh pr create` bypasses the /pull-request create skill (never blocks)"`. Fix this →


3. CHANGELOG still lists --no-edit as exempt — documentation bug — NOT FIXED

CHANGELOG.md:15 says:

…gating them would strand real work: `--amend`/`--no-edit`, `-C`/…

But 5ca0266 specifically removed --no-edit as a standalone exemption (git commit --no-edit -m x is now blocked). A consumer reading the changelog believes --no-edit is still exempt. Change --amend/--no-edit to just --amend, and add a note that --no-edit alone is not exempt. Fix this →


4. Duplicate test case — test quality — NOT FIXED

block-noncanonical-commit.test.sh:42 and lines 49–50 both run "git commit -F - --cleanup=verbatim" as the command. The trailer_policy none regression is worth documenting but the command must differ to prove anything. Drop --cleanup=verbatim from line 49–50. Fix this →


5. Sequencer fixture covers only MERGE_HEAD — test quality — NOT FIXED

The production guard at block-noncanonical-commit.sh:144 checks five markers (MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD, rebase-merge, rebase-apply). The sequencer fixture only exercises MERGE_HEAD. A mkdir "$GITDIR/rebase-merge" / rmdir cycle would validate the directory-marker path. Fix this →


6. Telemetry schema diverges — low priority — NOT FIXED

block-noncanonical-commit.sh:96 emits form (scalar string); flag-commit-pr-skill-bypass.sh:96 emits forms (array). Not a blocker.


Summary

c2552ee resolves the two correctness issues raised by Codex on 5ca0266 — last-value-wins for inline aliases (the decoy-value bypass) and the false-positive from the persisted lookup running after an inline alias was already expanded. The --config-env gap is correctly scoped as a documented residual needing a hook-utils change shared with block-dangerous-git.

Two merge blockers remain: plugin.json:5 still says "Eight" and lists git commit as advisory (issue 1), and plugin.json:74 still names git commit in the kill-switch description (issue 2) — neither touched in any commit so far. Issue 3 (CHANGELOG contradiction on --no-edit exemption) is a documentation accuracy concern. Issues 4 and 5 are test quality carry-overs. The core blocking logic, alias expansion with correct last-value semantics, and sequencer handling are sound.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c2552ee2a1

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh
`git --git-dir=X --work-tree=Y commit -m x` concludes a merge in X, but
the probe read the session cwd instead, so a real in-progress merge was
refused. A false block rather than a bypass — the safe direction — but
still wrong, and it would strand exactly the conflict resolution the
exemption exists to protect.

Same family as the -C fix: the command names the repo, and the guard has
to read that one.

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

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

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


Code Review — PR #736 (updated after 3efc8d5)

  • Gather context and inspect new commit (3efc8d5)
  • Verify status of all prior review issues (1–6)
  • Evaluate --git-dir/--work-tree fix
  • Report updated status

What Changed in 3efc8d5

The commit closes the Codex P2 finding from the previous review cycle: git --git-dir=X --work-tree=Y commit -m x concludes a merge in X, but the sequencer probe was reading the hook payload's cwd instead.

explicit_git_dir() — new function (lines 113–133): scans argv for --git-dir <path> and --git-dir=<path> forms. Returns on the first match; since --git-dir in a single command is never duplicated in practice this is fine, though it differs from the last-value accumulation used in effective_dir() for -C.

sequencer_in_progress() updated (lines 158–175): when explicit is non-empty, uses it directly as the git dir (resolving relative paths against $repo). Otherwise falls back to git -C $repo rev-parse --absolute-git-dir as before. The relative-path join ($repo/$dir) is correct for the common case of --git-dir=.git.

Two new tests (lines 172–185): MERGE_HEAD present → allowed; MERGE_HEAD removed → blocked. Both use cwd:"/" to prove the probe reads the named git dir, not the payload cwd.

The fix is mechanically sound.


Issues Fixed in 3efc8d5

Codex P2 — --git-dir/--work-tree sequencer gap: FIXED

A commit concluding a merge in an explicitly named git dir is now correctly exempted instead of blocked with a false-positive exit 2.


Remaining Open Issues

1. plugin.json:5 top-level description — merge blocker — NOT FIXED (carried from every prior review cycle)

plugin.json:5 still reads:

"description": "Eight safety guards that block … (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable."

Two problems: "Eight" should be "Nine", and git commit is now blocking, not advisory. This is what the marketplace and /plugin install display. Fix this →


2. plugin.json:74 flag_commit_pr_skill_bypass_enabled description — merge blocker — NOT FIXED (carried from every prior review cycle)

plugin.json:74 still reads:

"description": "Advise when direct git commit / gh pr create bypasses the source-control skills (never blocks)"

git commit is no longer this guard's concern — it moved to block-noncanonical-commit. A user toggling this kill switch via /plugin configure guardrails gets the wrong mental model.

Suggested: "Advise when direct \gh pr create` bypasses the /pull-request create skill (never blocks)"`. Fix this →


3. CHANGELOG still lists --no-edit as exempt — documentation bug — NOT FIXED

CHANGELOG.md:15 says:

…gating them would strand real work: `--amend`/`--no-edit`, `-C`/…

But 5ca0266 removed --no-edit as a standalone exemption — git commit --no-edit -m x is now blocked, which is correct since git accepts it for an ordinary commit. A consumer reading this changelog will believe --no-edit is still exempt when it is not. Change --amend/--no-edit to just --amend, and add a note that --no-edit alone is not exempt. Fix this →


4. Duplicate test case — test quality — NOT FIXED

block-noncanonical-commit.test.sh:42 and lines 49–50 both run "git commit -F - --cleanup=verbatim" as the command. The trailer_policy none regression is worth documenting, but the command must differ to prove anything distinct. Drop --cleanup=verbatim from lines 49–50 so the two cases exercise different inputs. Fix this →


5. Sequencer fixture covers only MERGE_HEAD — test quality — NOT FIXED

The production guard at block-noncanonical-commit.sh:176 checks five markers (MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD, rebase-merge, rebase-apply). All sequencer fixtures — the original cwd tests, the -C tests, and the new --git-dir tests added in 3efc8d5 — only exercise MERGE_HEAD. A mkdir "$GITDIR/rebase-merge" / rmdir cycle would validate the directory-marker path ([[ -e ]] covering both files and directories) and close the gap the PR body claims to have proven. Fix this →


6. Telemetry schema diverges — low priority — NOT FIXED

block-noncanonical-commit.sh:96 emits form (scalar string); flag-commit-pr-skill-bypass.sh:96 emits forms (array). Not a blocker.


Observation (no action needed)

explicit_git_dir() returns on first match; effective_dir() accumulates last -C. For -C, git applies the last value so accumulation is load-bearing. For --git-dir, while git would also use the last occurrence, nobody passes --git-dir twice in a single command, so first-match is harmless in practice. Not worth a change, but worth noting as an intentional asymmetry if ever revisited.


Summary

3efc8d5 correctly closes the --git-dir/--work-tree sequencer gap identified in the Codex P2 finding. The fix is sound: the new explicit_git_dir() function isolates the parse concern cleanly, sequencer_in_progress takes it as a second argument, and both the probe direction and test fixture are correct.

Two merge blockers remain unchanged: plugin.json:5 still says "Eight" and lists git commit as advisory (issue 1), and plugin.json:74 still names git commit in the kill-switch description (issue 2). Issue 3 (CHANGELOG contradiction on --no-edit) is a documentation accuracy concern. Issues 4 and 5 are test-quality carry-overs.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3efc8d572d

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh
Comment thread plugins/guardrails/hooks/block-noncanonical-commit.sh
@kyle-sexton
kyle-sexton merged commit 816e838 into main Jul 20, 2026
20 checks passed
@kyle-sexton
kyle-sexton deleted the feat/731-block-noncanonical-commit branch July 20, 2026 19:05
kyle-sexton added a commit that referenced this pull request Jul 22, 2026
…git parser (#903)

## What

The shared git-option parser `hook::git_resolve_subcommand`
(`lib/hook-utils.sh`) collected `-c`, `--config`, and `--config-env`
values into one `HOOK_GIT_CONFIG_VALUES` array with no marker
distinguishing them. But `--config-env=<key>=<envvar>` supplies the
**name of an environment variable** holding the value (git reads it at
runtime), not the value itself. Both git guards read the env-var name as
the literal alias expansion, so:

- `block-noncanonical-commit` — `git --config-env=alias.z=AV z` with
`AV=commit` was not recognized as a commit (verified exit 0 → **fail
open**).
- `block-dangerous-git` — the same shape for its alias-expansion path
(`AV='reset --hard'`).

Neither is a bug in the guards; the information they need was not in the
array.

Closes #740.

## Fix

- **Parser tags origin** — `hook::git_resolve_subcommand` fills a
parallel `HOOK_GIT_CONFIG_VALUE_KINDS` array (`"inline"` for
`-c`/`--config`, `"env"` for `--config-env`), for both the two-word and
`=`-attached forms.
- **New resolver** — `hook::git_effective_config_values` projects each
value to its EFFECTIVE assignment: inline values pass through; an `env`
value `<key>=<envvar>` resolves to `<key>=${!envvar}` against the hook's
inherited environment (the same environment git reads), gated on the
env-var name being a valid shell identifier and using `${!envvar-}` for
`set -u` safety. An unset or invalid-name variable projects to an empty
value — git itself rejects an unset `--config-env` variable (fatal), so
the assignment never takes effect and the empty projection yields no
spurious alias match.
- **Both guards** switch their alias-resolution loop from
`HOOK_GIT_CONFIG_VALUES` to the resolved `HOOK_GIT_CONFIG_EFFECTIVE`.

`block-no-verify.sh` is intentionally unchanged: it keys on the config
**key** (`core.hooksPath=`), which survives resolution, so it is not
affected by the env/value confusion.

## Review-driven hardening (two further fail-opens of the same guards)

An independent security review of the initial fix found two more
residual bypasses of these guards — both verified against real git
behavior — now closed on this branch:

- **Command-line env scope.** The resolver first read only the hook's
*ambient* environment, but `AV=commit git --config-env=alias.c=AV c`
(inline prefix) and `env AV=commit git …` set the variable only in
*git's* environment — a self-contained one-liner that passed an
ambient-only check. `hook::git_resolve_index` now collects the
command-line assignments it already walks past (both the inline prefix
and the `env` wrapper) into `HOOK_GIT_ENV_ASSIGNMENTS`, and the resolver
prefers them over ambient (last wins), matching what git's process
actually sees. The identifier gate on the indirect expansion is pinned
by an injection-shaped-name test proving no evaluation occurs (no file
created).
- **Case-insensitive alias matching.** git config names are
case-insensitive, so `git -c alias.RH='reset --hard' rh` and `git -c
alias.rh=… RH` both run the alias, yet the guards' inline-alias re-check
matched case-sensitively. Both guards now fold both sides of the
alias-key match (the expansion value keeps its case).

The `--config-env` inline-prefix case that the issue documented as out
of scope is thus specifically covered here for config resolution; the
parser still does not evaluate shell assignments generally.

## Blast radius

`hook-utils.sh` is a shared library materialized into every carrying
plugin via `scripts/sync-hook-utils.sh`, and the delivery gate requires
every carrying plugin to bump its version (the plugin version is the
consumer update-cache key). So this change syncs the lib to all 11
carrying plugins with a patch bump + changelog entry each (guardrails
carries the real fix note; the other 10 note a no-behavior-change
shared-lib sync).

## Tests

- `lib/hook-utils.test.sh` — parser kind tagging (both forms);
effective-value projection (env resolved, inline pass-through, unset →
empty, invalid/injection-shaped identifier → empty with no evaluation);
and command-line-assignment resolution (inline prefix, `env` wrapper,
override-of-ambient) through the full `git_resolve_index` path.
- Both guards' contract suites — env-sourced alias bypass blocked (`=`
and two-word forms), inline-prefix and `env`-wrapper bypass blocked,
case-folded alias (upper subcommand / upper key) blocked, safe env alias
allowed, unset var allowed (git rejects it), injection-shaped env-var
name allowed with an asserted no-file (identifier-gate pin).
- All green locally: `lib/hook-utils.test.sh` (75),
`block-noncanonical-commit` (59), `block-dangerous-git` (203);
`shellcheck --rcfile=.shellcheckrc`; `sync-hook-utils.sh
--check`/`--check-bump`; `check-changelog-parity.sh
--check`/`--check-bump`; `validate-plugins.sh`; markdownlint — all pass.

## Related

- Refs #731 / #736 — where the `--config-env` residual surfaced; #736
documented it in the new hook's header rather than half-fixing it in one
consumer. This PR fixes it in the shared parser so both guards inherit
the fix.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(guardrails): block non-canonical git commit; fix trailer-policy false positive

1 participant