-
Notifications
You must be signed in to change notification settings - Fork 2
feat(guardrails): opt-in git commit-msg hook for tool-agnostic subject enforcement #1077
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| #!/usr/bin/env bash | ||
| # guardrails-commit-msg-convention v1 | ||
| # Installed into <repo>/.git/hooks/commit-msg by /guardrails:setup apply | ||
| # install-commit-msg (personal lane). Safe to remove: delete this file (and | ||
| # guardrails-resolve-convention.sh beside it); nothing else references them. | ||
| # | ||
| # WHAT THIS IS — the tool-agnostic DEPTH layer of commit-convention | ||
| # enforcement: unlike the Claude-Code-layer gates (which see only tool calls), | ||
| # a git commit-msg hook validates EVERY commit on this machine in this repo — | ||
| # editor commits, `git commit -F <file>`, IDE integrations, humans outside | ||
| # Claude. It enforces the same team-tracked pattern the CC-layer gate reads, | ||
| # through a copy of the same resolver (single parse contract, no drift). | ||
| # | ||
| # SENTINEL: the "guardrails-commit-msg-convention" marker above is load-bearing. | ||
| # Convention-INFERENCE tooling (e.g. /source-control:setup) must not read this | ||
| # hook as an independent convention signal — it is derived FROM the tracked | ||
| # config, and counting it would create an echo cycle. Tools detect the marker | ||
| # and skip this file. | ||
| # | ||
| # UNRESOLVED = NO ENFORCEMENT: no team-tracked `subject_pattern` (or a | ||
| # non-POSIX-ERE one) -> exit 0. Enforcement strength equals the strength of | ||
| # explicit team config; this hook never imposes a default. | ||
| # | ||
| # NO BYPASS ADVICE BY DESIGN: the failure message says how to FIX the subject, | ||
| # never to pass --no-verify — in Claude Code sessions the guardrails | ||
| # block-no-verify guard refuses --no-verify anyway, and suggesting it would | ||
| # wedge an agent between two guards (the designed exit is a compliant subject). | ||
| # | ||
| # CHAINING: if a pre-existing commit-msg hook was present at install time, the | ||
| # installer renamed it to commit-msg.pre-guardrails and this hook runs it FIRST | ||
| # (its verdict stands — a rejection there rejects the commit), then applies the | ||
| # convention check. Removing this hook: restore commit-msg.pre-guardrails back | ||
| # to commit-msg. | ||
|
|
||
| set -uo pipefail | ||
|
|
||
| MSG_FILE="${1:?commit-msg hook invoked without a message file}" | ||
| HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
|
|
||
| # Chain a pre-existing hook first; its rejection is final. | ||
| if [[ -x "$HOOK_DIR/commit-msg.pre-guardrails" ]]; then | ||
| "$HOOK_DIR/commit-msg.pre-guardrails" "$@" || exit $? | ||
| elif [[ -f "$HOOK_DIR/commit-msg.pre-guardrails" ]]; then | ||
| bash "$HOOK_DIR/commit-msg.pre-guardrails" "$@" || exit $? | ||
| fi | ||
|
|
||
| # Repo root: commit-msg hooks run with cwd at the repo top level, but resolve | ||
| # explicitly so a hooks-dir invocation from elsewhere still reads the right | ||
| # config. | ||
| REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0 | ||
| RESOLVER="$HOOK_DIR/guardrails-resolve-convention.sh" | ||
| [[ -f "$RESOLVER" ]] || exit 0 # resolver removed -> no enforcement, never block blind | ||
|
|
||
| SUBJECT_ERE="$(bash "$RESOLVER" "$REPO_ROOT" subject_pattern 2>/dev/null)" || SUBJECT_ERE="" | ||
| [[ -n "$SUBJECT_ERE" ]] || exit 0 | ||
|
|
||
| # First non-comment, non-empty line = the subject. Comment char per git config | ||
| # (core.commentChar, default '#'); 'auto' cannot be pre-known, treat as '#'. | ||
| comment_char="$(git config --get core.commentChar 2>/dev/null || true)" | ||
| [[ -n "$comment_char" && "$comment_char" != "auto" ]] || comment_char="#" | ||
|
|
||
| subject="" | ||
| while IFS= read -r line || [[ -n "$line" ]]; do | ||
| line="${line%$'\r'}" | ||
| [[ -n "$line" ]] || continue | ||
| [[ "${line:0:1}" == "$comment_char" ]] && continue | ||
| subject="$line" | ||
| break | ||
| done <"$MSG_FILE" | ||
|
|
||
| # Empty message: git aborts the commit itself; nothing to validate. | ||
| [[ -n "$subject" ]] || exit 0 | ||
|
|
||
| # Fixup/squash autosquash subjects derive from an existing commit and are | ||
| # consumed by rebase --autosquash; validating the prefix form would block the | ||
| # documented workflow the CC-layer gate also exempts. | ||
| case "$subject" in | ||
| "fixup! "* | "squash! "* | "amend! "*) exit 0 ;; | ||
| *) ;; # an ordinary subject falls through to validation | ||
| esac | ||
|
|
||
| if ! printf '%s\n' "$subject" | grep -Eq -- "$SUBJECT_ERE"; then | ||
| { | ||
| echo "commit-msg (guardrails): subject violates the team convention." | ||
| echo " subject: $subject" | ||
| echo " pattern: $SUBJECT_ERE (from .claude/source-control.md, team layer)" | ||
| echo "Rewrite the subject to match the pattern and commit again." | ||
| echo "(Convention home: .claude/source-control.md — change it there via PR if the" | ||
| echo "pattern itself is wrong. This hook enforces only what the team tracked.)" | ||
| } >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| exit 0 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| #!/usr/bin/env bash | ||
| # Contract test for lib/git-hooks/commit-msg-convention.sh (guardrails plugin). | ||
| # | ||
| # Black-box: installs the hook template + resolver copy into an isolated repo's | ||
| # .git/hooks, writes commit-message files, invokes the hook directly, asserts | ||
| # on exit code (1 = rejected, 0 = accepted). | ||
|
|
||
| set -uo pipefail | ||
|
|
||
| HOOK_DIR_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| PLUGIN_ROOT="$(cd "$HOOK_DIR_SRC/../.." && pwd)" | ||
| TEMPLATE="$PLUGIN_ROOT/lib/git-hooks/commit-msg-convention.sh" | ||
| RESOLVER_SRC="$PLUGIN_ROOT/hooks/resolve-convention-pattern.sh" | ||
| TEST_TMPDIR="$(mktemp -d)" | ||
| trap 'rm -rf "$TEST_TMPDIR"' EXIT | ||
|
|
||
| PASS=0 | ||
| FAIL=0 | ||
| ok() { | ||
| PASS=$((PASS + 1)) | ||
| printf 'ok: %s\n' "$1" | ||
| } | ||
| bad() { | ||
| FAIL=$((FAIL + 1)) | ||
| printf 'FAIL: %s\n' "$1" >&2 | ||
| } | ||
| assert_exit() { | ||
| local label="$1" expected="$2" actual="$3" | ||
| if [[ "$expected" == "$actual" ]]; then ok "$label (exit $actual)"; else bad "$label: expected exit $expected, got $actual"; fi | ||
| } | ||
|
|
||
| # Isolated repo with the hook installed and an optional team convention body. | ||
| newrepo() { | ||
| local d | ||
| d="$(mktemp -d "$TEST_TMPDIR/repo.XXXXXX")" | ||
| git -C "$d" init -q -b main | ||
| mkdir -p "$d/.claude" | ||
| [[ -n "${1:-}" ]] && printf '%s\n' "$1" >"$d/.claude/source-control.md" | ||
| local hooks | ||
| hooks="$(git -C "$d" rev-parse --absolute-git-dir)/hooks" | ||
| mkdir -p "$hooks" | ||
| cp "$TEMPLATE" "$hooks/commit-msg" | ||
| cp "$RESOLVER_SRC" "$hooks/guardrails-resolve-convention.sh" | ||
| chmod +x "$hooks/commit-msg" | ||
| printf '%s' "$d" | ||
| } | ||
|
|
||
| # run <label> <repo> <message-content> <expected-exit> | ||
| run() { | ||
| local label="$1" repo="$2" msg="$3" expected="$4" rc hooks | ||
| hooks="$(git -C "$repo" rev-parse --absolute-git-dir)/hooks" | ||
| printf '%s\n' "$msg" >"$repo/.git-commit-msg-under-test" | ||
| (cd "$repo" && bash "$hooks/commit-msg" "$repo/.git-commit-msg-under-test") >/dev/null 2>&1 | ||
| rc=$? | ||
| assert_exit "$label" "$expected" "$rc" | ||
| } | ||
|
|
||
| TICKET=$'## subject_pattern\n^[A-Z]+-[0-9]+: .+' | ||
| CC=$'## subject_pattern\nConventional Commits' | ||
| PCRE=$'## subject_pattern\n^(?:feat|fix): .+' | ||
|
|
||
| # --- unresolved -> pass-through ------------------------------------------------ | ||
| r="$(newrepo "")" | ||
| run "no team file: any subject accepted" "$r" "whatever subject" 0 | ||
| r="$(newrepo "$PCRE")" | ||
| run "PCRE pattern: non-enforceable, accepted" "$r" "whatever subject" 0 | ||
|
|
||
| # --- enforcement --------------------------------------------------------------- | ||
| r="$(newrepo "$TICKET")" | ||
| run "ticket pattern: conforming accepted" "$r" $'ABC-123: do the thing\n\nBody.' 0 | ||
| run "ticket pattern: violating rejected" "$r" $'junk subject\n\nBody.' 1 | ||
| run "comment lines skipped before subject" "$r" $'# comment from template\nABC-9: real subject' 0 | ||
| run "fixup! subject exempt (autosquash)" "$r" "fixup! anything at all" 0 | ||
| run "squash! subject exempt (autosquash)" "$r" "squash! anything" 0 | ||
| run "empty message: git's problem, accepted here" "$r" "" 0 | ||
| r="$(newrepo "$CC")" | ||
| run "CC keyword: feat accepted" "$r" "feat: add thing" 0 | ||
| run "CC keyword: junk rejected" "$r" "junk subject" 1 | ||
|
|
||
| # --- resolver removed -> fail open, never block blind --------------------------- | ||
| r="$(newrepo "$TICKET")" | ||
| hooks="$(git -C "$r" rev-parse --absolute-git-dir)/hooks" | ||
| rm -f "$hooks/guardrails-resolve-convention.sh" | ||
| run "resolver removed: accepted (no blind block)" "$r" "junk subject" 0 | ||
|
|
||
| # --- chaining ------------------------------------------------------------------ | ||
| r="$(newrepo "$TICKET")" | ||
| hooks="$(git -C "$r" rev-parse --absolute-git-dir)/hooks" | ||
| printf '#!/usr/bin/env bash\nexit 1\n' >"$hooks/commit-msg.pre-guardrails" | ||
| chmod +x "$hooks/commit-msg.pre-guardrails" | ||
| run "chained pre-existing hook rejection is final" "$r" "ABC-1: fine subject" 1 | ||
| printf '#!/usr/bin/env bash\nexit 0\n' >"$hooks/commit-msg.pre-guardrails" | ||
| run "chained hook passes, convention still enforced" "$r" "junk subject" 1 | ||
| run "chained hook passes, conforming accepted" "$r" "ABC-2: fine" 0 | ||
|
|
||
| # --- sentinel present (inference-exclusion contract) --------------------------- | ||
| if grep -q "guardrails-commit-msg-convention" "$TEMPLATE"; then | ||
| ok "sentinel marker present in template" | ||
| else | ||
| bad "sentinel marker missing from template" | ||
| fi | ||
|
|
||
| echo "" | ||
| echo "PASS=$PASS FAIL=$FAIL" | ||
| [[ "$FAIL" -eq 0 ]] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,7 @@ | ||
| --- | ||
| name: setup | ||
| description: "Verify the guardrails hooks' runtime prerequisites and per-guard toggle state for this machine. Use when: 'set up guardrails', 'configure guardrails', 'is guardrails working', 'which guards are on', a guard failed open with a jq notice, or after tuning guard toggles. Actions: check (read-only verification, default) | apply (resolve what check found). Re-runnable and safe." | ||
| argument-hint: "check | apply" | ||
| description: "Verify the guardrails hooks' runtime prerequisites and per-guard toggle state for this machine. Use when: 'set up guardrails', 'configure guardrails', 'is guardrails working', 'which guards are on', a guard failed open with a jq notice, after tuning guard toggles, or 'install the commit-msg hook' / 'enforce the commit convention for every committer'. Actions: check (read-only verification, default) | apply (resolve what check found) | apply install-commit-msg (opt-in: install the tool-agnostic commit-msg convention hook into this repo's personal .git/hooks). Re-runnable and safe." | ||
| argument-hint: "check | apply | apply install-commit-msg" | ||
| user-invocable: true | ||
| disable-model-invocation: true | ||
| --- | ||
|
|
@@ -60,10 +60,70 @@ nothing — it only points: | |
|
|
||
| Re-running `apply` after everything passes changes nothing and reports "already configured". | ||
|
|
||
| ## `apply install-commit-msg` (opt-in, explicit argument only) | ||
|
|
||
| The DEPTH layer of commit-convention enforcement: a git `commit-msg` hook validating every | ||
| commit on this machine in this repo — editor commits, `git commit -F <file>`, IDE | ||
| integrations, humans outside Claude — against the same team-tracked pattern the CC-layer | ||
| `block-convention-violation` guard reads, through a copy of the same resolver. Never runs | ||
| from bare `apply`; only the explicit `install-commit-msg` argument installs anything. | ||
|
|
||
| **Lane: personal `.git/hooks/` only.** This writes the CURRENT OPERATOR's repo-local hooks | ||
| directory — invisible to teammates, uncommitted, removable by deleting two files. A | ||
| committed team lane (`core.hooksPath` pointing at a tracked directory) is deliberately NOT | ||
| scaffolded: `core.hooksPath` changes are exactly what the `block-no-verify` guard refuses | ||
| as a hook-bypass shape, and pointing every teammate's git at a tracked hooks dir is a team | ||
| decision made by a human in a PR, not by this skill. When the team wants shared | ||
| enforcement, say so and point at a commit-msg entry in the repo's own hook manager | ||
| (lefthook/husky/CI) instead. | ||
|
|
||
| **Preflight — refuse rather than surprise (run all, report, stop on any REFUSE):** | ||
|
|
||
| 1. **Managed-repo detection.** `git config --get core.hooksPath` non-empty, or | ||
| `lefthook.yml`/`.lefthook.yml`, `.husky/`, or a `pre-commit` config managing hooks → | ||
| REFUSE: the repo's hook manager owns this surface; installing behind its back invites | ||
| silent shadowing. Remediation: add the convention check to the manager's own | ||
| `commit-msg` entry. | ||
| 2. **Existing `commit-msg` hook.** Present and NOT sentinel-marked → offer exactly two | ||
| paths and default to refusing: **chain** (rename the existing hook to | ||
| `commit-msg.pre-guardrails`; the installed hook runs it first and its rejection is | ||
| final) or **refuse** (leave everything untouched). Never overwrite. This includes an | ||
| operator's machine-local commit-msg gate — chaining preserves it. | ||
| 3. **Sentinel-marked hook already installed** → idempotent re-install: overwrite the two | ||
| guardrails-owned files in place (template may have updated), report "refreshed". | ||
|
|
||
| **Install (on a clean preflight):** copy `${CLAUDE_PLUGIN_ROOT}/lib/git-hooks/commit-msg-convention.sh` | ||
| to `<git-dir>/hooks/commit-msg` and `${CLAUDE_PLUGIN_ROOT}/hooks/resolve-convention-pattern.sh` | ||
| to `<git-dir>/hooks/guardrails-resolve-convention.sh` (resolve `<git-dir>` via | ||
| `git rev-parse --absolute-git-dir` — in a worktree `.git` is a file), `chmod +x` both. | ||
|
Comment on lines
+95
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this setup is run from a linked worktree, Useful? React with 👍 / 👎. |
||
|
|
||
| **Verify + report:** run the installed hook against a throwaway conforming and violating | ||
| message file and show both outcomes; state the removal path (delete the two files; restore | ||
| `commit-msg.pre-guardrails` to `commit-msg` if chaining renamed one) and that | ||
| **unresolved = no enforcement** — with no team-tracked `subject_pattern` the hook | ||
| passes everything, so installing before `/source-control:setup apply` writes a convention | ||
| is inert, not harmful. | ||
|
|
||
| **Known interactions (state them in the report):** | ||
|
|
||
| - `--no-verify` skips commit-msg hooks, and the guardrails `block-no-verify` guard blocks | ||
| that flag in Claude sessions — by design the only exit from a rejection is a compliant | ||
| subject (the hook's message says exactly that and never suggests bypass). | ||
| - The CC-layer `block-convention-violation` guard usually blocks a violating subject | ||
| before git ever runs, so this hook firing in a Claude session means the CC layer was | ||
| bypassed or disabled — it is the backstop, not the primary UX. | ||
| - Convention-inference tooling must skip sentinel-marked hooks (the | ||
| `guardrails-commit-msg-convention` marker) — the hook is derived FROM the tracked | ||
| config and is not an independent convention signal. | ||
|
|
||
| ## What this skill does NOT do | ||
|
|
||
| - Exercise a guard — any matching tool call does that end-to-end. | ||
| - Write the plugin cache, Claude Code user settings, or `pluginConfigs`. | ||
| - Install any tool, during either `check` or `apply` — guidance only. | ||
| - Install any tool, during either `check` or `apply` — guidance only. The ONLY write this | ||
| skill ever performs is the explicit `apply install-commit-msg` action's two files in the | ||
| operator's own `.git/hooks/`, behind its preflight. | ||
| - Touch `core.hooksPath`, a hook manager's config, or any tracked file — the team | ||
| enforcement lane is a human decision in a PR. | ||
| - Weaken a guard: it reports and routes; disabling is always the user's explicit act | ||
| through the native configuration surface. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For commits whose real subject begins with the configured comment character, this skips that subject as if it were template commentary;
git commit -m '# bad'(andgit commit -F file) preserve that line as the commit subject, with cleanup controlled separately (git commit -hlists-m/-Fmessage inputs and a separate--cleanupoption). With a ticket-pattern config I verifiedgit commit -m '# definitely invalid'is accepted and recorded, and a-Ffile whose first line is invalid#...but later line matches is also accepted, so the new backstop can be bypassed by a comment-prefixed subject.Useful? React with 👍 / 👎.