Files
msd-core/hooks/lib/git-cmd.js
Jakub Zych 6cfa0c55d2 refactor: drop 12 runtimes, keep Claude, Codex, OpenCode, Cursor, ZCode, Antigravity
Removes kilo, kimi, kimi-code, copilot, windsurf, augment, trae, qwen, hermes,
cline, codebuddy and pi end to end: capability descriptors, installer branches
and converters (bin/install.js 14.9k -> 11.2k lines), TypeScript converters,
hook surfaces and runtime homes, review lanes qwen/kimi-code, the two pi
migrations, Kimi payload normalization in the hook guards, dead hostBehaviors
vocabulary, launcher home probes, fixtures, runtime-specific tests and the
prose that presented them as supported.

Installer output for the six kept runtimes is byte-identical to before the
prune. The Kimi tool-vocabulary tests in workflow-guard, read-guard and
read-injection-scanner are left in place pending a decision.
2026-10-06 20:02:40 +02:00

392 lines
19 KiB
JavaScript

'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 `msd-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. 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, '..', '..', 'msd-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 <name>` or `git branch <name>`. Returns
* null for any other command, including plain `git checkout <ref>` (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: `msd-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 — `<<WORD` is not
* mistaken for an opener. Without the `^\$\(` anchor,
* `-m "WIP notes <<EOF\nfix: smuggled subject"` resolved to the second line and
* ALLOWED a non-conforming commit: an enforcement bypass, not just a
* misclassification (review of #3802).
*
* NOT resolved, by design: an UNQUOTED delimiter (`<<EOF`). bash expands `$var`
* and `$(...)` in that body, so the literal text here is not what git receives.
* An earlier revision resolved it anyway and called the gap "the same
* pre-existing limit as expansions in a plain `-m` argument" — that framing was
* wrong on both halves: on base the whole heredoc form was blocked, so this fix
* CREATED the path, and it dodged the length gate as well as the format gate.
* See the expansion guard in the body (review of #3816, round 4).
*
* KNOWN LIMIT: the DOUBLE-QUOTED delimiter spelling (`<<"EOF"`) is resolvable
* here but unreachable through the caller — `msd-validate-commit.sh`'s
* double-quoted `-m` capture stops at the first `"`, which in that spelling is
* the delimiter's own quote, so the resolver only ever sees a truncated opener
* and the commit stays blocked. Its single-quoted `-m` capture DOES deliver the
* spelling intact, which is why an earlier revision's claim of unreachability
* was false; the caller now gates the resolver on the double-quoted arm alone
* (review of #3816, round 4), so the claim holds again — for that reason, not
* by luck. That is the pre-fix behaviour for the whole form (fail closed, a
* false positive on one rare spelling), and widening the bash capture to span
* inner quotes would change what is captured for EVERY message containing one —
* a regression class this fix deliberately does not touch (Codex review of
* #3816). The same capture truncation blocks a `"` in the SUBJECT LINE itself,
* where it lands inside the line being measured. It does NOT block a `"` on a
* later body line: the subject is already complete before the truncation point,
* so that message resolves and is allowed (measured; an earlier revision of this
* comment and of the changeset claimed a `"` ANYWHERE blocked, which is false —
* Codex review of #3816, round 4). The truncation guard below is what keeps the
* unmeasurable half fail-closed. Two more legal-but-unrecognized spellings
* stay blocked the same fail-closed way: an env-prefixed cat
* (`$(A=1 cat <<'EOF'`) and an option-terminated cat (`$(cat -- <<'EOF'`) —
* recognizing either would mean modelling bash prefix words here, cost with
* no reported user (review of #3816, round 3).
*
* @param {string} messageArg - the raw `-m` argument, already selected by the caller
* @returns {string} the subject to validate
*/
function resolveCommitSubject(messageArg) {
// CRLF-tolerant split. With a bare split('\n') every body line kept its \r,
// so `body.indexOf(delimiter)` never matched on CRLF input: the truncation
// guard was inert, an empty CRLF message resolved to 'EOF\r' instead of '',
// and a real 72-char subject measured 73 (review of #3816). Splitting on
// /\r?\n/ is the repo-wide remedy for this recurring defect class.
const lines = String(messageArg == null ? '' : messageArg).split(/\r?\n/);
// The path prefix is a PATH-CHARACTER class, not \S*: `\S*` accepted
// `id;/bin/cat`, so `$(id;/bin/cat <<'EOF' ...` was resolved to its heredoc
// body while bash actually runs `id` first and git's real subject is `id`'s
// OUTPUT — an enforcement bypass (Codex review of #3816). A prefix carrying
// any shell metacharacter now fails recognition, which falls back to the
// opener line and the format gate: fail closed, exactly the pre-fix
// behaviour for the whole form.
//
// Whitespace inside the recognition is ASCII space/tab — [ \t], never \s —
// because JavaScript \s includes Unicode whitespace bash does NOT split on:
// `$(<NBSP>/bin/cat <<'EOF'` was recognized here while bash reads
// `<NBSP>/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 `<<D` is expanded by bash, so the body
// captured here is NOT the text git receives, and measuring it is an
// enforcement bypass in both gates at once:
//
// -m "$(cat <<EOF\nfeat: $UNSET_VAR\nEOF\n)" git gets `feat:` -> format gate dodged
// -m "$(cat <<EOF\nfeat: ${LONG}\nEOF\n)" git gets any length -> 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 };