Skip to content

fix(hooks): commit-guard heredoc false positive, and a one-model data strip - #2

Merged
roadhero merged 39 commits into
mainfrom
fix/heredoc-strip
Sep 6, 2026
Merged

roadhero merged 39 commits into
mainfrom
fix/heredoc-strip

Conversation

@roadhero

@roadhero roadhero commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Summary

v1.0.6's commit guard blocked a Bash heredoc that wrote a file whose body mentioned git commit, a shell wrapper, and git push -f. Heredoc bodies are not quoted, so the old quote strip left them in and the flag checks fired on prose.

The first fix (a per-line heredoc pass in front of the sed quote strip) did not survive adversarial review: two quote models with per-line state opened several ways to hide a real force-push behind an innocuous first line. This PR replaces both with one model, then closes every divergence between that model and bash that repeated review and security audit could produce.

  • One character walk strips all data before classification, with state carried across lines: single, double, and ANSI-C quoted spans with backslash escapes; $(...) and backticks that reopen code inside double quotes and unquoted heredoc bodies; ${...} parameters, reduced to a single mark because bash may replace them with nothing; $((...))/((...))/$[...] arithmetic and array subscripts, where << is a shift and |/& are operators; [ ... ] test brackets in command position; # comments only where a word starts; heredoc bodies with any delimiter, <<-, EOF), several per line, unterminated to end of input. Real command boundaries are emitted as a \001 byte, so a separator that is data never splits a segment.
  • The invariant, in the header: the walk must never believe it is in data where bash is in code, and no rule may depend on token adjacency where bash may leave nothing (an unset variable, an empty substitution, a run-time pattern, a possibly-empty expansion). Every bypass produced in review is now a pinned case.
  • Refuse rather than guess for what the walk cannot classify, each with a reason: a ${ cmd; } funsub, a substitution inside ${...}, an unquoted */?/[/{a,b} in a commit or push, a raw control byte, any shell-wrapper shape, a heredoc marker before a same-line substitution, a parameter default that is a flag or +refspec, git config from the environment or an unreadable file, a -c/committer/author/hooksPath value built from an expansion, and a same-call git config user.name/email set to a non-human.
  • Fail closed and hygiene: pipefail around the strip, LC_ALL=C, set -f, every grep pattern via -e with status over 1 treated as failure, here-strings instead of pipes so SIGPIPE cannot read as "no match", the diff read with --text --no-textconv --no-relative --no-color --output-indicator-new, caps at 256 KB / 16 MB / 500 segments / 16 heredocs.
  • Tests: 585 guard cases, up from 73, green on macOS awk, mawk 1.3.4, and busybox awk; git 2.28 floor documented in the header and README.

Deferred / out of scope

  • The header's accepted-limits list is the contract: variable indirection, a word/flag spliced into another by an expansion (git pu${x}sh, --for$(echo c)e), a ${x/pat/--force} replacement (needs the variable set first), a script run by name, another interpreter. The false-block list is the price of the invariant and is documented item by item.
  • The inline -c user.email= / env GIT_COMMITTER_EMAIL= bot-address form is left uncovered on purpose: the committer name still comes from repo config and is checked, so no non-human-named commit lands, and email substring matching is noisy.
  • Nothing outside the hook, its tests, and one README paragraph changed.

Test plan

  • Failing first: the four allowed heredoc forms exit 2 on the v1.0.6 hook, 0 here; every bypass from review reproduced (hook exit 0 and the dangerous argv reaching a git shim) before its fix
  • Local quality gate green: shellcheck on hooks and tests, settings JSON, agent-name grep, template diffs, bash tests/hooks/test-guard-commit.sh (585 passed), bash tests/hooks/test-format.sh (4 passed)
  • /bin/bash 3.2.57; three awks via docker; per-call cost about 60 ms
  • Repeated adversarial review and security audit against a git argv shim; every finding closed with a pinned case
  • CI green on ubuntu-latest

…hat mentions git flags is not a command

A Bash heredoc that wrote a doc mentioning git commit and a shell wrapper was blocked. Heredoc bodies are data bash never executes, so they are now dropped line by line up to the terminator before the quote strip runs. A <<WORD marker counts only when it sits outside quotes on its line (even number of unescaped double and single quotes before it); a marker inside a string is text, and stripping past it would let a real command hide behind a fake terminator line. Nine cases added: four allowed forms (quoted and unquoted markers, commit -F - <<EOF, <<- with a tab-indented end) and five that must still block (a real command after the terminator, fake markers inside double and single quotes, a here-string).
The first heredoc strip judged quote parity per line, so a marker on line 2 of a multi-line quoted message was honored and everything after it, including a chained force-push, was stripped. Replaced the awk heredoc pass plus the sed quote strip with a single character walk that keeps state across lines: single and double quotes with backslash escapes, $( ) and backtick substitutions that reopen code inside double quotes, comments, and heredoc bodies with any delimiter, an EOF) terminator inside $( ), <<- tab stripping, and unterminated bodies running to end of input as bash does. Here-strings are no longer mistaken for markers. Ten cases added (92 total), eight of them bypass attempts from review.
…mments after ), ANSI-C strings; fail closed if the strip fails

