Skip to content

Close bypasses in the git-command guard hook - #26

Merged
PyYoshi merged 8 commits into
mainfrom
fix/guard-bash-bypasses
Oct 21, 2026
Merged

PyYoshi merged 8 commits into
mainfrom
fix/guard-bash-bypasses

Conversation

@PyYoshi

@PyYoshi PyYoshi commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Summary

A Claude Security scan of 151e3ef (whole repository, medium) reported
no vulnerabilities. Its panel rejected two candidates against
.claude/hooks/guard-bash.sh as not exploitable, since the agent the hook
constrains can run arbitrary shell anyway, but confirmed that the guard
missed blocked commands written in other forms, and that the comment
claiming over-splitting "can only cause a rare false block, never a false
allow" was wrong. This PR closes those gaps and corrects the comment. The
hook is still an advisory layer; rulesets and CI stay authoritative.

Recognized before matching (previously allowed, now blocked):

  • git/gh behind a variable assignment (GIT_TERMINAL_PROMPT=0 git ...), a
    path (/usr/bin/git, \git), shell keywords (if, then, !, ...)
    or a wrapper that runs a command (env, command, exec, eval,
    sudo, nice, timeout, nohup, time, xargs, sh -c/bash -c,
    ...). Behind a wrapper the next git/gh word is taken, so wrapper options
    need no parsing.
  • Quoted words ("git", origin "main", "--admin"): quotes and
    backslashes are stripped.
  • Short-option clusters: git commit -nm, -anm, git push -uf, -fu.
    The cluster is read up to the first option that takes a value, so
    git commit -mn (message "n") stays allowed.
  • Long-option abbreviations git accepts (verified against git 2.53 with
    --dry-run): --no-verif, --no-g, --force-w, --m (--mirror),
    and so on. Ambiguous prefixes such as --forc are blocked too, although
    git rejects them.
  • @ as HEAD (git push origin @ on main).
  • Redirections: git push origin 2>&1 on main counted 2> as a second
    push argument and slipped past the "pushing while on main" check.
  • Backslash-continued lines (git push \ + newline + --force).
  • A push after git checkout main / git switch main / git checkout -
    in the same command line (the branch was read before the command ran).

Newly blocked:

  • git push --all / --mirror (they push main too).
  • Signing and core.hooksPath overrides through --config-env (both
    forms) and GIT_CONFIG_PARAMETERS / GIT_CONFIG_KEY_n variables (the
    environment form of git -c).
  • Any git -c commit.gpgsign= value other than an explicit true (an empty
    value also disables signing).
  • git config --unset commit.gpgsign.

After the independent review (5b56a61): see the review reply comment
for each finding. In short:

  • Each command line is read twice: split as written, and normalized
    (continued lines joined, comments cut, redirections dropped, keeping a
    word glued to a redirection). It is blocked if either reading blocks it,
    so normalization cannot hide a command the old hook blocked.
  • It also blocks --no-verify/--no-gpg-sign on every git command,
    --branches and --m, matching/wildcard/brace refspecs and
    heads/main, git -c alias/push-refspec/push.default definitions,
    gh … --admin=true, an empty commit.gpgsign, and removing or
    renaming the commit/core config sections. It searches the whole
    command line for a Co-Authored-By trailer whenever it runs
    git commit.
  • Fewer false blocks: clusters git would reject as a whole (-inf), a PR
    body that mentions gh pr merge --admin, and a checkout that creates
    another branch or checks out paths.

Still out of reach, stated in the header: commands assembled at
runtime (scripts, aliases, variables, exported config), and literal forms
the hook does not model. Quoted text is not parsed, so a message word like
-nan still blocks; git commit -F FILE avoids it.

Testing

