From fb8dd9584b9657fbba6582b84fefb91a5d69ecfd Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:02:50 -0400 Subject: [PATCH 1/2] chore(git-hooks): install the guardrails git hooks as shared lefthook jobs Commit the guardrails plugin's three git-hook entries (commit-msg-convention, pre-commit-content, reference-verify) as shared lefthook jobs, so every clone gets them from `lefthook install`, and record the subject_pattern the commit-msg hook enforces in .claude/source-control.md. The .githooks/guardrails/ files are plugin-owned copies written by install.mjs copy --scope shared (guardrails 0.55.4), staged 100755 where they carry a shebang. The installer's .gitattributes line is left out: the managed `* text=auto eol=lf` already keeps them LF. Co-Authored-By: Claude Opus 5.5 --- .claude/source-control.md | 5 + .githooks/guardrails/abort-boundary.sh | 255 + .githooks/guardrails/check-lib.sh | 394 ++ .githooks/guardrails/cli-flag-cache.sh | 69 + .githooks/guardrails/cli-flag-verify.sh | 540 ++ .githooks/guardrails/commit-msg-convention.sh | 99 + .../guardrails/hardcoded-path-patterns.sh | 320 ++ .githooks/guardrails/hook-utils.sh | 5046 +++++++++++++++++ .githooks/guardrails/machine-path-patterns.sh | 66 + .githooks/guardrails/pre-commit-content.sh | 190 + .githooks/guardrails/reference-verify.sh | 193 + .../guardrails/resolve-convention-pattern.sh | 313 + .githooks/guardrails/run-guards.sh | 1126 ++++ .githooks/guardrails/secret-patterns.sh | 89 + .../guardrails/skill-reference-verify.sh | 927 +++ .githooks/guardrails/stale-path-verify.sh | 908 +++ .githooks/guardrails/verify-cli-flag.sh | 177 + README.md | 3 + lefthook.yml | 15 + 19 files changed, 10735 insertions(+) create mode 100644 .claude/source-control.md create mode 100644 .githooks/guardrails/abort-boundary.sh create mode 100755 .githooks/guardrails/check-lib.sh create mode 100644 .githooks/guardrails/cli-flag-cache.sh create mode 100755 .githooks/guardrails/cli-flag-verify.sh create mode 100755 .githooks/guardrails/commit-msg-convention.sh create mode 100644 .githooks/guardrails/hardcoded-path-patterns.sh create mode 100644 .githooks/guardrails/hook-utils.sh create mode 100644 .githooks/guardrails/machine-path-patterns.sh create mode 100755 .githooks/guardrails/pre-commit-content.sh create mode 100755 .githooks/guardrails/reference-verify.sh create mode 100755 .githooks/guardrails/resolve-convention-pattern.sh create mode 100755 .githooks/guardrails/run-guards.sh create mode 100644 .githooks/guardrails/secret-patterns.sh create mode 100755 .githooks/guardrails/skill-reference-verify.sh create mode 100755 .githooks/guardrails/stale-path-verify.sh create mode 100755 .githooks/guardrails/verify-cli-flag.sh create mode 100644 lefthook.yml diff --git a/.claude/source-control.md b/.claude/source-control.md new file mode 100644 index 0000000..039cf8a --- /dev/null +++ b/.claude/source-control.md @@ -0,0 +1,5 @@ +# source-control configuration + +## subject_pattern + +^((build|chore|ci|docs|feat|fix|perf|refactor|revert|security|style|test)(\(.+\))?!?: .+|Merge .+|Revert ".+) diff --git a/.githooks/guardrails/abort-boundary.sh b/.githooks/guardrails/abort-boundary.sh new file mode 100644 index 0000000..bf98073 --- /dev/null +++ b/.githooks/guardrails/abort-boundary.sh @@ -0,0 +1,255 @@ +# shellcheck shell=bash +# Abort boundary for the guardrails hooks (#3528). Sourced by every registered +# hook and by the dispatcher; never executed on its own. +# +# A hook has three outcomes, not two: allow (exit 0), block (exit 2, reason on +# stderr), and could-not-run. Before this file the third had no signal. A hook +# that died between its first line and its own final `exit` (an unbound +# variable under `set -u`, a helper that no longer exists, a `source` that +# failed) ended with whatever status the last command had, usually 1, and wrote +# nothing of its own. That status is not a block, so the tool call went ahead; +# bash's one-line error went to the debug log, and the transcript recorded +# "exit 1, stderr: (none)". How Claude Code treats each exit status is the +# hooks page's to state; our hooks.json rows set no failure-handling field, so +# they take its default. Pointer: +# https://code.claude.com/docs/en/hooks#exit-code-output. As of: 2026-10-11. +# Recheck trigger: that section changes what a status other than 0 or 2 does +# to the tool call, or is renamed. A blocking +# guard enforced nothing and nobody was told. The boundary makes that outcome a +# deliberate one: +# +# source "$_HOOK_SELF/abort-boundary.sh" +# guard::abort_boundary ... +# +# installs an EXIT trap that passes every CHOSEN status through untouched (the +# hook's own `exit 0` / `exit 2`, including the ones the shared helpers make on +# its behalf: hook::check_enabled, hook::require jq, hook::require_jq_blocking) +# and treats any other status as "the guard did not run". For those it writes +# one line naming the hook and the status to stderr, and then applies the +# hook's declared posture: +# +# open exit 0, after emitting the same text as a `systemMessage` on +# stdout, for the user (stderr on exit 0 reaches only the debug log, +# see docs/conventions/hook-observability/). The tool call proceeds, +# as it did before, and the notice says it was not checked. The +# model gets nothing: it cannot fix a hook. +# closed exit 2 with the notice on stderr, the channel a blocking exit feeds +# back to the agent. The tool call is denied. +# +# is the event the hook is registered for (PreToolUse, +# PostToolUse), kept in _GAB_EVENT for the dispatcher's telemetry. Pass an empty +# string when the event is not known at install time (the dispatcher serves +# both). +# +# Why a trap and not `set -e`: errexit across the hook set would change +# behavior on every non-zero status a guard tests on purpose. The trap sees +# only the status the process actually terminates with. +# +# Handler discipline. `trap - EXIT` is the handler's first action so nothing it +# does can re-enter it. It uses builtins only, every variable it reads is set +# at install time, and it calls nothing from hook-utils.sh, because a failed +# `source hook-utils.sh` is one of the aborts it exists to report. A failure +# inside the handler ends the process once, with that failure's status, after +# the stderr line is already written. +# +# One EXIT trap per shell. Bash holds a single EXIT trap, so a hook that runs +# its own `trap ... EXIT` (or `trap - EXIT`) after the install line REPLACES +# this boundary and is back to the silent abort it exists to remove, with +# nothing at run time to say so. abort-boundary.test.sh fails on any `trap` +# naming EXIT in a registered hook or in a library it sources; this file is the +# only one allowed to touch it. No hook needs exit-time work today (each is +# builtins plus one jq read, nothing to clean up). The supported way for one +# that does is to chain through this library, not around it: fill the chain slot +# below (_GAB_CONTINUE) that guard::_abort_on_exit calls before it decides, the +# chained function under the same handler discipline (builtins only, never +# exits, never touches the trap), with a suite case beside the others, in the +# same change. A release on purpose goes through guard::abort_boundary_release. +# +# run-guards.sh is the one documented exception to that discipline, and it is +# an exception because it is not a hook's exit-time work: its +# run_guards::guard_died is the DISPATCHER settling a dead guard and then +# finishing the run the process still owes, so it runs the guards left in +# another process, spawns jq to merge their documents, and ends at `builtin exit` +# without returning. Nothing is left for the handler to decide, which is why +# the slot may swallow control here and nowhere else. Any other consumer keeps +# the discipline above: a chained function that RETURNS, so that +# guard::_abort_on_exit still settles the status and exits. +# +# Under run-guards.sh the guards are sourced into the dispatcher's own shell, +# and `exit` there is a function of the dispatcher's. A guard that ends through +# it, or by falling off its end, never reaches this trap: the dispatcher applies +# guard::_abort_settle to the status itself and merges the notice document with +# the other guards' output. The trap fires only for a guard that dies of a hard +# error, when the shell is exiting, and then hands the status to the chain slot +# below (_GAB_CONTINUE), which the dispatcher fills for the span of its guards. + +[[ -n "${_GUARDRAILS_ABORT_BOUNDARY_LOADED:-}" ]] && return 0 +readonly _GUARDRAILS_ABORT_BOUNDARY_LOADED=1 + +_GAB_NAME="" +_GAB_EVENT="" +_GAB_POSTURE="open" +_GAB_CHOSEN=" " + +# guard::say_once : write to stderr unless this process +# already wrote . Under run-guards.sh the guards share one shell, so a +# refusal several of them make on one call reaches the deny reason once. +_GUARD_SAID=" " +guard::say_once() { + [[ "$_GUARD_SAID" == *" $1 "* ]] && return 0 + _GUARD_SAID+="$1 " + printf '%s\n' "$2" >&2 +} + +# Refusals several guards share. The caller exits 2. +guard::refuse_nul() { + guard::say_once nul "BLOCKED: the payload carries a NUL byte. Reissue the call without it." +} +guard::refuse_unparsable() { + guard::say_once unparsable "BLOCKED: the hook payload could not be parsed." +} +# The git guards that walk alias chains (hook::git_alias_admit). +# shellcheck disable=SC2154 # HOOK_ALIAS_WORK_MAX is hook-utils.sh's, loaded by every caller +guard::refuse_alias_chain() { + guard::say_once alias-chain "BLOCKED: checking this git alias chain needs over $HOOK_ALIAS_WORK_MAX re-expansions. Run the subcommand directly." +} +# guard::alias_depth_max: set HOOK_ALIAS_DEPTH_MAX, the deepest alias re-expansion +# the git guards walk. The work budget bounds time, not stack: bash runs each +# nested check_segment on the C stack, and a walk under 128 analyses still +# crashed the process (SIGSEGV, exit 139, which Claude Code lets through) on a +# 512 KB stack. A level costs up to ~9 KB measured under the dispatcher (a +# 512 KB stack survives 50 hops and not 60), so the cap is 20 KB a level, which +# leaves headroom for the heavier `!` shell-alias levels. The stack comes from +# /proc with builtins, because `$(ulimit -s)` forks and these guards run on every +# Bash call. No /proc (macOS, Git Bash) or an unlimited stack gives 25, what a +# 512 KB stack yields, and no stack gives more than 409 (8 MiB). For tests only, +# GUARDRAILS_PROC_LIMITS names a second limits file and GUARDRAILS_ALIAS_DEPTH_MAX +# a number; each can only lower the cap, since the result is the minimum of every +# value read and a missing or unparsable file reads as 25. +guard::alias_depth_max() { + local file line cap + HOOK_ALIAS_DEPTH_MAX= + for file in /proc/self/limits ${GUARDRAILS_PROC_LIMITS:+"$GUARDRAILS_PROC_LIMITS"}; do + cap=25 + if [[ -r "$file" ]]; then + while IFS= read -r line; do + if [[ "$line" =~ ^Max\ stack\ size[[:space:]]+([0-9]+)[[:space:]] ]]; then + cap=$((10#${BASH_REMATCH[1]} / 1024 / 20)) + break + fi + done <"$file" + fi + ((cap <= 409)) || cap=409 + [[ -n "$HOOK_ALIAS_DEPTH_MAX" ]] && ((cap >= HOOK_ALIAS_DEPTH_MAX)) || HOOK_ALIAS_DEPTH_MAX=$cap + done + if [[ "${GUARDRAILS_ALIAS_DEPTH_MAX-}" =~ ^[0-9]+$ ]] && ((10#$GUARDRAILS_ALIAS_DEPTH_MAX < HOOK_ALIAS_DEPTH_MAX)); then + HOOK_ALIAS_DEPTH_MAX=$((10#$GUARDRAILS_ALIAS_DEPTH_MAX)) + fi +} +guard::refuse_config_env_alias() { # + guard::say_once "config-env-$1" "BLOCKED: git alias '$1' is defined via --config-env, so its expansion cannot be checked. Define it in git config or run the subcommand directly." +} + +# guard::bad_switch_notice