Files
msd-core/hooks/gsd-validate-commit.sh
Tom Boucher ca8d9d4459 fix(#4429): stop a large commit_types config blocking or bypassing the gate (#4723)
* test(#4429): regression coverage for three defects in the commit hook

Failing-first coverage. Every conforming-subject row is red against the
unfixed hook, and each defect gets an explicit CONTROL row that reconstructs
the pre-fix form and asserts the defect reproduces -- without those, the
passing rows would pass with or without the fix.

1. SIGPIPE (the reported defect). The pre-fix first-line extraction used a
   `head -1` pipeline; once CONFIG_OUT exceeds the 64 KiB pipe buffer printf
   is killed and `set -euo pipefail` aborts the hook. That fix is already on
   next -- it landed incidentally in #4537, whose message never mentions
   #4429 -- and nothing in the tree would notice its removal.

2. regcomp. The commit-type alternation grew with the CONFIGURED list and
   exceeded bash's 64 KiB compiled-pattern cap. Boundary rows pin the cliff
   at 6051/6052, with controls on BOTH sides so limit-1 is not vacuous.

3. Ambient subprocess statuses (found by this change's security review).

Defects 1 and 2 cannot be separated: each configured type adds len+1 bytes to
CONFIG_OUT and len+1 to the alternation, so the smallest payload that
overflows the pipe (N=6059) already puts the alternation past the ceiling.

The SIGPIPE control accepts either SIGPIPE (141, Linux) or a reported write
error (macOS bash 3.2's builtin printf, exit 1). Asserting only the message
would go red on every CI lane, since the remote matrix is Linux-only.

Named to bucket with gsd-validate-commit-crash-policy.test.cjs, which covers
this same hook: lint-test-file-count derives a test's owning module from its
filename prefix, and `validate-commit-*` collided with the `validate` module,
already at its 2-file cap.

Harness note, learned from three vacuous control runs: hooks/lib/git-cmd.js
requires ../gsd-core/bin/lib/token-scanner.cjs relative to the hooks dir's
parent, so a copy in a bare tmpdir fails open and returns 0 for any input.
The layout symlinks gsd-core beside the copy, and every row that can prove it
asserts the run was substantive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4429): bound the commit-type regex and isolate subprocess statuses

Two fixes in the same file, both of the same shape: a value computed for one
purpose was being read as authority about something else.

1. The commit-type alternation could not be compiled.

COMMIT_TYPE_ALT joined every CONFIGURED type into one regex, so the pattern
grew without bound. bash caps a compiled pattern at 64 KiB. Bisected on bash
3.2.57 (this repo's macOS target): a 65504-byte alternation compiles, 65515
fails. `[[ =~ ]]` returns 2 on a compile failure, and `if !` cannot tell that
from "the subject does not conform" -- so the hook blocked a valid
`feat(auth): ...` with CONVENTIONAL_COMMITS_VIOLATION while printing `feat`
in its own valid_types.

Match the shape with a fixed-size pattern, capture the type, then test
membership against the COMMIT_TYPES array. The character class is exactly the
`^[a-z][a-z0-9-]*$` safe-token filter the config loader already applies, so it
captures every type that can legally reach COMMIT_TYPES and no token that
cannot. Review verified equivalence over 46 handcrafted plus 6000 randomized
adversarial subjects against a type list containing prefix-overlapping,
digit-bearing and trailing-hyphen types: zero divergences. The loop adds no
subprocess and no pipe, which is the hazard class #4429 is about.
COMMIT_TYPE_ALT is now unused and removed.

  types   pre-fix `feat(auth): ...`   fixed
  10      accept                      accept
  6051    accept                      accept
  6052    BLOCK                       accept
  20000   BLOCK                       accept

2. Subprocess statuses were inherited from the environment.

Each status is captured as `... || VAR=$?`, which assigns ONLY on the failure
branch; on success the variable kept whatever it already held, and
`${VAR:-0}` defaults only when unset or empty. So an EXPORTED CONFIG_STATUS,
CMD_STATUS or CLASSIFY_STATUS -- from a CI wrapper, a .envrc, or another hook
-- survived into the success path and was read as "the subprocess failed".
Since the hook fails OPEN on a genuine subprocess failure by design (#3838),
the result was a silent bypass. Measured: `CLASSIFY_STATUS=3 git commit -m
"nope: bad"` printed "validator disabled for this call" and exited 0.

The three are now initialised before use. The fail-open path is unchanged and
verified byte-identical to origin/next with a failing node.

hooks/dist/ is gitignored and rebuilt from hooks/ by scripts/build-hooks.js,
so there is no second copy to sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4429): register the new suite with the conformance manifests

Both conformance-tier manifests embed the test-file list, so adding a test
file makes them stale. Regenerated with their own generators:

  node scripts/gen-platform-conformance-tier.cjs --write
  node scripts/gen-platform-conformance-tier.cjs --target macos --write

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#4429): pin both fail-open-prone controls to their named cause

Two rows in the ambient-status block asserted `status === 0`, which the hook
also returns when the harness layout is broken -- so either row could have
passed for entirely the wrong reason. This is the same vacuity trap the rest
of the suite already guards, applied inconsistently to the rows added last.

Measured, rather than reasoned about:

  genuine ambient bypass (pre-fix hook, CLASSIFY_STATUS=3)  rc=0, no CLASSIFIER_THREW
  orphaned layout (no gsd-core symlink)                     rc=0, CLASSIFIER_THREW
  genuine fail-open (node shim exits 3)                     rc=0, no CLASSIFIER_THREW

So assertSubstantive separates the intended cause from the harness failure in
both rows, and each now pins its pass to the cause it names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#4429): stop asserting a macOS-only regex cap on every platform

First verification run was RED: 45636/45638 passed, both failures in this new
suite on linux-node24. Cause is mine -- I measured the compiled-pattern ceiling
on macOS and encoded it as a cross-platform expectation.

Measured in the tester image itself:

  engine                            6051      6052      20000
  bash 3.2.57 / BSD libc (macOS)    compiles  rc 2      rc 2
  bash 5.2.15 / glibc  (Linux)      compiles  compiles  compiles (228943 B)

glibc has no reachable cap, so the regcomp defect cannot occur there and the
control asserting a block at 6052 was red for a behaviour the platform cannot
produce.

The control now calibrates at runtime: it runs the pre-fix form and, when this
engine compiled the alternation, it SKIPS with a message naming the reason
rather than asserting. Skipped out loud, never silently passed -- a green row
there would read as "the defect is covered" on a platform where it cannot
occur. Both branches verified: the capped branch asserts (macOS 17/17, zero
skipped), and the uncapped branch was exercised by forcing the payload to a
size that always compiles, producing a skip and not a failure.

Consequence stated rather than hidden: the remote matrix is Linux-only, so this
one control is skipped in CI and really runs only on a macOS workstation. The
rows that run everywhere are the ones carrying the regression weight -- the
shipped hook accepting a conforming commit at every payload size, the gate
still blocking unknown types, the SIGPIPE control, and all seven ambient-status
rows.

Note this also narrows the coupling claim: SIGPIPE and regcomp are coupled only
on a capped engine. On glibc the SIGPIPE defect is directly testable without
the regcomp fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#4429): scope the regex-cap claim to the platform it applies to

The changeset told users the validator "built a regular expression bigger than
bash can compile" past ~6,000 configured types. That is false on Linux: glibc
compiled a 228,943-byte alternation without complaint, so a Linux reader would
have been misled about their own exposure. These are user-facing release notes,
so the claim is now scoped to macOS (bash 3.2 / BSD libc) and says explicitly
that glibc was never affected by this half.

The hook's own comment led with the same overstatement -- "bash caps a compiled
pattern at 64 KiB" -- before qualifying it. Reworded so the first clause states
what is actually true: the limit is a property of the platform's regex engine.

Text only; no behaviour change. Suite 17/17, eslint and lint:ci clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4429): backfill changeset PR number (#4723)

* chore(#4429): backfill changeset PR number (#4723)

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 05:43:59 -04:00

658 lines
39 KiB
Bash
Executable File

#!/usr/bin/env bash
# gsd-hook-version: {{GSD_VERSION}}
# gsd-validate-commit.sh — PreToolUse hook: enforce Conventional Commits format
# Blocks git commit commands with non-conforming messages (exit 2).
# Allows conforming messages and all non-commit commands (exit 0).
# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency).
#
# OPT-IN: This hook is a no-op unless config.json has hooks.community: true.
# Enable with: "hooks": { "community": true } in .planning/config.json
set -euo pipefail
# Temp files created below for subprocess stderr capture (config read, command
# extraction, classifier). A single EXIT trap replaces three hand-rolled
# mktemp/rm-f pairs so an early or unexpected exit path can never leak one —
# and a future fourth check does not need its own copy (#3911 review).
# Idempotent and failure-proof by construction: unset vars expand to "" (a
# no-op rm -f target), and `|| true` guarantees the trap itself never changes
# the script's exit status.
# Subprocess exit statuses, pre-initialised so they can never be inherited from
# the ambient environment. Each is captured as `... || VAR=$?`, which assigns
# ONLY on the failure branch; on success the variable keeps whatever it already
# held, and `${VAR:-0}` defaults only when unset or empty. So an EXPORTED
# CONFIG_STATUS / CMD_STATUS / CLASSIFY_STATUS — from a CI wrapper, a .envrc, or
# another hook — survived into the success path and was read as "the subprocess
# failed". Measured: `CLASSIFY_STATUS=3 git commit -m "nope: bad"` printed
# "validator disabled for this call" and exited 0, silently accepting a
# non-conforming commit. Same for CONFIG_STATUS and CMD_STATUS. Found by the
# security review of #4429; the gate is fail-open by design on a genuine
# subprocess failure (#3838), which is exactly what made this bypass quiet.
CONFIG_STATUS=0
CMD_STATUS=0
CLASSIFY_STATUS=0
ENABLED_ERR=""
CMD_ERR=""
CLASSIFY_ERR=""
cleanup_temp_files() {
rm -f "${ENABLED_ERR:-}" "${CMD_ERR:-}" "${CLASSIFY_ERR:-}" 2>/dev/null || true
}
trap cleanup_temp_files EXIT
# The 10 built-in Conventional Commits types — the SINGLE declaration (#3811
# review finding: this was previously hand-typed a second time inside the
# node -e script below, a generative-fix-divergence risk per CLAUDE.md's
# known-defect list). Threaded into node via an env var; reused directly by
# bash below when building COMMIT_TYPES.
BUILTIN_COMMIT_TYPES=(feat fix docs style refactor perf test build ci chore)
# Check opt-in config — exit silently if not enabled
if [ -f .planning/config.json ]; then
ENABLED_ERR=$(mktemp)
# Single node invocation reads BOTH hooks.community (line 1: '1'/'0') and
# hooks.commit_types (remaining lines: one sanitized extra type per line) —
# see #3811. Sanitizing here, not in bash, keeps the safe-token check in one
# place and guarantees only [a-z][a-z0-9-]* strings ever reach the regex
# built below, so a configured value can never alter the compiled pattern's
# structure.
BUILTIN_COMMIT_TYPES_CSV=$(IFS=,; echo "${BUILTIN_COMMIT_TYPES[*]}")
CONFIG_OUT=$(GSD_BUILTIN_COMMIT_TYPES="$BUILTIN_COMMIT_TYPES_CSV" node -e "
try{
const c=require('./.planning/config.json');
process.stdout.write(c.hooks?.community===true?'1':'0');
process.stdout.write('\n');
const raw=c.hooks?.commit_types;
const list=Array.isArray(raw)?raw:[];
const seen=new Set((process.env.GSD_BUILTIN_COMMIT_TYPES||'').split(',').filter(Boolean));
for (const t of list){
if (typeof t!=='string') continue;
if (!/^[a-z][a-z0-9-]*\$/.test(t)) continue;
if (seen.has(t)) continue;
seen.add(t);
process.stdout.write(t+'\n');
}
}catch(e){
process.stderr.write('CONFIG_READ_FAILED: '+(e&&e.message?e.message:String(e)));
process.exit(3);
}
" 2>"$ENABLED_ERR") || CONFIG_STATUS=$?
# Pre-initialised, NOT left to `${...:-0}` alone: the capture below only
# assigns on the `||` branch, so on SUCCESS the variable keeps whatever it
# already held — and an EXPORTED variable of this name is inherited from the
# ambient environment. `${VAR:-0}` defaults only when unset/empty, so
# `CONFIG_STATUS=3 git commit …` made this hook print "validator disabled" and exit 0,
# silently accepting a non-conforming commit. Found by review of #4429.
CONFIG_STATUS=${CONFIG_STATUS:-0}
if [ "$CONFIG_STATUS" != "0" ]; then
# Could not determine the opt-in flag at all (node missing, JSON parse
# error other than absence, etc.) — distinct from ".planning/config.json
# exists and legitimately disables the hook". Say so and pass, per #3838.
echo "gsd-validate-commit.sh: could not read .planning/config.json (opt-in check) — validator disabled for this call. $(cat "$ENABLED_ERR")" >&2
exit 0
fi
# Pure parameter expansion, not `printf ... | head -1`: same SIGPIPE race
# class as the SUBJECT extraction below (`echo "$MSG" | head -1`) — CONFIG_OUT
# is multi-line whenever extra commit types are configured, and `head -1`
# closing early can SIGPIPE `printf` under `set -euo pipefail`.
ENABLED="${CONFIG_OUT%%$'\n'*}"
if [ "$ENABLED" != "1" ]; then exit 0; fi
# Remaining lines (if any) are the sanitized, deduped configured commit
# types beyond the 10 built-ins (#3811). Read into a bash-3.2-safe array —
# `mapfile`/`readarray` are bash 4+ only and this hook is tested against
# bash 3.2.57 (macOS default).
EXTRA_COMMIT_TYPES=()
while IFS= read -r _extra_type; do
[ -n "$_extra_type" ] && EXTRA_COMMIT_TYPES+=("$_extra_type")
done < <(printf '%s\n' "$CONFIG_OUT" | tail -n +2)
else
exit 0
fi
INPUT=$(cat)
# Extract command from JSON using Node (handles escaping correctly, no jq needed)
CMD_ERR=$(mktemp)
CMD=$(echo "$INPUT" | node -e "
let d='';
process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
try{
process.stdout.write(JSON.parse(d).tool_input?.command||'');
}catch(e){
process.stderr.write('COMMAND_EXTRACTION_FAILED: '+(e&&e.message?e.message:String(e)));
process.exit(3);
}
});
" 2>"$CMD_ERR") || CMD_STATUS=$?
# Pre-initialised, NOT left to `${...:-0}` alone: the capture below only
# assigns on the `||` branch, so on SUCCESS the variable keeps whatever it
# already held — and an EXPORTED variable of this name is inherited from the
# ambient environment. `${VAR:-0}` defaults only when unset/empty, so
# `CMD_STATUS=3 git commit …` made this hook print "validator disabled" and exit 0,
# silently accepting a non-conforming commit. Found by review of #4429.
CMD_STATUS=${CMD_STATUS:-0}
if [ "$CMD_STATUS" != "0" ]; then
# Could not extract tool_input.command at all (node missing, malformed
# JSON, etc.) — distinct from "there is genuinely no command field". Say
# so and pass, per #3838.
echo "gsd-validate-commit.sh: could not extract tool_input.command from the hook payload — validator disabled for this call. $(cat "$CMD_ERR")" >&2
exit 0
fi
# Only check git commit commands.
# Delegates to hooks/lib/git-cmd.js isGitSubcommand() — the canonical token-walk
# classifier that handles env-prefix, -C path, and full-path git invocations.
# A naive `^git\s+commit` regex misses all three; this guard fixes that (#3129).
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
CLASSIFY_ERR=$(mktemp)
GIT_CMD_LIB="$HOOK_DIR/lib/git-cmd.js" node -e "
try {
const {isGitSubcommand}=require(process.env.GIT_CMD_LIB);
process.exit(isGitSubcommand(process.argv[1],'commit')?0:1);
} catch(e) {
process.stderr.write('CLASSIFIER_THREW: '+(e&&e.message?e.message:String(e)));
process.exit(3);
}
" "$CMD" 2>"$CLASSIFY_ERR" || CLASSIFY_STATUS=$?
# Pre-initialised, NOT left to `${...:-0}` alone: the capture below only
# assigns on the `||` branch, so on SUCCESS the variable keeps whatever it
# already held — and an EXPORTED variable of this name is inherited from the
# ambient environment. `${VAR:-0}` defaults only when unset/empty, so
# `CLASSIFY_STATUS=3 git commit …` made this hook print "validator disabled" and exit 0,
# silently accepting a non-conforming commit. Found by review of #4429.
CLASSIFY_STATUS=${CLASSIFY_STATUS:-0}
if [ "$CLASSIFY_STATUS" != "0" ] && [ "$CLASSIFY_STATUS" != "1" ]; then
# 0 = is a git commit (validate below); 1 = genuinely not a git commit
# (real negative, pass silently) — the ONLY intentional non-zero exit the
# script above ever produces on success. Any other status — 127 node
# missing, or 3 from the try/catch above when the git-cmd.js require chain
# throws (e.g. its built dependency, gsd-core/bin/lib/token-scanner.cjs, is
# a gitignored build artifact and absent on a fresh checkout — run
# `npm run build:lib`) — means the classifier could not run at all. Say so
# on stderr and pass (#3838): PreToolUse stderr does not disturb the JSON
# protocol.
echo "gsd-validate-commit.sh: could not classify the command via hooks/lib/git-cmd.js (exit $CLASSIFY_STATUS) — validator disabled for this call. If this persists, run \`npm run build:lib\`. $(cat "$CLASSIFY_ERR")" >&2
exit 0
fi
if [ "$CLASSIFY_STATUS" = "0" ]; then
# Extract message from -m flag.
#
# MSG_QUOTE records WHICH arm matched. bash treats the two arms differently
# and the subject step below depends on that difference — see the resolver
# gate (review of #3816, round 4).
MSG=""
MSG_QUOTE=""
MSG_MATCH=""
if [[ "$CMD" =~ -m[[:space:]]+\"([^\"]+)\" ]]; then
MSG="${BASH_REMATCH[1]}"
MSG_QUOTE=dq
MSG_MATCH="${BASH_REMATCH[0]}"
elif [[ "$CMD" =~ -m[[:space:]]+\'([^\']+)\' ]]; then
MSG="${BASH_REMATCH[1]}"
MSG_QUOTE=sq
MSG_MATCH="${BASH_REMATCH[0]}"
fi
if [ -n "$MSG" ]; then
# Subject = first line of the message, EXCEPT for the command-substituted
# heredoc form, where the first line is the opener rather than the message:
#
# git commit -m "$(cat <<'EOF'
# feat(auth): add login flow
# EOF
# )"
#
# The capture above spans it whole, because bash `[^"]` matches newlines, so
# `head -1` yielded the literal `$(cat <<'EOF'` and EVERY heredoc-form commit
# was blocked regardless of its message (#3802).
#
# Selection of WHICH argument is the message is unchanged above — only the
# subject-from-message step is delegated. Falls back to the previous `head -1`
# if node or the library is unavailable, so a broken extractor degrades to the
# old behavior instead of becoming a new silent-allow path.
#
# SINGLE-QUOTE GATE (review of #3816, round 4 — BLOCKER). The resolver may
# only run on the DOUBLE-quoted arm. Inside `-m '...'` bash performs NO
# command substitution, so `$(cat <<'EOF'` is literal text and git's real
# subject is that opener line — resolving the body there validates a
# message git never receives. Measured against the real hook, all four
# spellings (`<<'E'`, `<<"E"`, `<<\E`, `<<E`) went base=2 -> head=0: a
# net-new bypass reachable by the ordinary authoring slip of typing `'`
# for `"`. The sq arm therefore keeps the pre-fix `head -1`, which is exact
# base parity.
#
# ADJACENCY GUARD (review of #3816): text glued to the CLOSING quote —
# `-m "$(cat <<'EOF' ... )"suffix` — is concatenated by bash into the SAME
# argument, so the capture above holds only a PREFIX of the real message.
# Resolving a heredoc from a prefix hands the length gate a fraction of the
# real subject: a net-new bypass relative to base, which measured the
# opener line and blocked. When the quote is not followed by whitespace or
# the end of the command, skip the resolver and keep the pre-fix subject
# (first captured line): the heredoc form then fails the format gate
# exactly as it did on base, and the plain single-line form keeps base
# behavior unchanged. The guard is tested against the arm that MATCHED,
# not against both: testing both let a double-quoted heredoc whose BODY
# mentions a glued single-quoted token (`-m "... -m 'foo'bar ..."`) trip
# the sq arm and lose the fix for a message that never had a prefix
# problem (review of #3816, round 4, Minor 1).
# RESOLVER PRECONDITIONS. The resolver may run only where the captured text
# is provably the subject git receives. Each guard names an input where it
# is not; every refusal falls back to `head -1`, the pre-fix subject, which
# fails the format gate exactly as this whole form did before the fix.
RESOLVE=0
if [ "$MSG_QUOTE" = dq ]; then
RESOLVE=1
# Text before the message we matched. The heredoc BODY always sits after
# the match, so this window cannot be contaminated by message content —
# which is what lets the two guards below scan for tokens that would also
# be legal inside a commit message.
MSG_PREFIX="${CMD%%"$MSG_MATCH"*}"
# Text after it. Together, PREFIX and SUFFIX are the whole command MINUS
# the message — the window a guard must use when the token it scans for
# is also legal English inside a commit message, but may legally appear
# on EITHER side of the message on the command line.
# Indexed, not searched (#4492). `${CMD#*"$MSG_MATCH"}` is quadratic in
# the message: bash walks every prefix length and compares the whole
# matched literal at each one, and MSG_MATCH is BASH_REMATCH[0] — the
# entire `-m "..."` — so the cost grows with the thing being scanned.
# Measured on the path EVERY commit takes (conforming and non-conforming
# cost the same): 10.0 s at a 64 KB message, 22.0 s at 96 KB, 30.2 s at
# 112 KB. Sizes stop there deliberately — a single argument above Linux's
# MAX_ARG_STRLEN (131072 on a 4 KB-page kernel) never reaches this code
# at all, because execve fails and the hook fails open, so a larger
# "measurement" would be timing the wrong thing.
#
# MSG_PREFIX above has already located the match, so the suffix is
# arithmetic rather than a search: skip the prefix and the match. This
# removes the quadratic SEARCH; the expansion still counts characters and
# materialises a substring, so it is linear in the command, not O(1).
# Same first-occurrence assumption both expansions here always made —
# MSG_MATCH is a literal substring of CMD by construction.
MSG_SUFFIX="${CMD:$(( ${#MSG_PREFIX} + ${#MSG_MATCH} ))}"
# LINE CONTINUATIONS ARE NOT SEPARATORS (review of #3816, rounds 8 and 9).
# `git commit \` newline ` -m "$(cat <<'EOF' …` is an ordinary way to
# spread an invocation over lines, and every guard below reads a newline in
# a window as a command separator, so the whole form was refused. That was
# disclosed as a fail-closed limit in round 8 because "is this newline a
# continuation" looked like the segmentation question this file has
# reverted twice. It is not: bash's rule is local and character-level. A
# newline preceded by an ODD run of backslashes is a continuation and bash
# removes both; an EVEN run (`\\` then newline) is a literal backslash
# followed by a real newline, which IS a separator. So the windows are
# joined the way bash joins them, in three bash-3.2-safe steps: every `\\`
# pair is parked on \x01, a byte no real command line carries, any
# backslash-newline that remains is a lone (odd) one and is removed, then
# the pairs are restored. Applied to BOTH windows, BEFORE the dequote
# copies are derived, so every scan sees the joined text.
#
# KNOWN OVER-BLOCK, fail-closed: a literal \x01 that IS present in the
# command is restored as `\\`, so an option-shaped token carrying one
# (`-\x01m`) reads as `-\\m`, dequotes to `-m`, and refuses where it did
# not before (independent review, round 9). Refusing is the recoverable
# direction; a control byte in an option name is not a spelling anyone
# types, and it is not a hole in the accept direction.
#
# Direction check: a continuation glued to the closing quote
# (`"$(…)"\` newline `suffix`) joins to `"$(…)"suffix`, which the glue
# guard refuses exactly as bash would have glued it; `\\` + newline keeps
# its newline and is still refused by the separator guard. Measured on
# bash 3.2.57 and 5.3.15 in tests/hooks-opt-in.test.cjs.
CONT_PARK=$'\x01'
MSG_PREFIX="${MSG_PREFIX//\\\\/$CONT_PARK}"
MSG_PREFIX="${MSG_PREFIX//\\$'\n'/}"
MSG_PREFIX="${MSG_PREFIX//$CONT_PARK/\\\\}"
MSG_SUFFIX="${MSG_SUFFIX//\\\\/$CONT_PARK}"
MSG_SUFFIX="${MSG_SUFFIX//\\$'\n'/}"
MSG_SUFFIX="${MSG_SUFFIX//$CONT_PARK/\\\\}"
# QUOTE-SPLICED SPELLINGS (independent review of #3816, round 6). Bash
# removes quotes before git ever sees an argument, so the same option has
# unboundedly many spellings on the command line: `--clean""up=verbatim`
# IS `--cleanup=verbatim` to git, and `-""m` IS `-m`. Both matched no
# literal and were measured ACCEPTING a 75-byte subject the length gate
# had recorded as 72. The guards below therefore scan a copy of their
# window with quote characters removed, which is what bash does to it.
# Only the two OPTION-NAME scans use it; the adjacency test deliberately
# does not, because it asks about a literal character position, and the
# message span itself is excluded from both windows either way.
MSG_PREFIX_DEQ="${MSG_PREFIX//[\"\']/}"
MSG_SUFFIX_DEQ="${MSG_SUFFIX//[\"\']/}"
# BACKSLASH-SPLICED SPELLINGS (independent review of #3816, round 7).
# Quote removal alone was not "the command as bash hands it to git": bash
# also removes syntactic backslashes, so `-\m WIP` IS `-m WIP` and
# `--clean\up=verbatim` IS `--cleanup=verbatim` to git, and both matched
# no literal. Measured: `-\m WIP -m <conforming heredoc>` accepted the
# heredoc while git recorded `WIP`, and a trailing `--clean\up=verbatim`
# accepted a 75-byte subject the length gate measured as 72. Stripped in a
# second pass so the class is unambiguous.
MSG_PREFIX_DEQ="${MSG_PREFIX_DEQ//\\/}"
MSG_SUFFIX_DEQ="${MSG_SUFFIX_DEQ//\\/}"
# DOLLAR-QUOTED SPELLINGS (independent review of #3816, round 8). The two
# passes above still were not "the command as bash hands it to git": bash
# has TWO more quoting forms whose introducer is a `$`, and removing the
# quote characters alone leaves that `$` stranded in the middle of the
# option name. `-$"m"` became `-$m` here while bash passes a real `-m` to
# git, and `--mes$'sage'=WIP` became `--mes$sage=WIP`; neither matched any
# literal, so the first-message guard below never fired. Measured on bash
# 3.2.57 and 5.3.15 against a real repository: the hook allowed
# `-$"m" WIP -m <conforming heredoc>` (exit 0) while `git cat-file -p`
# recorded the subject `WIP` — the same command spelled `-m WIP` is
# refused (exit 2). Stripping `$` closes both dollar-quote forms.
#
# RESIDUAL, and not fixable from a string: an option name assembled by an
# EXPANSION — `-${x}m`, `-$(printf m)` — is not knowable without running
# the command, the same limit this file already documents for expanded
# heredoc bodies. Stripping `$` makes those spellings collapse toward the
# literal too, which over-matches, and over-matching only refuses more.
MSG_PREFIX_DEQ="${MSG_PREFIX_DEQ//\$/}"
MSG_SUFFIX_DEQ="${MSG_SUFFIX_DEQ//\$/}"
# ADJACENCY GUARD (review of #3816): text glued to the CLOSING quote —
# `-m "$(cat <<'EOF' ... )"suffix` — is concatenated by bash into the SAME
# argument, so the capture holds only a PREFIX of the real message, and
# the length gate would measure a fraction of the real subject.
# SCOPE (review of #3816, round 6 — MAJOR). Glue is a property of the ONE
# character following the MATCHED span, so that character is the whole
# window. Scanning $CMD for the shape anywhere refused any conforming
# commit whose command merely CONTAINED a glued `-m` elsewhere —
# `git commit -m "<heredoc>" && echo -m "test"z` stayed blocked with
# CONVENTIONAL_COMMITS_VIOLATION. Base blocks it too, because base blocks
# EVERY heredoc form (that is #3802): this was the fix not reaching the
# shape, measured base=2 -> pre=2 -> post=0, not a regression.
# The separators and redirections are excluded because bash does NOT
# concatenate across them: in `-m "msg"&& echo hi` the argument ends at
# the quote, so there is no truncated capture to defend against.
# The class is held in a VARIABLE, not written inline. Inline, every
# member needs a backslash to get past the `[[ ]]` parser (`;`, `&` and
# `|` are metacharacters there) — and on bash 3.2, the system /bin/bash on
# macOS, those backslashes are passed THROUGH to the regex engine instead
# of being consumed by the shell, silently adding a literal `\` to the
# class. Unquoted expansion of a variable on the right of `=~` is the one
# spelling that is a plain regex on 3.2 and 5.x alike (review of #3816,
# round 8). Writing `[^[:space:];&|()<>]` inline is NOT the fix: it is a
# bash syntax error on both versions.
GLUE_CLASS='^[^[:space:];&|()<>]'
if [[ "$MSG_SUFFIX" =~ $GLUE_CLASS ]]; then RESOLVE=0; fi
# FIRST-MESSAGE GUARD (Codex review of #3816, round 4 — BLOCKER). The
# capture is a SEARCH over the whole command and the double-quoted arm is
# tried first, so it can select a `-m` that is not git's subject at all:
#
# git commit -m 'WIP first' -m "$(cat <<'EOF' -> git concatenates; the
# git commit -m WIP -m "$(cat <<'EOF' subject is `WIP first`
# git commit -m WIP -- -m "$(cat <<'EOF' -> after --, not a message
# git commit -m WIP && echo -m "$(cat <<'EOF' -> belongs to `echo`
#
# All four measured base=2 -> head=0, with git recording the FIRST message
# as the subject (verified against real commits, not the man page). The
# mis-selection is pre-existing; resolving it is what turned it into an
# enforcement bypass. Resolve only when nothing before the match could
# have been an earlier message, an end-of-options marker, or another
# command.
# BUNDLED SHORT OPTIONS (independent review of #3816, round 6). git splits
# `-am 'WIP first'` into `-a -m`, so the real subject is `WIP first` and
# the heredoc is git's SECOND message — measured accepting the heredoc's
# subject while git recorded `WIP first`. A standalone `-m` is therefore
# not the only spelling that claims the message; any short-option cluster
# ending in `m` does.
# ATTACHED VALUES AND --message ABBREVIATIONS (independent review of
# #3816, round 7). The scan required a space or `=` after the option name,
# so two spellings git accepts matched nothing: an ATTACHED short-option
# value (`-mWIP`, which git reads as `-m WIP`) and a long-option
# abbreviation (`--mes=WIP`), the same abbreviation behaviour this file
# already models for `--cleanup`. Both were measured accepting a later
# conforming heredoc while git recorded `WIP` as the subject — confirmed
# against the raw commit object, not `git log --pretty=%s`. The short arm
# therefore drops its trailing requirement entirely: a `-` followed by
# letters ending in `m` claims the message however it is spelled. Wider
# than git's own abbreviation set on purpose — over-matching only refuses
# more, which is the recoverable direction.
# Variable-held for the same bash-3.2 reason as GLUE_CLASS above.
# AN OPTION NAME BUILT BY A COMMAND SUBSTITUTION IS UNRESOLVABLE
# (independent review of #3816, round 8). Stripping `$` above collapses the
# two dollar-QUOTE forms onto their literals, but `--clean$(printf up)=`
# is a different thing: bash RUNS a program to finish the option name, so
# the argv git receives is not derivable from this string at all. Measured
# accepting a 75-byte subject the length gate had recorded as 72.
#
# SCOPED TO THE NAME, NOT THE VALUE. The class is a `-`-leading token whose
# characters up to the substitution contain no `=` — an option NAME being
# assembled. `--author="$(git config user.name)"` and `--author "$(…)"`
# both put the substitution in the VALUE, which this file never models and
# which stays allowed; only `-…$(` before any `=` refuses. Scanned on the
# RAW windows on purpose: the dequoted copies have had their `$` removed,
# so the shape is no longer visible there.
#
# This is a SHAPE, not a segmentation: it never tries to decide where
# git's own command ends. Two attempts at that were reverted for opening
# accept-direction holes, and the reasoning above still stands.
# WIDENED, and the strategy changed with it (independent review, round 9).
# The `$(`-only spelling above was the fourth patch in a row that tried to
# EMULATE what bash does to an argument before git sees it -- round 6
# removed quotes, round 7 backslashes, round 8 the `$` of a dollar-quote,
# and each time review found another transform that had been missed. Round
# 9 found four more, all measured accepting `WIP` as the real subject on
# bash 3.2.57 and 5.3.15 while the plain spelling of the same command is
# refused:
#
# -$'\155' WIP ANSI-C octal escape decodes to `m`
# -$'\x6d' WIP ANSI-C hex escape decodes to `m`
# -`printf m` WIP command substitution, backtick spelling
# x= … -${x}m WIP parameter expansion
# -? WIP pathname expansion, with a file named `-m`
#
# The last two settle the strategy: an option name finished by a PARAMETER
# expansion depends on a variable's runtime value, and one finished by a
# PATHNAME expansion depends on the contents of the working directory.
# Neither is derivable from the command string at any level of effort, so
# emulation cannot be completed -- not "has not been completed yet".
#
# So the rule is no longer "normalise it and match the literal". It is: an
# option NAME containing a shell expansion or quoting construct is
# UNRESOLVABLE, and unresolvable refuses. One rule covers every spelling
# above, and every spelling nobody has thought of yet, in the fail-closed
# direction. The dequoting passes above are kept: they still normalise the
# deterministic removals so the guards RECOGNISE `--clean""up=` and `-\m`
# rather than merely refusing them, which keeps the existing rows honest.
#
# SCOPED TO THE NAME, NOT THE VALUE, exactly as before: the class is a
# `-`-leading token whose characters up to the substitution contain no `=`.
# `--author="$(git config user.name)"` and `--author "$(…)"` put the
# construct in the VALUE and still resolve, pinned in both directions.
# Scanned on the RAW windows, because the dequoted copies have had `$` and
# the quote characters removed and the shape is no longer visible there.
#
# The class is bracket-only and holds no backslash, per round 8: a POSIX
# bracket expression has no escape mechanism, and a backslash written
# inside one becomes a literal member on bash 3.2.
SUBST_NAME_CLASS='(^|[[:space:]])-[^[:space:]=]*[$`?*[]'
if [[ "$MSG_PREFIX" =~ $SUBST_NAME_CLASS ]] \
|| [[ "$MSG_SUFFIX" =~ $SUBST_NAME_CLASS ]]; then RESOLVE=0; fi
SEP_CLASS='[;&|]'
if [[ "$MSG_PREFIX_DEQ" =~ (^|[[:space:]])(-[a-zA-Z]*m|--m[a-z]*([=[:space:]]|$)) ]] \
|| [[ "$MSG_PREFIX" =~ (^|[[:space:]])--([[:space:]]|$) ]] \
|| [[ "$MSG_PREFIX" =~ $SEP_CLASS ]] \
|| [[ "$MSG_PREFIX" == *$'\n'* ]]; then RESOLVE=0; fi
# NEWLINE IS A COMMAND SEPARATOR TOO (independent review of #3816, round
# 7) — the test above. The separator scan covered `;`, `&` and `|` but not
# a literal newline, so a LATER command's heredoc-shaped `-m` was taken
# for this commit's message:
#
# git commit --amend --no-edit
# echo -m "$(cat <<'EOF'
# fix: conforming text unrelated to the commit
# EOF
# )"
#
# The classifier recognises the leading commit, the capture reaches across
# the newline into `echo`'s argument, and a conforming string with no
# relationship to the commit was validated and allowed. Tested as a glob
# rather than folded into the bracket class, because a literal newline
# inside a bash regex bracket expression is not portably expressible.
# CLEANUP-MODE GUARD (Codex review of #3816, round 4 — BLOCKER). The
# resolver skips leading blank lines and strips trailing whitespace
# because git's DEFAULT cleanup=whitespace does. Under
# `--cleanup=verbatim` git does neither, so a 72-char subject plus three
# trailing spaces is committed as a 75-byte subject while the hook
# measured 72 — COMMIT_SUBJECT_TOO_LONG dodged (measured base=2 -> head=0;
# confirmed by reading the raw commit object, since `git log --pretty=%s`
# strips trailing whitespace in its own output and hides it).
# Any named mode other than `whitespace` refuses. A mode set persistently
# in git config is invisible here and stays a documented residual limit.
# SCOPE (review of #3816, round 5 — BLOCKER). This scan must exclude the
# message. `--cleanup=` and `commit.cleanup=` are ordinary English inside
# a commit message — this repository's own hooks and docs discuss them
# constantly — and the heredoc BODY sits verbatim inside $CMD, so
# scanning $CMD refused to resolve any conforming message that merely
# MENTIONED the token, blocking it with CONVENTIONAL_COMMITS_VIOLATION.
# Scanning $MSG_PREFIX alone (the fix as first prescribed) would reopen
# the bypass this guard exists for: git accepts the flag on either side
# of -m, and `git commit -m "<heredoc>" --cleanup=verbatim` is caught
# today only because the scan is command-wide. PREFIX + SUFFIX keeps both
# positions covered while excluding the one span that is message text.
# The two are joined with a space so a token cannot be forged across the
# seam out of a prefix tail and a suffix head.
# KNOWN LIMIT, deliberately fail-closed (#3816, round 6). This window is
# the whole command minus the message, so a `--cleanup=` that belongs to a
# DIFFERENT command — `git commit -m "<heredoc>" && echo --cleanup=verbatim`
# — also refuses, and a conforming commit git would accept stays blocked.
# Narrowing it to git's own segment was tried and reverted: deciding where
# git's command ends needs a shell parse, and a substring scan is not one.
# Trimming at the first `;&|` cut the window short whenever a separator sat
# inside an ordinary argument — `--author "a&b"`, and equally `--author
# a\&b` — which hid a REAL trailing `--cleanup=verbatim` and ACCEPTED a
# 75-byte subject the length gate had measured as 72. Two successive
# narrowings each reopened that hole on a shape the previous one missed, so
# the scan stays wide: refusing a commit git would take is recoverable,
# accepting an over-long subject is not.
# ABBREVIATIONS (independent review of #3816, round 6). git accepts any
# unambiguous prefix of a long option, so `--cle=verbatim` sets the mode
# while matching no literal `--cleanup` — measured accepting a 75-byte
# subject recorded as 72. The class is deliberately wider than git's own
# abbreviation set: over-matching only refuses more, which is the safe
# direction, and no other `--cl` option exists for git commit.
# LAST DIRECTIVE WINS, AND ONE MATCH CANNOT SEE IT (independent review of
# #3816, round 7). A bash regex yields ONE BASH_REMATCH, so only the
# FIRST cleanup directive was inspected — and git applies the LAST one.
# `--cleanup=whitespace -m <heredoc> --cleanup=verbatim` therefore read as
# mode=whitespace, resolution stayed enabled, and a 72-character subject
# plus trailing spaces was accepted while git recorded 75 bytes with the
# whitespace preserved (confirmed against the raw commit object). Deciding
# WHICH directive is last needs an argv order this substring scan does not
# have, so multiplicity itself refuses: more than one directive is
# unresolvable, not "probably fine". Single-directive behaviour is
# unchanged.
CLEANUP_WINDOW="$MSG_PREFIX_DEQ $MSG_SUFFIX_DEQ"
# `|| true` is load-bearing: this script runs under `set -euo pipefail`,
# and grep exits 1 when it matches NOTHING — which is the common case, a
# command with no cleanup directive at all. Without it the pipeline's
# non-zero status killed the hook outright (exit 1, no verdict) for every
# ordinary commit. Caught by running the real hook rather than the scan.
CLEANUP_HITS=$( { printf '%s' "$CLEANUP_WINDOW" | grep -oE '(--cl[a-z]*|commit\.cleanup)[=[:space:]]+[^[:space:]]+' || true; } | wc -l | tr -d ' ')
if [ "${CLEANUP_HITS:-0}" -gt 1 ]; then
RESOLVE=0
elif [[ "$CLEANUP_WINDOW" =~ (--cl[a-z]*|commit\.cleanup)[=[:space:]]+([^[:space:]]+) ]]; then
if [ "${BASH_REMATCH[2]}" != "whitespace" ]; then RESOLVE=0; fi
fi
# GIT-GENERATED SUBJECTS (independent review of #3816, round 7). With
# `--squash=<commit>` or `--fixup=<commit>` git composes the subject
# itself — measured recording `squash! base: something` while a conforming
# heredoc supplied via -m sailed through. The supplied message is not the
# subject in these modes at all, so there is nothing here worth measuring
# and resolution is refused outright. Abbreviations included for the same
# reason as --cleanup's. Deliberately NOT extended to the other
# message-SOURCE options (-C/--reuse-message, -c/--reedit-message,
# -F/--file, -t/--template): `-c` is also a git GLOBAL option that legally
# precedes the subcommand, so a scan for it would refuse ordinary
# `git -c k=v commit` invocations. Those remain a disclosed gap rather
# than a guessed guard.
if [[ "$MSG_PREFIX_DEQ $MSG_SUFFIX_DEQ" =~ (^|[[:space:]])--(squash|fixup|sq[a-z]*|fix[a-z]*)[=[:space:]] ]]; then RESOLVE=0; fi
fi
if [ "$RESOLVE" = 1 ]; then
SUBJECT=$(GIT_CMD_LIB="$HOOK_DIR/lib/git-cmd.js" MSG="$MSG" node -e "
const {resolveCommitSubject}=require(process.env.GIT_CMD_LIB);
process.stdout.write(resolveCommitSubject(process.env.MSG));
" 2>/dev/null) || SUBJECT="${MSG%%$'\n'*}"
else
# Pure parameter expansion, not `echo "$MSG" | head -1`: that pipeline
# raced a SIGPIPE under `set -euo pipefail` whenever $MSG had a body
# (the common case) — `head -1` can close its read end as soon as it
# has the first line, and if `echo`'s write lands after that close,
# `echo` dies with signal 13 (exit 141), which is NOT suppressed by
# `set -e` and aborted the whole hook intermittently (observed in
# tests/hooks-opt-in.test.cjs's --fixup=HEAD "round 7" case). Zero
# subprocesses here means zero pipe/race surface. Equivalent to
# `head -1` for single-line, multi-line, and trailing-newline input.
SUBJECT="${MSG%%$'\n'*}"
fi
# Single source of truth for the accepted commit-type list (#3811): the
# 10 built-ins plus whatever passed the safe-token filter above. Both the
# membership test and the human-readable error text below are derived
# from this ONE array — no hand-synced second copy.
#
# The `"${EXTRA_COMMIT_TYPES[@]+"${EXTRA_COMMIT_TYPES[@]}"}"` form (not
# plain `"${EXTRA_COMMIT_TYPES[@]}"`) is required: on bash 3.2.57 (this
# repo's macOS test target), expanding `[@]` on an array that is declared
# but has zero elements throws "unbound variable" under `set -u` (which
# this script has via `set -euo pipefail`). Verified directly against
# /bin/bash 3.2.57 on macOS. The `${arr[@]+word}` form is the
# nounset-safe idiom for "expand if set, empty otherwise" on empty arrays.
COMMIT_TYPES=("${BUILTIN_COMMIT_TYPES[@]}" "${EXTRA_COMMIT_TYPES[@]+"${EXTRA_COMMIT_TYPES[@]}"}")
COMMIT_TYPE_LIST=$(printf '%s, ' "${COMMIT_TYPES[@]}")
COMMIT_TYPE_LIST="${COMMIT_TYPE_LIST%, }"
# Typed `valid_types` array (#3811 review finding): CONTRIBUTING.md bans
# substring/prose matching on `reason` in tests — a test needing to
# verify the accepted-type set must have a typed field, not grep prose.
# Safe to build with a bare printf (no JSON-escaping needed): every
# element of COMMIT_TYPES has already passed the `^[a-z][a-z0-9-]*$`
# safe-token filter (or is a literal built-in), so none can contain `"`
# or `\`.
COMMIT_TYPES_JSON=$(printf '"%s",' "${COMMIT_TYPES[@]}")
COMMIT_TYPES_JSON="[${COMMIT_TYPES_JSON%,}]"
# Validate Conventional Commits format.
#
# #4429: do NOT build `^(type1|type2|...)` out of COMMIT_TYPES. That
# alternation grows with the CONFIGURED list, and how large a pattern can be
# compiled is a property of the platform's regex engine. bash 3.2.57 / BSD
# libc (macOS, this file's stated target) caps it at 64 KiB - bisected: a
# 65504-byte alternation compiles, 65515 fails. bash 5.2 / glibc has no
# reachable cap, so this half never bit Linux. Past a cap `[[ =~ ]]`
# returns 2, and `if !` cannot tell a COMPILE ERROR from "the subject does
# not conform" - so a valid `feat(auth): ...` was blocked with
# CONVENTIONAL_COMMITS_VIOLATION while `feat` sat in its own valid_types.
#
# Match the SHAPE with a fixed-size pattern, then test membership against
# the array. The regex no longer depends on how many types are configured,
# and the loop adds no subprocess or pipe (the #4429 hazard this file
# already avoids elsewhere). The character class is exactly the safe-token
# filter `^[a-z][a-z0-9-]*$` applied above, so it captures every type that
# can legally reach COMMIT_TYPES and no token that cannot.
SUBJECT_TYPE=''
if [[ "$SUBJECT" =~ ^([a-z][a-z0-9-]*)(\(.+\))?:[[:space:]].+ ]]; then
SUBJECT_TYPE="${BASH_REMATCH[1]}"
fi
COMMIT_TYPE_OK=0
if [ -n "$SUBJECT_TYPE" ]; then
for _known_type in "${COMMIT_TYPES[@]}"; do
if [ "$_known_type" = "$SUBJECT_TYPE" ]; then COMMIT_TYPE_OK=1; break; fi
done
fi
if [ "$COMMIT_TYPE_OK" -ne 1 ]; then
# Emit typed `code` and `valid_types` fields alongside `reason` (#2974,
# #3811). Tests assert on the stable code string and the typed array;
# the reason is the human-readable copy, never grepped by tests.
echo "{\"decision\": \"block\", \"code\": \"CONVENTIONAL_COMMITS_VIOLATION\", \"valid_types\": $COMMIT_TYPES_JSON, \"reason\": \"Commit message must follow Conventional Commits: <type>(<scope>): <subject>. Valid types: $COMMIT_TYPE_LIST. Subject must be <=72 chars, lowercase, imperative mood, no trailing period.\"}"
exit 2
fi
if [ ${#SUBJECT} -gt 72 ]; then
echo '{"decision": "block", "code": "COMMIT_SUBJECT_TOO_LONG", "reason": "Commit subject must be 72 characters or less."}'
exit 2
fi
fi
fi
exit 0