Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/guardrails/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
33 changes: 33 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>`,
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
Expand Down
94 changes: 94 additions & 0 deletions plugins/guardrails/lib/git-hooks/commit-msg-convention.sh
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
Comment on lines +65 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate comment-prefixed commit subjects

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' (and git commit -F file) preserve that line as the commit subject, with cleanup controlled separately (git commit -h lists -m/-F message inputs and a separate --cleanup option). With a ticket-pattern config I verified git commit -m '# definitely invalid' is accepted and recorded, and a -F file 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 👍 / 👎.

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
105 changes: 105 additions & 0 deletions plugins/guardrails/lib/git-hooks/commit-msg-convention.test.sh
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 ]]
66 changes: 63 additions & 3 deletions plugins/guardrails/skills/setup/SKILL.md
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
---
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Install hooks at Git’s resolved hook path

When this setup is run from a linked worktree, git rev-parse --absolute-git-dir resolves to the per-worktree admin dir (for example .git/worktrees/<name>), but Git resolves hooks through git rev-parse --git-path hooks/commit-msg, which points at the common hooks directory. I verified a commit in a linked worktree ignores a hook copied under the absolute git dir, so apply install-commit-msg can report success while normal commits in that worktree remain unenforced; use Git’s --git-path hooks/... result for the install/chain targets instead.

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.
Loading