diff --git a/.claude/hooks/check-affected-pre-push.test.sh b/.claude/hooks/check-affected-pre-push.test.sh index f653de3124..19ba2a9135 100755 --- a/.claude/hooks/check-affected-pre-push.test.sh +++ b/.claude/hooks/check-affected-pre-push.test.sh @@ -309,6 +309,7 @@ cp "$tmp/stub" "$lh/scripts/check-affected.sh" cp "$hook" "$lh/.claude/hooks/" cp "$repo_root/lib/hook-utils.sh" "$lh/lib/" cp "$repo_root/lefthook.yml" "$lh/" +cp -R "$repo_root/.githooks" "$lh/" git_test_config "$lh" add -A git_test_config "$lh" commit -q -m base git_test_config "$lh" update-ref refs/remotes/origin/main HEAD diff --git a/.claude/source-control.md b/.claude/source-control.md index ca47cad92b..13e05da70c 100644 --- a/.claude/source-control.md +++ b/.claude/source-control.md @@ -5,8 +5,9 @@ Commit-subject / PR-title convention for the source-control plugin, resolved by CLAUDE.md/rules/commit-msg hook or fall back to the bundled Conventional Commits default. Re-run `/source-control:setup` to change these values. -Of the convention keys, only `pr_body_required_sections` is set here. Every other one falls -through to `/source-control:setup`'s inference (this repo's commit history is already +Of the convention keys, only `pr_body_required_sections` and `subject_pattern` are set here; +the guardrails `commit-msg` job in `lefthook.yml` enforces `subject_pattern`. Every other key +falls through to `/source-control:setup`'s inference (this repo's commit history is already Conventional-Commits-shaped) per config-resolution.md's per-key fallthrough, so this file deliberately does not restate them. The `babysit_loop_*` keys below are the other key family this file carries, and they are set explicitly. @@ -45,3 +46,7 @@ c2-mechanical ## babysit_loop_grace_window_minutes 30 + +## 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 0000000000..bf980737eb --- /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