scripts/testdata/guard-bash-cases.tsv holds 397 rows (block/allow, and
the branch the command runs on), including every case from the review. scripts/test-guard-bash.sh feeds each
row to the hook as Claude Code's PreToolUse JSON, from a scratch repository
on that branch. It runs as make test-hooks, inside make verify, and as
a step in the CI lint job. Without jq the hook is inert, so a local run
skips the table with a warning, while CI fails. The maintainer approved
adding this check (design gate: a new check, but no new CI job; required
check names are unchanged).

  • New hook: 397/397 as expected, under bash 5.3.9 and bash 3.2.57
    (bash:3.2 Docker image).
  • Old hook (main): every mismatch is a row that should be blocked, except five rows git or gh rejects (recorded as allow rows), all of them rows that should be
    blocked; it passes every allow row, so the expected values match its
    intent.
  • No command the old hook blocks is allowed by the new one, across the
    table and the reviewer's case files (421 rows).
  • Allow cases include the repository's own workflow (git push -u origin <branch>, git push origin vX.Y.Z on main, gh pr merge --merge --delete-branch, git switch -c x origin/main && git push -u origin x,
    git checkout main && git pull) and the header's examples.
  • A 400-word gh pr create --body command takes about 0.1 s; a 254 KB
    heredoc command about 2.4 s (hook timeout: 15 s).
  • To reproduce the old-hook numbers:
    git show origin/main:.claude/hooks/guard-bash.sh > /tmp/old.sh && ./scripts/test-guard-bash.sh /tmp/old.sh

Checklist

  • make verify passes locally (build + lint + tests)
  • make integration passes (required when client.go / conn.go / commands.go / internal/proto/ / docker/ changed). Not run: none of these paths changed.
  • README.md and README.ja.md updated together, or the change touches neither
  • CHANGELOG.md updated under [Unreleased] for user-visible changes
  • No new dependencies, no assembled EICAR string, no weakened guards; design changes reference an ADR
  • Reviewed against the AGENTS.md Review checklist by someone other than the author, covering the merged commit (recorded in the PR); every finding fixed or answered

A Claude Security scan of 151e3ef rejected two candidates against
.claude/hooks/guard-bash.sh as not exploitable, since the agent the
hook constrains can run arbitrary shell anyway. It did confirm that
the guard missed blocked commands written in other forms, and that
its "never a false allow" comment was wrong.

Segments are now normalized before matching: backslash-continued
lines are joined, quotes and backslashes stripped and redirections
dropped, and the command word is found behind variable assignments,
a path, shell keywords and wrappers such as env, sudo, sh -c and
xargs. Short options are read inside clusters and long options by
the abbreviations git accepts. A push after a checkout of main in the
same command line counts as a push from main, and @ counts as HEAD.

The guard also blocks git push --all/--mirror, signing and hooksPath
overrides through --config-env and GIT_CONFIG_* variables, any
git -c commit.gpgsign value other than true, and
git config --unset commit.gpgsign.

The header now states the remaining limit: a string filter cannot see
commands assembled at runtime, so the hook stays an advisory layer
behind the rulesets and CI.
The cases that verified the guard fix lived only in a scratch
directory, so a later edit could reopen a bypass unnoticed and a
reviewer could not rerun them. The maintainer approved adding them as
a check (design gate: new check, no new CI job).

scripts/testdata/guard-bash-cases.tsv holds 107 block/allow rows, each
with the branch the command runs on. scripts/test-guard-bash.sh feeds
each row to the hook as Claude Code's PreToolUse JSON from a scratch
repository on that branch and fails on any mismatch, unexpected exit
status or empty table. It runs as make test-hooks, as part of
make verify, and as a step in the CI lint job. Without jq the hook is
inert, so a local run skips the table with a warning, while CI fails.
The independent review of d3db75a found 12 commands that the old guard
blocked and the new one allowed. Dropping redirections and joining
continued lines ignored quoting, so a quoted ">" or "<", an escaped
backslash or a backslash in a comment could hide a word or a whole
command. That is a weakened guard.

Each command line is now read twice: split as written, and normalized
(continued lines joined, comments cut, redirections dropped). It is
blocked if either reading blocks it. The normalized reading keeps a
word glued to a redirection (-f>/dev/null) instead of dropping it.
Across the committed table and the review's case files (421 rows), no
command the old hook blocks is allowed.

The other findings are fixed as well:
- git push --m, --b and --branches resolve to --mirror and --all, so
  the minimum abbreviation length is now 3.
- GIT_CONFIG_PARAMETERS with several entries is searched as a whole.
- Values of -o, --push-option, --repo, --receive-pack and --exec, and
  comments, no longer count as refspecs in the push-from-main check.
- git config setting commit.gpgsign to an empty value, and removing or
  renaming the commit or core section, are blocked.
- The whole command line is searched for a Co-Authored-By trailer
  whenever it runs git commit, so heredocs and multi-line messages are
  covered.
- More wrappers are recognized, and every git or gh word after a
  wrapper is checked (sudo -u git git push).
- $'...' and $"..." quoting, checkout of @{-N} and --track */main.
- --no-verify and --no-gpg-sign on every git command; heads/main,
  matching (:), wildcard and brace-expanded refspecs; git -c alias,
  remote.*.mirror, remote.*.push and push.default; gh --admin=...;
  git --attr-source and --super-prefix values.
- Short-option clusters that git would reject, such as -inf in a
  commit message, no longer block. -U takes a value.
- A checkout that creates another branch, or checks out paths after
  --, no longer leaves the guard assuming main. It never overrides the
  branch the hook sees toward "not main".
- gh pr merge must be the first two positional words, so a PR body
  mentioning it is not blocked.

The table grows to 263 rows, including the remaining known false
blocks (quoted text is not parsed). It passes under bash 5.3 and
bash 3.2.57.
@PyYoshi

PyYoshi commented Oct 21, 2026

Copy link
Copy Markdown
Owner Author

Review of d3db75a: findings and how each was handled

An independent reviewer agent reviewed d3db75a against the AGENTS.md Review checklist and requested changes. 5b56a61 addresses the findings. Every case the reviewer wrote is now a row in scripts/testdata/guard-bash-cases.tsv (263 rows), with the expectation stated below. The table passes under bash 5.3.9 and bash 3.2.57 (bash:3.2 Docker image). Across the table and the reviewer's case files (421 rows), no command the old hook blocks is allowed by the new one.

# Severity Finding Resolution
1 high 12 commands the old hook blocked were allowed. Redirection dropping and the line-continuation join ignored quoting. Fixed. Each command line is read twice: split as written, and normalized (joined, comments cut, redirections dropped). It is blocked if either reading blocks it. The normalized reading also keeps a word glued to a redirection.
2 medium --m, --b and --branches push main. The MIN comment was wrong. The PR said git accepts --forc. Fixed. --all, --branches and --mirror use MIN 3. The comment now says MIN is at or below the shortest prefix git resolves. The PR description is corrected: git rejects --forc as ambiguous, and the hook blocks it anyway.
3 medium GIT_CONFIG_PARAMETERS with several entries got through. Fixed. When GIT_CONFIG_PARAMETERS, COUNT, KEY_n or VALUE_n is present, the whole segment is searched.
4 medium A word glued to a redirection was hidden (-f>/dev/null). Fixed by keeping the prefix (see 1).
5 medium -o values and comments counted as refspecs in the on-main check. Fixed. Values of -o, --push-option, --repo, --receive-pack and --exec, including a cluster ending in o, are not counted. They are still matched against the blocked patterns. Comments are cut in the normalized reading.
6 medium git config commit.gpgsign "" and --remove-section / --rename-section were allowed. Fixed.
7 medium A Co-Authored-By trailer on a later line was missed (pre-existing). Fixed. If any segment runs git commit, the whole command line is searched.
8 low-medium Wrappers were missing. A wrapper's option value named git and hid the real git. Fixed. More wrappers are recognized, and every git/gh word after a wrapper is checked.
9 low $'…' and $"…" quoting. Fixed.
10 low @{-1} and --track origin/main switch to main. Fixed.
11 low Pre-existing gaps outside the PR's claims. Fixed: --no-verify / --no-gpg-sign on every git command; heads/main, :, wildcard and brace refspecs; git -c alias.*, remote.*.mirror, remote.*.push and push.default; gh … --admin=true; --attr-source and --super-prefix values.
12a low Commit messages with words starting with - were blocked. Partly fixed, rest answered. A cluster git would reject as a whole (-inf, -version, -enable-foo) no longer blocks. A cluster git accepts (-nan, -nm) inside a quoted message still blocks. Telling message words from arguments needs a shell lexer, and a wrong guess there is a false allow. git commit -F FILE avoids it. These rows are in the table as known false blocks.
12b low switched_to_main never reset. Fixed. A checkout that creates another branch (-b/-B/-c/-C/--orphan) returns to the branch checked out when the hook runs, and a checkout of paths after -- changes nothing. The hook never overrides that branch toward "not main", so this cannot allow a push the old hook blocked.
12c low A PR body mentioning gh pr merge --admin was blocked. Fixed. pr merge must be the first two positional words.
12d low sh -c "echo git push --force" was blocked. Answered: by design. Every git word after a wrapper is checked, because parsing the -c string has the same lexer problem as 12a. It is in the table as a known false block.
12e low The GIT_CONFIG check fired on a heredoc body. Answered. Heredoc bodies were always read as commands; that was already true for git lines before this PR. The trigger is narrowed to the variables that set config (PARAMETERS, COUNT, KEY_n, VALUE_n). Writing test data with a file tool avoids it.
12f — git config --get core.hooksPath is in the allow file but blocked. Answered: unchanged. The old hook blocks every git config that names core.hooksPath. This PR does not relax any block target, so the row is a known false block.
13 nit -U takes a value. Fixed. As a result, git commit -Unm x is allowed, because git rejects a non-integer -U value. The row is flipped, with a comment.
14 low Comments and the CHANGELOG overstated coverage. Fixed. The header now describes the two readings and says literal forms the hook does not model can still get through. The MIN comment and the CHANGELOG entries are rewritten.