Security review of the character walk found three ways an innocuous first line hid a plain force-push: $((1<<2)) read as a heredoc marker, a # comment directly after ) not recognized, and an escaped quote inside $'...' flipping the quote state for the rest of the input. Arithmetic frames ($(( )), (( )), $[ ]) now suppress marker detection, the comment-start class includes ) and backtick, and $'...' is its own state with backslash escapes. The strip runs under pipefail and an awk failure blocks; LC_ALL=C keeps every tool byte-oriented so an invalid UTF-8 byte cannot drop the rest of the input; commands over 256 KB are refused. Nine cases added (101 total).
…continuations inside the walker; parens inside arithmetic

Second code review found two regressions against main: bash expands $(...) and backticks inside an unquoted-delimiter heredoc body, and the global backslash-newline join ran before the walker knew which lines were a body, so a body line ending in a backslash could swallow the terminator. Unquoted bodies are now walked in a data mode that reopens code for substitutions; quoted bodies stay inert; the continuation join happens in the walker for code only. A paren nested inside $(( )) or (( )) keeps the arithmetic context, and a quoted delimiter may contain spaces. Ten cases added (111 total); the suite passes on macOS awk, mawk 1.3.4, and busybox awk.
…[ ]; case patterns; continuations joined with nothing; delimiter continuation refused

Security re-audit found four more places the walker took bash code for data: a # directly after $(...) or a backtick is part of the word, not a comment; a backslash-newline inside a heredoc delimiter was eaten as an escape and armed a phantom body; << inside ${...} and inside an array subscript was read as a heredoc marker. Continuations are now joined with nothing, as bash does, and the fast path joins JSON-escaped continuations before matching, so a word or flag split across lines cannot dodge it. case ... esac is tracked so a pattern's ) no longer closes an enclosing $(...); that limit is gone from the header. The fast path fails closed if sed or grep errors, and pipefail is on for the whole hook. Fourteen cases added (125 total); green on macOS awk, mawk, busybox awk.
…ne; nested and same-line markers fail closed; segment cap

Third code review: an EOF) terminator followed by a quote or an operator was not recognized, a marker registered inside a body's substitution was queued behind the outer terminator, and a body started at the end of the marker line even when a quote or continuation was still open. Now a terminator may be followed by ) and the rest of that line is walked as code; a body starts only at a newline reached in code state with no continuation; frames left open inside a body die with it; a marker nested inside a body, or one registered inside a substitution that closes on the same line, makes the walk fail closed. A backslash before a flag character is dropped as bash does. The per-segment loop refuses over 500 separators so it cannot outlast the hook timeout, and a missing awk is tested to fail closed. Fourteen cases added (139 total); green on macOS awk, mawk, busybox awk.
… frame it closes; no comments inside ${ }; open frame or quote at end fails closed; here-strings instead of pipes

