diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index bf6b33c1c6..6e087942c6 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "guardrails", - "version": "0.11.0", + "version": "0.12.0", "description": "Nine 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) 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.", "author": { "name": "Melodic Software", diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 62995974ad..bb9dab5125 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,39 @@ 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.12.0] + +### Added + +- **Opt-in git `commit-msg` hook — tool-agnostic convention enforcement (audit f1 + depth layer, f4 backstop).** New `/guardrails:setup apply install-commit-msg` + action installs `lib/git-hooks/commit-msg-convention.sh` (plus a copy of the + enforcement resolver) into the operator's personal `.git/hooks/`, validating the + subject of EVERY commit in the repo — editor commits, `git commit -F `, + IDE integrations, humans outside Claude — against the same team-tracked pattern + the CC-layer gate reads. Trust-surface contract: + - **Never runs from bare `apply`** — only the explicit `install-commit-msg` + argument writes anything, and only the two guardrails-owned files in the + operator's own hooks dir. `core.hooksPath`, hook-manager configs, and tracked + files are never touched; the committed team lane is deliberately not + scaffolded (a human PR decision — and `core.hooksPath` changes are the exact + shape `block-no-verify` refuses). + - **Chain-or-refuse:** managed repos (`core.hooksPath`, lefthook, husky, + pre-commit) → refuse with the manager-side remediation; an existing + `commit-msg` hook is never overwritten — chain (renamed to + `commit-msg.pre-guardrails`, run first, its rejection final) or refuse. + - **Sentinel-marked** (`guardrails-commit-msg-convention`) so + convention-inference tooling excludes the installed hook as a signal + (echo-cycle guard); sentinel-marked re-install is idempotent ("refreshed"). + - **Unresolved = no enforcement** (never the bundled CC default); resolver + removed → fail open, never block blind; `fixup!`/`squash!`/`amend!` subjects + exempt (autosquash); a chained pre-existing hook's rejection is final. + - **Deadlock-by-design exit:** the rejection message instructs fixing the + subject and never suggests `--no-verify` (which `block-no-verify` refuses in + Claude sessions anyway); in-session the CC-layer gate blocks first, making + this hook the cross-tool backstop. + 15-case contract suite (`lib/git-hooks/commit-msg-convention.test.sh`). + ## [0.11.0] ### Added diff --git a/plugins/guardrails/lib/git-hooks/commit-msg-convention.sh b/plugins/guardrails/lib/git-hooks/commit-msg-convention.sh new file mode 100755 index 0000000000..b3fc339031 --- /dev/null +++ b/plugins/guardrails/lib/git-hooks/commit-msg-convention.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# guardrails-commit-msg-convention v1 +# Installed into /.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 `, 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 diff --git a/plugins/guardrails/lib/git-hooks/commit-msg-convention.test.sh b/plugins/guardrails/lib/git-hooks/commit-msg-convention.test.sh new file mode 100755 index 0000000000..2db509ba94 --- /dev/null +++ b/plugins/guardrails/lib/git-hooks/commit-msg-convention.test.sh @@ -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