Also checked:

  • --push-option=ci:main stays blocked: values after = are still matched against the main patterns.
  • Speed: a 254 KB heredoc command takes about 2.4 s with both readings, against the 15 s hook timeout.

The fix commit goes back to the same reviewer. The reviewed SHA will be recorded here.

The re-review of 5b56a61 approved with findings to fix or answer.

- A bare git -c remote.<name>.mirror is a boolean true and forces a
  mirror push; the key without a value is now blocked.
- One key check (check_ref_config) covers aliases, mirror remotes,
  push refspecs and push.default=matching for git -c, the
  GIT_CONFIG_* variables and git config writes. Reads stay allowed,
  and push.default=current is no longer blocked.
- The Co-Authored-By search also runs on the command line without
  quotes and backslashes, which can split the word.
- More wrappers: gtimeout, caffeinate, xcrun, arch, chrt, taskset,
  watch, runuser, systemd-run and ssh.
- A branch name glued to -b/-B/-c/-C or given as --orphan=NAME is read.
- gh alias set with --admin and git send-pack are blocked.
- A wildcard refspec is blocked only when a side matches main, so
  git push origin "feature/*" is allowed.
- --super-prefix is not skipped as a value-taking option: git 2.53
  rejects it.
- shellcheck is clean.

The table grows to 311 rows. Among the committed table and the
review's case files (626 rows), the only commands the old hook blocks
and the new one allows are five that git or gh rejects, or where HEAD
is a push option value; they are recorded as allow rows.
@PyYoshi

PyYoshi commented Oct 21, 2026

Copy link
Copy Markdown
Owner Author

Re-review of 5b56a61: findings and how each was handled

The same reviewer re-reviewed 5b56a61, including a full review of 3d7115a. The verdict was approve, with findings to fix or answer. 6fdb713 handles them:

  • The table now has 311 rows. It passes under bash 5.3.9 and bash 3.2.57.
  • shellcheck reports nothing for the hook or the runner.
  • I compared the old and new hooks over the table and every review case file (626 rows). Only five rows are blocked by the old hook and allowed by the new one. git or gh rejects four of them, and in the fifth (-o HEAD) HEAD is a push option value. All five are recorded as allow rows (finding 8).
# Severity Finding Resolution
1 medium A bare git -c remote.origin.mirror push (boolean true, so a force mirror push) got past the rule. Fixed. A key without a value goes through the same check.
2 low-medium The new key rules did not cover GIT_CONFIG_* or git config (alias, mirror, push.default). Fixed. One key check, check_ref_config, is shared by git -c, GIT_CONFIG_* and git config writes. Reads (--get*, --list, get, list, or a key alone) stay allowed.
3 low The trailer could be split with quotes or backslashes ("Co-Authored""-By"). Fixed. The search also runs on the command line with quotes and backslashes removed.
4 low Wrappers were missing (macOS and others). Fixed. Added gtimeout, caffeinate, xcrun, arch, chrt, taskset, watch, runuser, systemd-run and ssh.
5a low git checkout main; git checkout -b fix/x; git push -u origin HEAD is allowed. If -b fails, the push runs from main. Answered: kept. The trade-off was left to the author. Clearing the assumption after a branch-creating checkout removes the round-1 false blocks. The real HEAD is always checked, so this is never weaker than the old hook. The ruleset also rejects a push to main. The row is recorded as a known gap.
5b low A name glued to -B/-b/-c/-C (-Bmain) was not read. Fixed. --orphan=NAME is read as well.
6 low gh alias set … --admin, ANSI-C escapes ($'\155ain') and git send-pack were allowed. Fixed: the gh alias and send-pack. Answered: ANSI-C escapes are not decoded. They are a runtime-style encoding, like the variables the header already excludes. The row is recorded as a known gap.
7 low Some commands are now false blocks. git -c push.default=current … is fixed: only matching is blocked. git push origin "feature/*" is fixed: a wildcard refspec is blocked only when one of its sides matches main. The command-wide trailer search is answered: it is kept, because the trailer can sit anywhere in a heredoc or a multi-line message. Write test data that mentions both with a file tool.
8 nit Rows the old hook blocks and the new one allows (none of them runs). Answered. They are recorded as allow rows with the reason. --super-prefix is no longer skipped as a value-taking option, since git 2.53 rejects it. That row is blocked again.
9 nit shellcheck SC1083 on @{-*}. Fixed. shellcheck is clean, and the wildcard check uses an intentional unquoted pattern with a disable=SC2254 comment.