Final security pass: the word case anywhere (docs/use-case.md inside a substitution) stuck the pattern counter, so the ) closing $( ) never popped and the closing quote read as an opening one, hiding a chained force-push; an unbalanced [ or ${ leaked the same way; # inside ${ } was taken as a comment; and global pipefail turned grep -q's early exit into a SIGPIPE upstream, silently disabling the secrets scan on staged diffs over a pipe buffer. Now case and esac count only in command position, a ) the top frame cannot close unwinds to the substitution it does close, [ frames die at command boundaries, comments are not recognized inside expansion frames, a frame or quote still open at end of input fails closed (which also covers a case with no esac), and every whole-input check reads through a here-string. The fast path joins continuations with a bash substitution instead of sed. Fifteen cases added (154 total), including a secret at the top of a 200 KB staged diff and 80 KB of padding before a wrapper and a hooksPath override; green on macOS awk, mawk, busybox awk.
…s; # after (( )) is a comment; secrets scan reads every added line

Fifth code review: a ; & or | inside ${x:-;} split git push from --force across segments, and # directly after an ((...)) command was not a comment though bash treats it as one. $(( is now a distinct frame from ((, separators inside ${ }, (( )), $(( )) and their parens are emitted as text, and the comment rule accepts the )) of an arithmetic command but not of an expansion. The secrets scan drops -q and excludes only the +++ b/ and +++ /dev/null headers, so an added line that itself starts with ++ is scanned. Seven cases added (161 total); green on macOS awk, mawk, busybox awk.
…th retries dequoted; every tool status-checked; heredoc queue capped; function definitions and coproc set command position

Final security audit: the fast path could be dodged by splitting the subcommand word itself (git pu"sh", git pus\h, git $'push'), which made every later check moot; a missing tr failed open; a pending-heredoc queue with thousands of terminators was quadratic and could outlast the hook timeout; and case after a function definition or coproc was not in command position, which on bash 4+ let a pattern's ) close the enclosing substitution. A quoted span with no whitespace inside is now glued back into its word as bash does, which also closes the old --no-veri"fy", $'--no-verify', and push "-f" limits; the fast path retries with quotes, backslashes, $ and continuations removed when the plain grep finds nothing, refusing payloads over 512 KB; tr and wc are status-checked and the secrets pipeline inspects every stage's status; more than sixteen pending heredocs fails closed. Eighteen cases added (179 total); green on macOS awk, mawk, busybox awk.
…losed; linear fast-path retry; -F is not a force flag

Sixth code review: a one-word quoted message ending in ; was glued into its word and then split by the segment loop, moving --no-verify or --force into a git-less segment; $'\x70ush' was glued verbatim where bash decodes it; and the fast-path retry used bash substitutions that are quadratic on bash 3.2, stalling unrelated quote-dense commands. The glue rule now treats separators, newlines, and any substitution inside the span as data, a numeric escape inside $'...' fails closed in the walker and routes to it from the fast path, the retry runs sed once and only when the payload contains the word git, and the short force flag is matched case-sensitively so git commit -F is not a force-push. Eleven cases added (190 total); green on macOS awk, mawk, busybox awk.
… walker refusal names its construct

Seventh code review: gating the retry on the substring git meant that splitting the word git itself (g"it" push --force) skipped every later check, a regression from the previous commit. The retry now runs whenever the plain grep finds nothing; sed is linear so the gate bought nothing. Each fail-closed exit in the walker prints which construct it refused, so an agent that hits one (a numeric escape in $'...', a nested heredoc, an open quote) knows what to rewrite. Seven cases added (197 total); green on macOS awk, mawk, busybox awk.
…nuation state carries into the next line; command position after coproc and time; quoted hooksPath; every grep and git call status-checked

Closing security audit and eighth code review. The walker printed a newline at the end of every line, but inside $(...) that newline does not end the enclosing command, so the repo's own multi-line heredoc commit message followed by --no-verify split into separate segments and passed; and a continuation line starting with # was read as a comment. A newline is now a segment boundary only in code at depth zero, a space inside a substitution, and nothing inside a quoted span; continuation state persists into the next line. Also: coproc NAME and time -p keep the following word in command position; a quoted core.hooksPath value with a space is caught on the raw command; the fast path flattens pretty-printed payloads and routes JSON unicode escapes to the walker; the ANSI-C router no longer stops at an escaped quote; the committer name is sanitized before it reaches the model; every grep and git call treats a tool failure as a block; the untracked scan is case-insensitive, NUL-safe, and fails closed on an unreadable file; a commit with -i, -o, or a pathspec-looking token scans the working tree; the refusal test asserts the reason text. Twenty-one cases added (218 total); green on macOS awk, mawk, busybox awk.
… absolute-path shell wrappers; --no-color on the diff; symlinks, $'EOF' delimiters, and numeric escapes without git

Ninth code review: a ; or | inside a $(...) used as an argument (git push origin "$(git branch --show-current | tr -d ' ')" --force) split the enclosing command so the flag lost its git; /bin/sh -c was not recognized as a wrapper; color.ui=always hid the + prefix from the secrets scan. Separators and newlines inside a substitution now become spaces (a newline in a bare subshell stays a separator), the wrapper class admits an absolute path, git diff runs with --no-color, the untracked scan skips symlinks and non-regular files and matches exclusions on the whole name, a $'EOF' delimiter is quoted, an ANSI-C numeric escape is refused only when git, commit, or push is also mentioned, stdin read failure blocks, and has/hasc use a local status. Fifteen cases added (233 total); green on macOS awk, mawk, busybox awk.
…xpansion frame; the secrets diff is read as text without textconv or relative paths; wrapper flags before -c

Tenth code review: a newline inside ${...}, $((...)) or $[...] split the enclosing command so a flag lost its git; a -diff attribute or a NUL byte made git call the file binary and the secrets scan saw no + lines; bash -x -c and ksh -c were not recognized as wrappers. A newline is now a command boundary only when every open frame is a bare subshell; $[ has its own frame kind so it survives the end-of-line test-bracket drop; the guard's git diff runs with --text --no-textconv --no-color --no-relative and a diff over 16 MB fails closed; the untracked scan reads binaries; the wrapper regex accepts flags before -c and ksh, csh, tcsh, fish. Header lists interpreter wrappers, filter.*.clean, and the -n-inside-substitution false block. Twelve cases added (245 total); green on macOS awk, mawk, busybox awk.
…cess substitution is a word; added lines come from git's own indicator

Eleventh code review and third security audit: a shell fed on stdin (bash <<'EOF', bash <<<, ... | sh, bash -s) was not a wrapper so its body was stripped as data; bash -cx and -ceu clusters slipped the -c test; trap runs text at exit; ash, mksh, yash were unlisted. Process substitution <(...) was pushed as a bare subshell, so a separator inside it split the enclosing command and detached --force or --no-verify from its git; it is now its own frame that merges like $(...). A staged line whose content starts with ++ b/ evaded the diff-header filter; git now marks added lines with its own --output-indicator-new, so header shape is irrelevant, and the secrets scan is one grep. Twenty-two cases added (267 total); green on macOS awk, mawk, busybox awk.
… aliases refused; env -S; git 2.28 floor; JSON-escape tests really contain escapes

Twelfth code review: the stdin-fed-shell detection caught a few shapes and missed many (bash --norc <<EOF, bash 0<<EOF, | bash;, a pipe into bash on the next line, exec-redirected stdin then bare bash, bash <(...), source <(...)); a git alias defined in the same call (git -c alias.fp=push fp --force) renamed the subcommand out of reach of every check; env -S splits a string into a command; and the two JSON unicode-escape tests had lost their backslashes in transit and asserted nothing. The wrapper check now runs per segment with one rule: a shell by name or path is a wrapper when given leading options, when anything is redirected into it, or when it has no arguments at all; a shell given a script path and the script's own arguments is not. Process substitution keeps its < in the stripped text. alias. anywhere in a call that commits or pushes is refused. The header states the git 2.28 floor. Nineteen cases added (286 total); green on macOS awk, mawk, busybox awk.
…operators and escaped separators are text; wrappers next to parens, after redirections, and glued to redirects; env options before -S; alias rule on stripped text

Fourth security audit and thirteenth code review. The stripped text reused ; & | as its own segment delimiter, so an escaped \; or the & of 2>&1 became a command boundary bash never has and a flag after it lost its git. The walker now emits a \001 byte for a real boundary, escaped separators and redirection operators are text, and segments split only on the sentinel. $( and ( emit a trailing space so a wrapper right after them is at a word start (a regression from the previous commit); the wrapper rule adopts a reviewer-verified regex set: a bare shell followed only by redirections, a redirect glued to the shell word, env options before -S, and a path prefix that ignores shebang writes and assignments. The alias rule reads the stripped text plus the inline -c form, so a message mentioning alias.md is data. Any bare token after commit that is not a message-like option value triggers the working-tree scan. Patterns are passed to grep with -e. The suite pins /bin/bash when present. Fifty-six cases added (342 total); green on macOS awk, mawk, busybox awk.
…boundary byte

A quoted span with whitespace inside is data and is dropped, but it was
dropped without a trace, so `-m "fix: two words" tracked.txt` made the
pathspec look like the message value and skipped the working-tree secret
scan. A dropped span now leaves an empty "" in its place; an empty pair
spliced into a word (commi""t) still vanishes, as in bash.

The pathspec loop now consumes exactly the next token after a
value-taking option, whatever its shape (-m -F file), no longer treats
-S/--gpg-sign as value-taking (they are not), and skips redirection
targets (2>&1). Globbing is off for the whole hook so an unquoted token
can never expand against the working directory.

The walk refuses a raw \001 byte in the command (its own segment mark).
env -C/-u/-P/-a/--unset/--chdir/--argv0 with a separate argument, and
versioned shell names (ksh93, bash5), are recognized as wrappers.
…tterns and identity overrides are checked

An array subscript inside an expansion or arithmetic (${a[1|2]}) was
treated as a test bracket, so its `|` or `&` split the command where
bash does not; the pieces then dodged every per-segment check. The walk
now opens a subscript frame there, whose separators are text.

A word holding an expansion may be nothing at all when bash runs it, so
`bash $a -c "..."` was not seen as a wrapper. The walk now leaves a `$`
(or backtick) in every such word and the wrapper rule steps over them.

An unquoted `*`, `?`, `[`, or `{a,b}` in a commit or push is expanded at
run time into words the walk never sees (`-*` with a file named
`--no-verify`; `--{force,}`), so it is refused; a quoted or escaped one
is data.

The committer check read only the repo config. It now also reads an
override on the command itself: -c user.name=, GIT_COMMITTER_NAME=,
GIT_AUTHOR_NAME=, and --author.

The two secrets greps take their pattern through -e like the rest.
…ide command position is a pattern

A `)` inside `${...}` unwound the frame without restoring the quote it
was opened in, so `: "${x:-)}" ; git push -f origin main #"` hid the
push as quoted data. Inside `${...}` a `)` is now text, the unwind
refuses to drop a frame opened in a quote, and a `${ cmd; }` or a
substitution inside `${...}` is refused rather than guessed.

The interior of a `${...}` word is dropped and a lone `$` stands for it,
in code and in double quotes, so `bash ${a[@]} -c`, `bash "${@}" -c`,
and `bash `echo` -c` are seen as wrappers, and `${BRANCH##*/}` no longer
trips the pattern refusal. A dropped quoted span that held a
substitution leaves `"$"`, for the same reason.

A `[` opens a test bracket only where a command starts; glued to a word
it is a subscript, and anywhere else it is a pattern character, so
`-[n]` is refused like `-*`.

The committer override check reads the stripped text as well as the raw
one (`user.name=Cla'ude'`, `$'Claude'`) and no longer fires on a quoted
message that mentions `user.name=`. Config passed through the
environment (`--config-env`, `GIT_CONFIG_*`) in a commit call is refused.
A short cluster ending in an option that takes a value (`-qm fix`) no
longer makes the value a pathspec; parentheses and angle brackets are
no longer glued back into a word.
… is refused

A lone `[` in a `${...}` value (`${x:-[}`) opened a subscript frame that
swallowed the word's own `}`, so the word stayed open across real
command separators, and a later `]}` truncated away every command bash
had run in between. Inside `${...}` a `[` is now text, as `)` already
was; a `}` that arrives with a paren or bracket still open inside the
braces is refused rather than absorbed.
…at their `)`; config through the environment is one rule

A `${...}` word spanning lines printed its earlier lines as plain
tokens, so a three-line `bash ${a-` ... `} -c` was not seen as a
wrapper. Every line of such a word is now cut back to the word's mark.

A case pattern's `)` is a command boundary, so the pattern no longer
lands in the next command's segment and trips the pattern refusal.

The pathspec loop skips the words of a `$(...)` or backtick
substitution, a `-S<keyid>` consumes nothing, and a short cluster is
read as value-taking only when its other letters are no-value flags
(`-SDEADBEEF` is a key, not `-F`). A dropped quoted span holding a
substitution emits nothing, since the substitution's own mark is
already there. The `-a/-i/-o` check is case-sensitive.

Config passed through the environment (`--config-env`, `GIT_CONFIG_*`)
is refused by one rule for commits and pushes alike, ahead of the
hooks-path rule, which now names only that key. The quoted path
refuses a substitution inside `${...}` with the same reason as code.
README names what the guard refuses instead of a claim it did not keep.
…icks and redirects in the pathspec loop

Bash keeps reading a `${...}` word across newlines and starts a heredoc
body only after the line holding the `}`; the walk entered the body at
the first newline, then cut the following lines to the word's mark, so
a command bash ran on one of them reached no rule. A heredoc marker on
a line that ends inside `${...}` is now refused.

In the pathspec loop backticks are their own flag, toggled by an odd
count in a token, so a `)` inside a backtick pair is no longer counted
twice and the pathspec after it is scanned. A redirection glued to a
word (`tracked.txt>log`, `tracked.txt2>&1`) leaves the word an
argument; `--pathspec-from-file`, `-p`, `--patch`, and `--interactive`
commit working-tree content and scan it. Header and README wording
follow the code.
…ted committer, empty-arg and literal-mark pathspecs

A `<<WORD` marker registered before a `$(...)`, backtick, or `<(...)`
that opens on the same line let bash defer the body past the
substitution's close and run the lines between as its commands, which
the walk dropped as data. That interleave is now refused, alongside the
`${...}` case it mirrors.

The committer override check now also catches a key quoted with its
whole argument (`-c "user.name=Claude Bot"`, `"--author=Claude Bot"`)
and a bot name with a trailing digit.

In the pathspec loop a `$(` mark is matched exactly, so a literal
`x$(y` word is not mistaken for a substitution and no longer hides the
pathspec after it; a quoted backtick is dropped as data so it cannot do
the same; and an empty quoted argument that is a whole word
(`-m "" file`) is kept as one, so its pathspec is still scanned.

Config read from a file the guard cannot see (`include.path`,
`includeIf.`, `GIT_CONFIG_GLOBAL`/`SYSTEM`, `HOME=`) in a commit or
push call is refused, the same as the environment routes.
…d; XDG_CONFIG_HOME is a config route; anchor includeIf

The empty-argument fix from the previous commit emitted a placeholder
for a quote at the start of a word, but bash elides an empty quote glued
to the characters after it (`git ""commit` is `git commit`,
`push ""--force` is `push --force`). The placeholder was then a phantom
only the guard saw, and it broke every word-anchored check: a two-byte
`""` prefix on a subcommand, flag, or shell name slipped a force-push,
`--no-verify`, a secret-bearing or bot-authored commit, or a `bash -c`
wrapper straight through. The placeholder is now emitted only when the
quote is a whole word, proven by the character before it and a one-char
lookahead at the character after.

XDG_CONFIG_HOME points git at a config file the guard cannot read, the
same hooks-path bypass as HOME, so it is refused in a commit or push
call. The includeIf config route now requires its `.path=` so a file
literally named includeIf.md is not blocked. The header names the
secret-scan reach of the expansion limit.
…empty one cannot glue to a flag

A braced `${x}`, a substitution, and an empty quote all leave a `$` or a
space in the walk, so a flag or subcommand after them keeps its word
boundary. A bare unbraced `$x` did not: it fell through to the default
copy and was emitted verbatim. Unset, bash drops it and applies the
literal next to it, so `git push origin $x--force` force-pushed while
the guard read `$x--force` as one unknown word and allowed it. The same
held for `$x--no-verify`, `$x-n`, and a `$x`-prefixed shell wrapper.

A bare `$name`, `$1`, or `$@` now emits a `$` mark set off by spaces,
the same treatment `${...}` already gets, so the boundary is restored
and the flag is seen. Also add XDG_CONFIG_HOME to the header's
false-block list for parity with the rule.
…ue to or hide a flag

The empty-expansion fix reached code state but not the inside of a double
quote, so `git push origin "$x--force"` and `"${x}--force"` (x unset)
still force-pushed: the walk kept `$x` glued to the flag, and a `${x}`
dropped the literal after it, so the flag was invisible to the anchored
checks. The same hid `-f`, `--no-verify`, `-n`, and a shell wrapper
(`bash "$a" -c`).

The double-quote walk now treats a substitution and a possibly-empty
parameter differently. A substitution keeps its marks; a bare `$name`,
`$1`, `$@`, or `${...}` contributes nothing to the word, so a flag glued
after it stays a visible, bounded token, and an expansion-only span
collapses to a single `$` word: spaced at a word start so a following
flag still shows, glued after `=` so `--author=$x` stays one token. A
substitution in the span no longer leaves a phantom empty argument, so a
`$(...)` or `${...}` message is not mistaken for a pathspec.

A committer or author name split across two concatenated quoted spans
(`--author="$x""Claude"`) stays a documented limit: closing it would
block ordinary commit messages that mention these config keys, and the
enforced committer from repo config is still checked.
A `${x:---force}` or `${x:=--no-verify}` expands to that flag when the
parameter is unset, which is the default state, so unlike variable
indirection it needs no prior assignment. The walk drops a `${...}`
interior, so it never saw the flag, and `git push origin "${x:---force}"`
force-pushed while the guard allowed it, quoted or not.

A `${...}` whose `:-`, `-`, `:=`, or `=` default begins with a dash is
now refused: the walk cannot tell whether it expands to that flag. A
normal default is a value, not a flag, so `${VERSION:-0.0.0}`,
`${BRANCH:-main}`, `${x:-HEAD}`, a length `${#x}`, and a replacement
`${x//a/b}` are unaffected.
…nd a space before the dash

The previous refuse caught only `:-`, `-`, `:=`, `=`. It missed the
alternate operators `:+` and `+`, which produce their value when the
parameter is set: `${HOME:+--force}` fires deterministically, since HOME
is always set, with no prior assignment. It also missed a space between
the operator and the dash (`${x:- --force}`), which an unquoted
expansion word-splits into the flag. Both are now refused, and an array
subscript before the operator (`${a[0]:---force}`) is handled.

A `${x/pat/--force}` replacement producing a flag needs the variable set
to a controlled value first, so it is folded into the documented
variable-indirection limit rather than guessed at.

The concatenated-quoted-span identity form (`--author="$x""Claude"`)
moves from the false-block list to the accepted-limits list: it is a
false allow mitigated by the enforced repo-config committer check, not
an over-block, and the header now says so.
…nfig/identity token is refused

A brace default whose value the walk drops was only partly covered. The
value could be quoted or escaped past the check (`${x:-"--force"}`,
`${x:-\-\-force}`), so the dash test now steps over a run of
non-alphanumerics before the flag. The value could also be a `+refspec`,
the other force-push form (`git push origin ${x:-+main}`), so the value
class is now a dash or a plus.

The same dropped interior hid a config or identity token fed to a slot
that only a raw-command check guards: `git -c ${x:-core.hooksPath=/tmp}`
disabled the repo hooks, and `git -c ${x:-user.name=Claude}`,
`git -c user.name=${x:-Claude}`, `GIT_AUTHOR_NAME=${x:-Claude}`,
`--author=${x:-Claude}`, and `core.hooksPath=$HOME` set a non-human
committer or re-pointed the hooks path, all while the guard allowed it.
A `-c`, `--config`, committer, author, or hooksPath value taken from a
shell expansion is now refused, since the walk cannot see it. A literal
override (`-c user.name="Dennis"`) is unaffected.

This also closes the concatenated-quoted-span author form
(`--author="$x""Claude"`), which was a documented limit: its `--author="$`
is a value from an expansion, so it is now caught rather than tolerated.
…ght even when the key is split

The previous refuse anchored the expansion to the character right after
-c or after the key's =, so a value built by concatenation slipped past:
git -c c"$(printf 'ore.hooksPath=/tmp')" commit set core.hooksPath (an
attacker-writable hooks path is code execution), and
GIT_AUTHOR_NAME=C"$(printf laude)" set a non-human committer, both while
the guard allowed them. The literal key itself was split across a
literal prefix and a substitution, so no contiguous key matched either.

Three checks replace the one anchored rule:

- A brace default or alternate whose value carries an `=` is a possible
  config token and is refused (`${x:-user.name=Claude}`), alongside the
  existing flag and +refspec values.
- On the stripped text, an identity, author, or hooksPath `KEY=` value
  that carries any expansion is refused. The stripped text has already
  dropped a quoted message, so a commit message that mentions such a key
  is exempt; a real override keeps its literal key and the value's mark.
- In the segment loop, a config-position `-c`/`--config` (before the
  subcommand) whose value carries an expansion is refused, which catches
  a key split across a substitution. A reuse-message `commit -c $sha`
  (value after the subcommand) and a `-C <dir>` change-directory
  (uppercase) are not that and stay allowed.
…sed, mirroring the hooksPath rule

The committer check reads the repo config as it stands before the
command runs. A `git config user.name Claude && git commit` in the same
call sets the bot identity first, so the commit lands as Claude while
the guard, having read the pre-execution human name, allowed it. The
same-call `core.hooksPath` write was already refused; this is its
identity twin, and it was the last unguarded §2 committer channel.

A `git config` (with any flags, or the `set` subcommand) that writes
user.name or user.email to a known bot identity, in a call that also
commits, is now refused. A literal human name (`git config user.name
"Dennis" && git commit`) and an unrelated key (`core.editor`) are
unaffected. As with the other identity checks, a commit message that
quotes such a config line is a documented false block.

The header now records that a brace default carrying `=` is refused as
a possible config token, and that a case-insensitive `-c` key from an
expansion is caught by the config-position refuse.
…uses a value built from an expansion

The same-call config-identity refuse only read the raw command and
needed a contiguous bot name, so two shapes slipped through: a split
key (`git config user.na"me" Claude`, whose key glues back only in the
stripped text) and a value built from an expansion (`git config
user.name C"$(printf laude)"`, `git config user.name $BOT`), where the
bot name is never a literal.

The literal-identity check now also scans the stripped text, so a split
key is seen. And a config write of user.name or user.email whose value
carries any expansion is refused, since the walk cannot read what it
becomes. A quoted commit message is already dropped in the stripped
text, so a prose mention stays exempt, and a config write of an
unrelated key from a variable (`core.editor $EDITOR`) is unaffected.
…git config -f <file> user.name)

A git config write can carry its target file with the short -f flag, not
only --file, so the skip between config and the key now covers a short
flag and its argument as well as long flags and set. A -f write of
user.name or user.email to a bot is refused; a -f write of an unrelated
key stays allowed.
…ion flag (git config --type=path user.name)

A git config flag can attach its value with = (`--type=path`,
`--file=cfg`), which left the =value between the flag and the key and
broke the prefix, so `git config --type=path user.name Claude` set a bot
committer while the guard allowed it. The skipped flag token now takes an
optional =value, so the equals-glued forms are covered for both the
literal bot name and a value built from an expansion; an equals-glued
flag on an unrelated key (`--file=.git/config core.editor`) stays
allowed.
… like git config keys

git treats a config key case-insensitively (User.name == user.name), and
the literal identity check already matched case-insensitively, but the
expansion-value refuse did not, so git config User.name C$(printf laude)
set a bot committer while the guard allowed it. That refuse now matches
case-insensitively too. The key scoping to user.name/user.email is
unchanged, so an uppercase unrelated key (Core.editor $EDITOR) stays
allowed. The config-position -c refuse stays case-sensitive on purpose,
to tell -c (config) from -C (chdir).
@roadhero
roadhero merged commit a640f03 into main Sep 6, 2026
1 check passed
@roadhero
roadhero deleted the fix/heredoc-strip branch September 6, 2026 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant