'use strict'; /** * git-cmd.js — token-walk git command classifier. * * Determines whether a shell command string invokes a specific git * subcommand. Handles the four forms that a naive `^git\s+commit` regex * misses: * * bare: git commit -m "..." ✓ * -C path: git -C /some/path commit -m "..." ✓ (missed by regex) * env-prefix: GIT_AUTHOR_NAME=x git commit "..." ✓ (missed by regex) * full-path: /usr/bin/git commit -m "..." ✓ (missed by regex) * * This module is the single source of truth for git-commit detection so all * hooks that need to gate on git commits share one implementation. * * Exported by the hooks/lib/ directory — require via a path relative to the * hook's own __dirname: * * const { isGitSubcommand } = require(path.join(__dirname, 'lib', 'git-cmd.js')); * * `tokenize()` delegates to the shared `src/token-scanner.cts` seam (ADR-3212 * §4, epic #3212 Phase 3, #3414) — the built `gsd-core/bin/lib/token-scanner.cjs` * artifact, not a sibling hooks/-tree file, because hook scripts are staged as * standalone files at install time and a sibling require is a staging * dependency that can fail silently (see gsd-workflow-guard.js's own * KIMI_TOOL_NAMES comment for the precedent this follows). Re-exported here * unchanged — every existing caller's behavior is identical (parity-asserted * in tests/token-scanner.test.cjs row 5). */ const path = require('path'); const { tokenizeShellLike } = require(path.join(__dirname, '..', '..', 'gsd-core', 'bin', 'lib', 'token-scanner.cjs')); /** * Git global options that take a following argument. * These must be consumed as (option, argument) pairs when walking tokens. */ const ARGUMENT_TAKING_FLAGS = new Set([ '-C', // working directory '-c', // config override (separate-arg form: `git -c k=v …`; #3504) '--git-dir', // path to git repository '--work-tree', // path to working tree '--namespace', // git namespace '--super-prefix', // superproject-relative prefix '--exec-path', // path to core git programs (when given an arg) '--html-path', '--man-path', '--info-path', '--list-cmds', ]); /** * Git global flags that consume no extra argument. */ const BOOLEAN_FLAGS = new Set([ '-p', '--paginate', '--no-pager', '--no-replace-objects', '--bare', '--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs', '--icase-pathspecs', '--no-optional-locks', '-P', '--no-lazy-fetch', '--version', '--help', ]); /** * Tokenize a shell command string. * Handles single-quoted strings, double-quoted strings, and unquoted tokens. * Does NOT perform variable expansion or brace expansion. * * Delegates to the shared `src/token-scanner.cts` seam — see the module * header comment for why the built artifact, not a sibling require, is used. * * @param {string} cmd * @returns {string[]} */ function tokenize(cmd) { return tokenizeShellLike(cmd); } /** * Walk past leading env-prefix assignments and global git options, same as * `isGitSubcommand`'s phases 1-3. Returns the index of the subcommand token, * or -1 if the command does not resolve to a git invocation at all. * * @param {string[]} tokens * @returns {number} */ function skipToSubcommand(tokens) { let i = 0; while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) { i++; } if (i >= tokens.length) return -1; const gitToken = tokens[i++]; if (path.basename(gitToken) !== 'git') return -1; while (i < tokens.length) { const t = tokens[i]; const eqIdx = t.indexOf('='); const flagName = eqIdx !== -1 ? t.slice(0, eqIdx) : t; if (ARGUMENT_TAKING_FLAGS.has(flagName)) { i += eqIdx !== -1 ? 1 : 2; continue; } // #3504: glued `-ckey=value` form — git accepts the config override with // its argument attached (`git -cfoo.bar=1 …`). The eq-slice above yields // flagName `-cfoo`, which no set contains, so without this arm the walk // stops and the whole invocation is misclassified as not-git. if (/^-c\S*=/.test(t)) { i++; continue; } if (BOOLEAN_FLAGS.has(t)) { i++; continue; } break; } return i; } /** * Extract the branch-name argument from a git command line that creates or * references one — `git checkout -b ` or `git branch `. Returns * null for any other command, including plain `git checkout ` (switches * branches, does not create one) and commands where a checkout/branch-shaped * substring appears only inside a quoted argument (e.g. a commit message). * * New capability (ADR-3212 §4, epic #3212 Phase 3, #3414) exercising the * shared scanner on the domain the ADR names ("a branch name... [is] not * regular") — not a migration of existing duplicated logic; no prior * implementation of this existed in the repo (design doc §1.2). * * @param {string} cmd * @returns {string | null} */ function extractBranchArgument(cmd) { if (!cmd) return null; const tokens = tokenizeShellLike(cmd); const subIdx = skipToSubcommand(tokens); if (subIdx === -1 || subIdx >= tokens.length) return null; const sub = tokens[subIdx]; if (sub === 'checkout') { for (let j = subIdx + 1; j < tokens.length; j++) { if (tokens[j] === '-b' && j + 1 < tokens.length) return tokens[j + 1]; } return null; } if (sub === 'branch') { for (let j = subIdx + 1; j < tokens.length; j++) { if (!tokens[j].startsWith('-')) return tokens[j]; } return null; } return null; } /** * Return true if `cmd` invokes the git subcommand `sub`. * * @param {string} cmd - Full shell command string (may include env vars, full paths) * @param {string} sub - Subcommand to test for, e.g. 'commit' * @returns {boolean} */ function isGitSubcommand(cmd, sub) { if (!cmd || !sub) return false; // Phases 1-3 (env-prefix skip, git-executable check, global-option consume) // extracted verbatim into skipToSubcommand — byte-identical logic, shared // with extractBranchArgument rather than a second copy (#3212 Phase 3). const tokens = tokenizeShellLike(cmd); const subIdx = skipToSubcommand(tokens); // Phase 4: check the subcommand if (subIdx === -1 || subIdx >= tokens.length) return false; return tokens[subIdx] === sub; } /** * Resolve a `-m` message argument to its SUBJECT — the first line of the commit * message — resolving the command-substituted heredoc form to the heredoc * BODY's first line. * * PURE STRING FUNCTION. It deliberately does NOT tokenize or walk the command * line: selecting *which* argument is the message stays with the caller, exactly * as before, so this cannot change which commands are validated. An earlier * revision of this fix did walk tokens and regressed four separate cases — * `git commit -- -m WIP` (a pathspec), `git commit --amend && echo -m WIP` (a * later command's flag), `-m "" --allow-empty-message` (the scanner drops empty * tokens, so the following flag became the subject), and unquoted * `git commit -m WIP`. All were allowed upstream and would have started being * blocked. Reported in review of #3802. * * The defect this DOES fix: `gsd-validate-commit.sh` captured the message with * `-m[[:space:]]+"([^"]+)"`, and bash `[^"]` matches newlines, so the widely * used agent-authored commit idiom * * git commit -m "$(cat <<'EOF' * feat(auth): add login flow * EOF * )" * * captured the whole span up to the final quote at `)"`. Taking its first line * yielded the literal `$(cat <<'EOF'`, which can never satisfy Conventional * Commits, so every heredoc-form commit was blocked regardless of its message. * * Recognition is anchored at BOTH ends and requires a command substitution, so * an ordinary message merely CONTAINING — or ending in — `</bin/cat <<'EOF'` was recognized here while bash reads // `/bin/cat` as the executable NAME, so recognition claimed a // substitution that does not run cat (Codex review of #3816, round 2). The // same ASCII rule as the blank-line skip below, for the same reason. // `cat[ \t]*<<`, not `+`: bash accepts `cat<<'EOF'` with no space (review of // #3816, round 3), and recognizing it costs nothing — the token before `<<` // is still literally `cat`. // // The path prefix must be ABSOLUTE (Codex review of #3816, round 4). The old // `[\w./-]*\/` also accepted `./cat` and `../evil/cat`, so a relative // executable that merely ENDS in `cat` was trusted to echo its stdin: with a // planted `../evil/cat` printing `WIP injected`, the resolver validated the // heredoc body while git's real subject was `WIP injected` (measured // base=2 -> head=0 against a real commit). Requiring `/` up front costs // nothing real — `/bin/cat` and a bare `cat` both still resolve. // // AN ABSOLUTE PATH IS NOT AN IDENTITY EITHER (independent review of #3816, // round 8). Round 4 stopped at "must be absolute", so any absolute path // ENDING in `/cat` was still trusted to echo its stdin — the very thing the // round-4 reasoning rejected one spelling earlier. With an executable at // `/some/scratch/dir/cat` printing `WIP injected`, the resolver validated // the conforming heredoc body while git's real subject was `WIP injected` // (measured on bash 3.2.57 and 5.3.15 against a real commit: hook exit 0, // `git cat-file -p` subject `WIP injected`, while the same command through // `./cat` was already refused). The prefix is now the canonical system // locations, which is the only claim a string can support. `/usr/local/bin` // is deliberately excluded: it is user-writable on ordinary machines, which // is the plantable case this guard exists for. // // RESIDUAL, not fixable from a string: a bare `cat` shadowed earlier on PATH // has the same effect and is indistinguishable here. It is also not a // meaningful boundary — anyone who can plant an executable on PATH can run // `git commit` directly — so this hook stays an authoring guard, not a // security control. // // The delimiter alternatives are split so the BARE spelling is its own group: // `\\(...)` (backslash-quoted) and `(...)` (bare) were one `\\?(...)` branch, // which conflated the only two spellings that differ in bash. See the // expansion guard below. const opener = /^\$\([ \t]*(?:\/(?:usr\/)?bin\/)?cat[ \t]*<<(-?)[ \t]*(?:'([^']+)'|"([^"]+)"|\\([^\s'"();|&<>\\]+)|([^\s'"();|&<>\\]+))[ \t]*$/ .exec(lines[0]); if (!opener) return lines[0]; // EXPANSION GUARD (review of #3816, round 4 — BLOCKER). Only `<<'D'`, `<<"D"` // and `<<\D` suppress expansion. A BARE `< format gate dodged // -m "$(cat < length gate dodged // // Both measured base=2 -> head=0 against the real hook. This is NOT the // pre-existing plain-`-m` expansion limit an earlier revision claimed it was: // on base the whole heredoc form was blocked, so no expansion inside a body // ever reached an allow — this fix created the path, and closes it here. // Same rule every other guard in this function follows: when the real subject // cannot be known, fall back to the opener line, which fails the format gate. if (opener[5]) return lines[0]; // `<<-` strips leading TABS from every body line, including the terminator. const stripTabs = opener[1] === '-'; const delimiter = opener[2] || opener[3] || opener[4]; const body = lines.slice(1).map((l) => (stripTabs ? l.replace(/^\t+/, '') : l)); // TRUNCATION GUARD. The capture that produced this argument stops at the first // `"`, so a message containing one arrives here missing its tail — and its // terminator. Resolving anyway would hand the length gate a PREFIX of the real // subject and let an over-long message through, an enforcement hole that did // not exist before this fix (review of #3802). A body with no terminator is // therefore not resolved at all: returning the opener line fails the format // gate, which is exactly what this whole form did before the fix. The fix // applies where the capture is complete and changes nothing where it is not. const end = body.indexOf(delimiter); // POST-TERMINATOR GUARD (review of #3816, round 3 — BLOCKER). Everything // after the terminator is still part of the real message once bash // substitutes: `-m "$(cat <<'EOF'\nfeat: ok\nEOF\n) <200 a's>"` expands to a // single 200+ char subject, but discarding the tail measured 8 and DODGED // COMMIT_SUBJECT_TOO_LONG — the same prefix-measurement class the truncation // guard below exists for, missed on the other side of the terminator. The // canonical idiom's tail is exactly one closing-paren line; anything else // means the substitution is composed with more text, so fall back to the // opener line — blocked, the pre-fix behaviour for the whole form. if (end !== -1) { const tail = body.slice(end + 1); if (tail.length !== 1 || !/^[ \t]*\)[ \t]*$/.test(tail[0])) return lines[0]; } // git's default `cleanup=whitespace` strips leading blank lines, so the subject // is the first NON-EMPTY body line, not blindly the first one. Taking lines[1] // returned '' for a body that starts blank and falsely blocked a conforming // commit — the very defect class #3802 reports (review of #3802). const scan = end === -1 ? body : body.slice(0, end); // "Blank" is git's ASCII definition — space and tab — never JavaScript's // trim(), whose Unicode whitespace class skips lines git KEEPS: a body whose // first line is a NBSP resolved to the SECOND line while git's real subject // is the NBSP line — an enforcement bypass (Codex review of #3816, verified // against `git stripspace`, which preserves the c2a0 bytes). A Unicode-blank // first line is now returned as the subject and fails the format gate: the // same fail-closed direction git itself takes. const idx = scan.findIndex((l) => !/^[ \t]*$/.test(l)); if (idx === -1) return end === -1 ? lines[0] : ''; // Truncation is only fatal to the line it lands IN. A captured line is complete // exactly when another line follows it, because the capture kept its newline. // So an unterminated body whose subject line is followed by more text is still // measurable; only a subject line that runs to the end of a truncated capture // is not, and that one falls back to the opener — which fails the format gate, // exactly as this whole form did before the fix. Without this, the length gate // measured a PREFIX of the real subject and let an over-long message through // (review of #3802). if (end === -1 && idx >= body.length - 1) return lines[0]; // git's `cleanup=whitespace` strips whitespace at BOTH ends of the line, not // just leading blanks. Measuring the raw line rejected `feat: <66 x's>` plus // three trailing spaces as 75 chars when git's actual subject is a conforming // 72 — a still-blocked conforming commit, the very defect #3802 reports // (review of #3816). Trailing only, here: leading blank-LINE handling is the // findIndex above, and `<<-` leading-tab stripping already happened. return scan[idx].replace(/[ \t]+$/, ''); } module.exports = { isGitSubcommand, tokenize, extractBranchArgument, skipToSubcommand, resolveCommitSubject };