Notes on 3d7115a:

  • The new check was approved by the maintainer. The maintainer approved adding the guard-hook regression table to make verify and the CI lint job in the authoring session, as a step in an existing job with no change to the required check names. This answers the design gate.
  • CI runs the table only under bash 5. A bash 3.2 CI step would be a new CI job and would go through the design gate. For now, bash 3.2 is checked locally (bash:3.2 image) and on macOS /bin/bash.
  • make test-hooks is now listed in the CONTRIBUTING.md commands table.

The fix commit goes back to the same reviewer.

The third review of 6fdb713 approved with findings to fix or answer.

- push.default=upstream (tracking) pushes the current branch to its
  upstream, which is origin/main for a branch made from it, so it is
  blocked along with matching.
- Splitting on $( and backticks cut the words after a substitution off
  from their command in both readings, so
  git commit -m "$(cat <<EOF ...)" --no-verify got through (the old hook
  had the same gap). A line with a substitution is now read twice more
  with each substitution replaced by one word (awk, nesting counted).
- git config skips the value of --comment, --file, --blob, --type,
  --default and --value, so a value such as --list cannot pass for a
  read. Unsets are treated as reads for the alias and push-refspec
  checks.
- git remote --mirror is blocked.
- A wildcard refspec is checked on its destination side (or the source
  when there is none), so refs/heads/*:refs/heads/backup/* is allowed.
- git switch --create=NAME and --force-create=NAME are read.

While timing the new readings, a command with 2,000 git commit segments
did not finish in 300 s. The whole-line Co-Authored-By search added in
5b56a61 ran once per commit segment, which made the hook quadratic in
the command length, and a hook that times out lets the command through.
The search now runs once per command: 2,000 segments take 2.9 s.

The table grows to 372 rows and passes under bash 5.3.9 and 3.2.57;
shellcheck is clean.
@PyYoshi

PyYoshi commented Oct 21, 2026

Copy link
Copy Markdown
Owner Author

Third review of 6fdb713: findings and how each was handled

The reviewer re-reviewed 6fdb713 and approved, with findings to fix or answer. 31f0131 handles them:

  • The table now has 372 rows. It passes under bash 5.3.9 and bash 3.2.57.
  • shellcheck is clean.
  • Across 750 compared rows, the only commands the old hook blocks and the new one allows are the five recorded rows.
# Severity Finding Resolution
1 medium push.default=upstream/tracking push to origin/main for a branch made from it. Fixed. Blocked in check_ref_config and in GIT_CONFIG_*, along with matching.
2 medium A $(...) or backtick substitution hid the words after it in both readings. Example: git commit -m "$(cat <<EOF …)" --no-verify. The old hook had the same gap. Fixed, following your prototype. A line with a substitution is read twice more, with each substitution replaced by one word. An awk depth counter handles nesting across lines.
3 low A read option given as an option's value hid a write (--comment --list). Fixed. The values of --comment, -f/--file, --blob, --type, --default and --value are skipped.
4 low git remote add --mirror=push sets up a mirror remote. Fixed. git remote … --mirror* is blocked.
5 low Some false blocks. --unset of an alias or mirror is fixed: it is treated as a read. The wildcard refspec is fixed: the destination side is checked, or the source side when there is no destination. git config --global alias.co checkout is answered: kept blocked, since every alias write is blocked. It is recorded as a known false block.
6 nit --create= and --force-create= were not read. Fixed.

Found while testing: a command with 2,000 git commit segments did not finish in 300 s.

  • Cause: the whole-line Co-Authored-By search added in 5b56a61 ran once per commit segment, which made the hook quadratic in the command length. A hook that times out lets the command through.
  • Fix: the search now runs once per command. 2,000 segments now take 2.9 s, and a 254 KB heredoc 2.5 s.

Stopping rule, set by the maintainer: this is the last fix round. In the final review of this commit, critical, high and medium findings are fixed. Low findings and nits are answered in the PR as known limits of a string filter, with no further commit. The header and the table's known-gaps and known-false-blocks sections record those limits.

The final review of 31f0131 requested changes for two medium findings.

The substitution pass made the hook quadratic: it read the whole
command as one awk record and appended to its output one character at
a time, which BWK awk (macOS), mawk and busybox awk handle in quadratic
time, so a large heredoc with a backtick ran past the 15 s timeout, and
a hook that times out lets the command through. Both awk passes now
read one line per record, carry their state across lines, split each
line into characters once and print as they go. Their output is
identical under BWK awk, mawk, gawk and busybox awk.

A ( ) ; & | inside quoted text cut the words after it off from their
command in every reading, so git commit -m "feat(proto): x" --no-verify
got through (the old hook had the same gap). A line with quotes is now
read twice more with the separators inside quotes turned into spaces.

To keep the six readings inside the timeout, a segment that names
neither git, gh nor GIT_CONFIG_ is skipped before it is split into
words, and the branch HEAD is looked up once. A 1 MB heredoc command
takes 4.4 s (31f0131: 33 s with mawk); 90 KB on one line takes 3.7 s
(18 s in the BWK substitution pass before).

Also from the review: process substitution (<(...), >(...)) is
flattened like $(...), and abbreviations of git remote --mirror are
blocked. The table grows to 394 rows; three answered rows are recorded
as known gaps.
@PyYoshi

PyYoshi commented Oct 21, 2026

Copy link
Copy Markdown
Owner Author

Final review of 31f0131: findings and how each was handled

The final review of 31f0131 requested changes for two medium findings. Under the maintainer's stopping rule, the mediums are fixed in 8733b5c and the lows are answered. Results for 8733b5c:

  • The table now has 394 rows. It passes under bash 5.3.9 and bash 3.2.57, and shellcheck is clean.
  • Against origin/main's hook, only the five recorded rows are allowed by the new one and blocked by the old. Against 31f0131, no row is.
  • The output of the awk passes is identical under BWK awk (Debian original-awk), mawk, gawk and busybox awk.
# Severity Finding Resolution
1 medium flatten_subst was quadratic in BWK, mawk and busybox awk. A large heredoc ran past the 15 s timeout, which lets the command through. Fixed. Both awk passes read one line per record, carry their state across lines, split each line into characters once (split($0, ch, ""), so substr() is not used over a long record) and print as they go. To keep the six readings inside the timeout, a segment that names neither git, gh nor GIT_CONFIG_ (with quotes and backslashes removed) is skipped before it is tokenized, and HEAD is looked up once. Timings are in the table below.
2 medium A ( ) ; & | inside quoted text hid the words after it from every reading (-m "feat(proto): x" --no-verify). The old hook had the same gap. Fixed, following your prototype. A line with quotes is read twice more, with the separators and newlines inside quotes turned into spaces. Comments are cut and backslash escapes honoured. Your 7 cases are blocked. The header now says that only this pair of readings follows quoting.
3 low <(…) was not flattened; $(echo ")") could still hide a word. <(/>( fixed (one character class in the same function as finding 1). $(echo ")") answered: a separator quoted inside a substitution can still hide a word from every reading. The header says so, and the row is recorded as a known gap.
4 low A --unset/--get word inside a quoted config value was taken as an option. Answered as a known limit: quoted values are not parsed. The rows are recorded as known gaps.
5 low git remote add --mi=push got through. Fixed (one line: abbrev_of … --mirror 4), since this commit was needed anyway.
6 nit RS = "\001". Fixed by finding 1's change: the trick is gone.

Timings, with mawk unless noted:

Input Time
1 MB heredoc ending in a backtick 4.4 s (31f0131: 33 s)
90 KB on one line, 2,000 substitutions 3.7 s (the BWK substitution pass alone took 18 s before, 0.09 s now)
254 KB heredoc 0.3 s
2,000 git log -m "$(…)" segments 2.8 s

This commit goes back to the reviewer as a fix verification. Only critical, high or medium findings in it lead to another change.

The prefilter added in 8733b5c removed quotes and backslashes before
looking for git, gh or GIT_CONFIG_, but the word processing also drops
the $ of $"..." and $'...', so gi$"t" push -f origin x (which bash runs
as git) was skipped by the prefilter while 31f0131 blocked it. The
prefilter now removes $ as well, so it keeps every segment the word
processing would read as git or gh.

An automated review also flagged the prefilter for matching case-
sensitively. That does not lose anything: the rules match git, gh and
GIT_CONFIG_ case-sensitively too, as the old hook did.
On bash 3.2 (macOS /bin/bash), ${var//pattern/repl} slows down sharply
on long strings with many matches. The segment prefilter, the
per-token quote stripping and the line-continuation join used it, so a
command with a 12 KB one-line JSON payload and a force push ran past
the 15 s hook timeout (origin/main's hook blocks it at once). The fix
review of 45f7758 found and tested this change:

- the prefilter reads a second copy of the segments with quotes,
  backslashes and $ already removed by tr, in step with the first;
- tokens longer than 256 bytes are stripped with sed and tr;
- continued lines are joined with awk.

Under bash 3.2 that command is now blocked in under a second (45f7758:
about 60 s). Under bash 5 a 1 MB heredoc command takes 7.8 s (45f7758:
4.3 s), inside the 15 s timeout. The table (397 rows) passes under
bash 5.3.9 and 3.2.57, with no row changing against 45f7758.
@PyYoshi

PyYoshi commented Oct 21, 2026

Copy link
Copy Markdown
Owner Author

Fix verification of 45f7758: last round

The reviewer verified 8733b5c and 45f7758. At the maintainer's request, the run was stopped before the full fuzz of 45f7758. The checks it did not get to are listed below.

  • The two medium findings from the final review are fixed: the awk passes are linear under BWK awk, mawk and gawk, and quoted separators no longer hide words.
  • Nothing origin/main's hook blocks is allowed beyond the five recorded rows. No row changed between 31f0131 and 45f7758.

One medium finding (fixed in a02f2e5): on bash 3.2 (macOS /bin/bash), ${var//…/…} slows down sharply on long strings. A 12 KB one-line JSON payload followed by a force push therefore ran past the 15 s hook timeout, while origin/main blocks it at once.

  • The fix, from the reviewer:
    • the prefilter reads a tr -d copy of the segments;
    • tokens longer than 256 bytes are stripped with sed/tr;
    • continued lines are joined with awk.
  • bash 3.2: the command is now blocked in under a second (45f7758: about 60 s).
  • bash 5: a 1 MB heredoc command takes 7.8 s (45f7758: 4.3 s), against the 15 s timeout.
  • Tests: the table passes 397/397 under bash 5.3.9 and 3.2.57. No row changes against 45f7758, and the comparison with origin/main still shows only the five recorded rows.

Low findings, answered as known limits with no further commit, per the maintainer's stopping rule:

  • A heredoc in which almost every line mentions git is linear but slow: about 35 µs per byte on bash 5, and slower on bash 3.2. The prefilter's *gh* also matches words such as "through". Real documents are fast: README, AGENTS, CONTRIBUTING and CHANGELOG together take 0.05 s on bash 3.2.
  • Case-insensitive filesystems: on macOS, Git push -f runs git, and no rule catches it, because the rules match git with exact case. origin/main has the same gap.
  • A backslash-newline is joined with a space. So gi\ + newline + t push -f runs git and gets through. This has been the case since d3db75a, and origin/main misses it too.

Checks the reviewer did not get to:

  • a full fuzz of 45f7758 (the reviewer relied on the 8733b5c run, since 45f7758 only loosens the prefilter);
  • a real Mac with /bin/bash 3.2 and awk 20200816, including whether split(s, a, "") works there;
  • Claude Code's actual behavior when a hook times out.

Review of the last commit: this is the last change to this PR. The maintainer reviews the final commit's 8-line diff (git diff 45f7758 a02f2e5) as the reviewer of record; AGENTS.md allows the maintainer to review.

@PyYoshi
PyYoshi merged commit cfdbc4b into main Oct 21, 2026
10 checks passed
@PyYoshi
PyYoshi deleted the fix/guard-bash-bypasses branch October 21, 2026 13:54
@PyYoshi PyYoshi mentioned this pull request Oct 23, 2026
5 of 6 tasks
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