diff --git a/CLAUDE.md b/CLAUDE.md index bf0c002..3af97ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,7 +190,7 @@ Requires `shellcheck`, `jq`, and `git` (the hooks need `jq` at runtime too) — ### 19.4 Current release pointers -- **Live version:** v1.0.6 (annotated tag, latest on `main`). +- **Live version:** v1.0.7 (annotated tag, latest on `main`). - **In flight:** none (set per session). - **CHANGELOG:** none — release notes are the GitHub Release body, generated from `git log` between tags. - **Spec / PRD:** `README.md` + `STRUCTURE.md` are canonical. diff --git a/README.md b/README.md index b29cac8..04a02bc 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,20 @@ Most people publish a single `CLAUDE.md` and call it a setup. The thing that act ## What's inside -| Path | What it is | -| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `CLAUDE.md` | The universal spine — a lean (~220-line) always-loaded core: workflow, git rules, coding guidelines, secrets, anti-patterns. Stack-agnostic. Longer reference material lives in `docs/` and loads on demand. | -| `rules/{web,android,ios,compute}.md` | Platform rule packs. Each carries a `paths:` frontmatter glob; Claude Code loads a pack when it reads a file matching that glob (`*.kt` → android, `*.swift` → ios, `*.ts`/`*.py` → web, `*.cpp`/`*.cu` → compute). Path-triggered, so packs whose files you never touch stay out of context. | -| `agents/` (15) | The default subagent roster: the four-hat chain (architect → senior-swe → code-reviewer → qa), plus specialists (security, performance, db-migration, debugger, devops, docs, design), release-engineer + tech-writer, and a delivery layer (TPM, scrum-master). | -| `agents-android/` (7), `agents-ios/` (7), `agents-compute/` (13) | Per-stack overrides. Drop them into a repo's `.claude/agents/` and they override the generic ones of the same name with platform-brained versions. | -| `hooks/guard-commit.sh` | A Claude Code Bash hook (PreToolUse) that blocks the _agent_ from force-pushing, skipping git hooks with `--no-verify`, committing as a non-human, writing AI attribution into a commit message, or staging obvious secrets. Every segment of a chained command is checked on its own. It guards Claude's git commands — not a human typing `git` directly in their own terminal. | -| `hooks/format.sh` | Auto-formats edited files by extension across every stack. Missing formatter is a silent no-op, never an error. | -| `tests/hooks/` | Behavioral tests for both hooks: a JSON payload on stdin, an exit code out, one case per rule and per past regression. Plain bash 3.2 + `jq`. CI (`.github/workflows/gate.yml`) runs them with the rest of the quality gate on every PR. | -| `skills/new-repo/` | A scaffolder skill: spins up a new repo with the right `CLAUDE.md`, `.gitignore`, quality gate, and release workflow. Scaffolds **web + Android**; iOS and compute ship as rule + agent packs (no scaffolder for them yet). | -| `docs/` | On-demand reference the spine points to (full roster tables, the Phase-3 review checklist, the error-recovery table, PR template, scaling notes). Installed to `~/.claude/docs/`; loaded only when a stub references it. | -| `templates/` | Blank project `CLAUDE.md` templates (generic + compute) to copy into a new repo and fill in. | -| `examples/CLAUDE.example-web.md` | A filled-in example so you can see what "done" looks like before you write your own. | -| `STRUCTURE.md` | The full layout, install steps, and how the two-settings-file swap works. **Read this first.** | +| Path | What it is | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CLAUDE.md` | The universal spine — a lean (~220-line) always-loaded core: workflow, git rules, coding guidelines, secrets, anti-patterns. Stack-agnostic. Longer reference material lives in `docs/` and loads on demand. | +| `rules/{web,android,ios,compute}.md` | Platform rule packs. Each carries a `paths:` frontmatter glob; Claude Code loads a pack when it reads a file matching that glob (`*.kt` → android, `*.swift` → ios, `*.ts`/`*.py` → web, `*.cpp`/`*.cu` → compute). Path-triggered, so packs whose files you never touch stay out of context. | +| `agents/` (15) | The default subagent roster: the four-hat chain (architect → senior-swe → code-reviewer → qa), plus specialists (security, performance, db-migration, debugger, devops, docs, design), release-engineer + tech-writer, and a delivery layer (TPM, scrum-master). | +| `agents-android/` (7), `agents-ios/` (7), `agents-compute/` (13) | Per-stack overrides. Drop them into a repo's `.claude/agents/` and they override the generic ones of the same name with platform-brained versions. | +| `hooks/guard-commit.sh` | A Claude Code Bash hook (PreToolUse) that blocks the _agent_ from force-pushing, skipping git hooks with `--no-verify`, committing as a non-human, writing AI attribution into a commit message, or staging obvious secrets. Every segment of a chained command is checked on its own. Quoted text, heredoc bodies, substitutions, and arithmetic are stripped as data first, so a commit message or a file body that merely mentions a blocked flag never trips it; a shape the check cannot classify (a shell wrapper of any form, a substitution inside `${...}`, an unquoted pattern in a commit or push, git config passed through the environment) is refused rather than guessed. It guards Claude's git commands — not a human typing `git` directly in their own terminal. | +| `hooks/format.sh` | Auto-formats edited files by extension across every stack. Missing formatter is a silent no-op, never an error. | +| `tests/hooks/` | Behavioral tests for both hooks: a JSON payload on stdin, an exit code out, one case per rule and per past regression. Plain bash 3.2 + `jq`. CI (`.github/workflows/gate.yml`) runs them with the rest of the quality gate on every PR. | +| `skills/new-repo/` | A scaffolder skill: spins up a new repo with the right `CLAUDE.md`, `.gitignore`, quality gate, and release workflow. Scaffolds **web + Android**; iOS and compute ship as rule + agent packs (no scaffolder for them yet). | +| `docs/` | On-demand reference the spine points to (full roster tables, the Phase-3 review checklist, the error-recovery table, PR template, scaling notes). Installed to `~/.claude/docs/`; loaded only when a stub references it. | +| `templates/` | Blank project `CLAUDE.md` templates (generic + compute) to copy into a new repo and fill in. | +| `examples/CLAUDE.example-web.md` | A filled-in example so you can see what "done" looks like before you write your own. | +| `STRUCTURE.md` | The full layout, install steps, and how the two-settings-file swap works. **Read this first.** | ## The idea in one paragraph @@ -27,7 +27,7 @@ Two tiers. The **global** tier (`~/.claude/CLAUDE.md` + `rules/` + `agents/` + ` ## Install -Mac/Linux, with Claude Code already installed. Full steps and the per-repo install are in [`STRUCTURE.md`](./STRUCTURE.md). First install the hook prerequisite `jq` (`guard-commit.sh` fails closed without it): `brew install jq` (macOS) / `sudo apt-get install jq` (Debian/Ubuntu). Then: +Mac/Linux, with Claude Code already installed. Full steps and the per-repo install are in [`STRUCTURE.md`](./STRUCTURE.md). First install the hook prerequisite `jq` (`guard-commit.sh` fails closed without it): `brew install jq` (macOS) / `sudo apt-get install jq` (Debian/Ubuntu); the guard also needs git 2.28 or newer. Then: ```bash git clone https://github.com/roadhero/claude-code-setup.git && cd claude-code-setup diff --git a/hooks/guard-commit.sh b/hooks/guard-commit.sh index bd40ade..eac370a 100644 --- a/hooks/guard-commit.sh +++ b/hooks/guard-commit.sh @@ -1,18 +1,83 @@ #!/usr/bin/env bash -# PreToolUse(Bash) — block AI attribution, secrets, force-push, and hook-skipping (--no-verify) -# before they happen. Exit 2 + a stderr reason is the documented blocking contract; stdout is unused. +# PreToolUse(Bash) — block force-push, hook-skipping (--no-verify, core.hooksPath), shell wrappers +# around git, non-human committers, AI attribution, and staged secrets before they happen. +# Exit 2 + a stderr reason is the documented blocking contract; stdout is unused. +# +# Needs jq and git 2.28+ (for --no-relative and --output-indicator-new on the diff it reads). # # This is a backstop behind the settings.json deny rules and plan mode, not a sandbox. Known, -# accepted limits: variable indirection (`p=push; git $p -f`), git aliases defined in an earlier +# accepted limits: variable indirection (`p=push; git $p -f`, or a `${x/pat/--force}` replacement, +# both of which need the variable set to a controlled value first), git aliases defined in an earlier # call, `git config core.hooksPath` set in an earlier call, flags fed through a pipe (`xargs`), -# and flags spelled with quoting tricks (`--no-veri"fy"`, `$'--no-verify'`, unique-prefix `--no-veri`). -INPUT=$(cat) +# unique-prefix long options (`--no-veri`), a word or flag spliced by any expansion or substitution +# (`git pu${x}sh`, `git pu$(echo s)h`, `--for$(echo c)e`), and the committer and secrets +# checks reading the current directory's repo (`git -C elsewhere commit`), a `filter.*.clean` +# set in an earlier call (it rewrites the working-tree diff the guard reads), a script body run +# by name (`bash run.sh`, `source ./run.sh`), and git run from another interpreter +# (`python3 -c "os.system('git push -f')"`, `perl -e`, `node -e`, awk's `system()`), whose +# program is data to this walk; likewise an expansion that produces a pathspec (`git commit -m x +# $(echo f)`), whose unseen word is not scanned for secrets. Known false blocks (safe direction): a shell wrapper anywhere in +# the same call as a plain commit or push (`git commit -m x && bash -c "echo done"`, +# `bash -x build.sh && git commit -m x`, `which bash && git commit -m x`, `git add zsh bash fish` +# in a dotfiles repo), `source`/`.` of a process substitution, any mention of `alias.` outside quotes +# in the same call as a commit or push (`git config alias.st status; git commit`, `git commit -m x +# alias.md`), a substitution inside `${...}` (`${v:-$(git describe)}`), a one-word quoted argument +# that spells a flag or subcommand (`-m "--no-verify"`, `-m "eval"`, `tag -m "push" -f`), a quoted +# identity inside a message (`-m "set user.name='claude'"`, `-m "run git config user.name claude"`), a `-n` or `-f` on another +# command inside the same `$(...)` as a commit or push (`-m "$(git log -1 | head -n 1)"`), +# `--force-if-includes` on its own, `-S` with an +# `n` in the key id, an unquoted `*`, `?`, `[`, or `{a,b}` in a commit or push (bash expands it +# when the command runs; quote it, or list the files), an expansion between a shell and its +# option (`bash $a -c`: bash may see nothing there), a `${x}` in a commit or push whose default or +# alternate value is a flag, a `+refspec`, or a config token carrying `=` (`${x:---force}`, `${x:-+main}`, +# `${x:-"--force"}`, `${x:-user.name=Claude}`; refused, since it may expand to that), git config passed through the environment in +# a commit or push call (`--config-env`, `GIT_CONFIG_*`, `include.path`, `HOME=`, `XDG_CONFIG_HOME=`), a +# `-c`/committer/author value taken from an expansion (`git -c ${x:-user.name=Claude}`; refused), +# a commit message mentioning `-c core.hooksPath` or an unquoted `include.path`/`HOME=`/`XDG_CONFIG_HOME=`, a diff over 16 MB (commit +# large binaries separately, or via LFS), and an ANSI-C numeric escape (`$'\033[0m'`) in a call +# that also mentions git, commit, or push. A call whose every one of those words is itself +# numerically encoded (`$'\x67it' $'\x70ush'`) is the splice limit above. +# +# No `grep -q` ever reads from a pipe: it exits on the first match, and under pipefail the +# upstream writer's SIGPIPE would read as "no match". Checks read here-strings, and every grep +# treats an exit status over 1 as a tool failure that blocks. +export LC_ALL=C # byte-oriented grep/awk/tr: an invalid UTF-8 byte must not make a tool drop the rest of the input +set -o pipefail # a failing tool anywhere in a pipeline must surface, never read as "no match" +set -f # no pathname expansion anywhere: the hook never globs, and an unquoted token must stay a token +INPUT=$(cat) || { echo "Blocked: guard-commit.sh could not read its stdin. Failing closed." >&2; exit 2; } + +# has (case-insensitive) / hasc (case-sensitive): grep that treats any status over 1 +# as a tool failure and fails closed, so a broken grep can never read as "no match". +has() { grep -qiE -e "$1" <<<"$2"; local rc=$?; [ "$rc" -gt 1 ] && { echo "Blocked: guard-commit.sh grep failed. Failing closed." >&2; exit 2; }; return "$rc"; } +hasc() { grep -qE -e "$1" <<<"$2"; local rc=$?; [ "$rc" -gt 1 ] && { echo "Blocked: guard-commit.sh grep failed. Failing closed." >&2; exit 2; }; return "$rc"; } # Fast path: this guard only concerns `git commit` / `git push`. If the payload mentions # neither, allow immediately — so a missing jq (below) never blocks unrelated Bash (ls/cat/grep). # Match loosely (no quote-class): a quoted arg before the subcommand (`git -C "x" commit`) -# must NOT slip past into a silent allow. -printf '%s' "$INPUT" | grep -qiE 'git.*(commit|push)' || exit 0 +# must NOT slip past into a silent allow. grep exit 1 = no match; anything else = error. +FLAT=$(tr '\n' ' ' <<<"$INPUT") || FLAT=$INPUT # a pretty-printed payload must not split the match across lines +grep -qiE 'git.*(commit|push)' <<<"$FLAT" +RC=$? +if [ "$RC" -eq 1 ]; then + # No plain mention. A split word (`g"it" push`, `git pu"sh"`, `git pus\h`, `git $'push'`, + # `git pu\` + `sh`) could still be one: retry with quotes, backslashes, `$`, and JSON-escaped + # continuations removed (sed: linear, ~25 ms on 400 KB; a bash substitution is quadratic on 3.2). + # That only joins text, it can never destroy a real mention. An ANSI-C numeric escape + # (`$'\x70ush'`) decodes to text no dequoting can reveal, so it goes to the walker, which refuses it. + if ! J=$(sed -e 's/\\\\\\n//g' -e 's/\\"//g' -e "s/['\$\\\\]//g" <<<"$FLAT"); then + echo "Blocked: guard-commit.sh could not run its fast-path check (sed failed). Failing closed." >&2; exit 2 + fi + grep -qiE 'git.*(commit|push)' <<<"$J" + RC=$? + # Text no dequoting can reveal goes to the walker: an ANSI-C numeric escape (`$'\x70ush'`, the + # walker refuses it) or a JSON \u escape for an ASCII letter (jq decodes it; the walker sees it). + if [ "$RC" -eq 1 ] && has 'git|commit|push' "$J" && { hasc "\\\$'" "$FLAT" && hasc '\\\\[xuU0-7]' "$FLAT" || hasc '\\u00[0-9a-fA-F]{2}' "$FLAT"; }; then RC=0; fi +fi +case $RC in + 0) ;; + 1) exit 0 ;; + *) echo "Blocked: guard-commit.sh could not run its fast-path check (grep failed). Failing closed." >&2; exit 2 ;; +esac # The command plausibly commits/pushes — jq is needed to inspect it. Fail loud and closed. if ! command -v jq >/dev/null 2>&1; then @@ -22,37 +87,386 @@ fi # The payload mentions git commit/push, so an empty command here means the payload did not parse # (or its shape changed). Fail closed rather than silently turning into a no-op. -CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) +CMD=$(jq -r '.tool_input.command // empty' <<<"$INPUT" 2>/dev/null) if [ -z "$CMD" ]; then echo "Blocked: guard-commit.sh could not read .tool_input.command from the PreToolUse payload. Failing closed." >&2 exit 2 fi -# Join backslash-newline continuations so a flag on its own line stays with its subcommand. -CMD=${CMD//\\$'\n'/ } +# No agent-realistic command is this long; past this size the character walk below gets slow, so +# stop here (the per-segment loop further down has its own cap for the same reason). +if [ ${#CMD} -gt 262144 ]; then + echo "Blocked: guard-commit.sh will not inspect a git commit/push command over 256 KB. Split it up." >&2 + exit 2 +fi # Classify per command segment, not per Bash call: `git commit -m ok && git push -f` is a commit -# AND a force-push, and each segment is judged on its own. Quoted text is stripped first so a -# commit MESSAGE that merely mentions `push --force` or `--no-verify` (or contains `;`) can't -# trip a flag check or split a segment. The strip is multi-line aware (newlines are hidden as -# \001 during sed) so a heredoc message `-m "$(cat <<'EOF' ... EOF)"` is one quoted span, and -# escaped \" are dropped first so they cannot flip quote parity. A `git commit -F - <(...)` +# are words of the enclosing command, so separators inside them never split it; +# - `$((...))`, `((...))`, `$[...]`, `${...}`, and `[...]` frames, where `<<` is a shift or +# text and `#` is not a comment; an unbalanced `[` dies at the next command boundary; +# - `#` comments to end of line, only where bash starts one: at the start of a word, which a +# `)` closing `$(...)` or a closing backtick is not; +# - `case ... esac`, counted only in command position: a pattern's `)` is a command boundary and +# does not close an enclosing `$(...)`; a `)` that the top frame cannot close unwinds to the `(` +# it does close; +# a frame or quote still open at the end of the input fails closed; +# - heredoc bodies, dropped line by line up to the terminator (`EOF)` also closes a `$(`, +# `<<-` allows leading tabs, an unterminated body runs to end of input as in bash). A `< pendat[depth]) refuse("a heredoc marker inside a substitution that closes on the same line") + # `name()` is a function definition: what follows is a command (`f() case ...`) + cmdpos = (kind[depth] == "(" && pushnr[depth] == NR && substr(line, pushpos[depth], i - pushpos[depth] + 1) ~ /^\([ \t]*\)$/) + q = save[depth]; poppedkind = kind[depth]; depth--; poppos = i + } + function refuse(why) { refused = 1; print "guard-commit.sh cannot classify this command: " why ". Rewrite it without that construct." > "/dev/stderr"; exit 3 } + # a short quoted fragment is part of its word; a dropped span leaves an empty "" so the + # argument still counts as one (`-m "two words" file` keeps `file` as the pathspec it is), + # unless it held a substitution, whose own mark already stands there; an empty pair + # (`commi""t`) is nothing, as in bash + function closeq() { + if (qbad) return + if (qb != "" && qb !~ /[ \t;&|*?\[{()<>`]/) o = o qb # a clean literal fragment glues to its word + else if (qb != "") o = o "\"\"" # a fragment with a space/special: a placeholder word + else if (qdollar) o = o (qfresh ? "$ " : "$") # a possibly-empty parameter: `$` word, spaced only at a word start + else if (!qmark && qfresh && (i >= n || c[i + 1] ~ /[ \t;&|()<>`]/)) o = o "\"\"" # truly empty quotes + } + function droptests() { while (depth > 0 && kind[depth] == "[") depth-- } # a test bracket cannot span a command + function insubst( k) { for (k = depth; k > 0; k--) if (kind[k] == "$(" || kind[k] == "`" || kind[k] == "<(") return 1; return 0 } + function inframe( k) { for (k = depth; k > 0; k--) if (kind[k] != "(") return 1; return 0 } # anything but a bare subshell + function inbrace( k) { for (k = depth; k > 0; k--) if (kind[k] == "${") return 1; return 0 } # inside a `${...}` word + function fresh() { return (o == "" || o ~ /[ \t]$/ || substr(o, length(o), 1) == "\001") } # a quote at the start of a word + function heredocbeforesubst( k) { for (k = depth; k > 0; k--) if ((kind[k] == "$(" || kind[k] == "`" || kind[k] == "<(") && pendat[k] > 0) return 1; return 0 } + BEGIN { q = 0; depth = 0; npend = 0; body = 0; cont = 0; casec[0] = 0; poppos = -1; poppedkind = ""; cmdpos = 1; kwlead = 0 } + { + line = $0 + if (index(line, "\001")) refuse("a control byte in the command") # \001 is the walk'"'"'s own boundary mark + if (body) { + t = line + if (pdash[1]) sub(/^\t+/, "", t) + w1 = pword[1]; rest = ""; term = 0 + if (t == w1) term = 1 + else if (depth > 0 && substr(t, 1, length(w1) + 1) == w1 ")") { term = 1; rest = substr(t, length(w1) + 1) } # `EOF)...` closes a $( too + if (term) { # terminator: leave this body, walk the rest of the line as code + for (k = 1; k < npend; k++) { pword[k] = pword[k + 1]; pdash[k] = pdash[k + 1]; pq[k] = pq[k + 1] } + npend-- + depth = bodydepth # anything left open inside the body dies with it, as in bash + if (npend == 0) { body = 0; q = 0 } else q = (pq[1] ? 5 : 4) + line = rest + } else if (q == 5) next # quoted-delimiter body: inert, drop the line + } + n = split(line, c, "") + o = "" + bmark = 0 # where the current `${...}` word began on this line + i = 1 + poppos = -1 + joined = cont; cont = 0 # this line continues the previous one: no word starts at column 1 + if (q == 0 && !joined) cmdpos = 1 + while (i <= n) { + ch = c[i] + if (q == 1) { if (ch == "\047") { q = 0; closeq() } else qb = qb ch; i++; continue } + if (q == 3) { + if (ch == "\\" && c[i + 1] ~ /[xuU0-7]/) refuse("a numeric escape (\\x, \\u, octal) inside $'"'"'...'"'"'") + if (ch == "\\") { qb = qb c[i + 1]; i += 2; continue } + if (ch == "\047") { q = 0; closeq() } else qb = qb ch + i++; continue + } + if (q == 2 || q == 4) { # data that still expands substitutions + if (ch == "\\") { if (q == 2) qb = qb c[i + 1]; i += 2; continue } + if (q == 2 && ch == "\"") { q = 0; closeq(); i++; continue } + if (inbrace() && ((ch == "$" && c[i + 1] == "(") || ch == "`")) refuse("a substitution inside ${...}") + if (ch == "$" && c[i + 1] == "(" && c[i + 2] == "(") { qmark = 1; push("$((", q); q = 0; o = o " "; i += 3; continue } + if (ch == "$" && c[i + 1] == "(") { qmark = 1; push("$(", q); q = 0; o = o "$( "; i += 2; continue } + if (ch == "$" && c[i + 1] == "{") { + if (c[i + 2] ~ /[ \t|]/) refuse("a ${ command; } substitution") + outer = !inbrace(); qdollar = 1; push("${", q); q = 0; if (outer) bmark = length(o); i += 2; continue + } + if (ch == "`") { qmark = 1; push("`", q); q = 0; o = o " `"; i++; continue } + # a bare `$name` / `$1` / special param inside "..." may expand to nothing, so it contributes + # nothing to the word: a flag glued after it (`"$x--force"`) stays a visible, bounded token + if (q == 2 && ch == "$" && c[i + 1] ~ /[A-Za-z_]/) { qdollar = 1; i += 2; while (i <= n && c[i] ~ /[A-Za-z0-9_]/) i++; continue } + if (q == 2 && ch == "$" && c[i + 1] ~ /[0-9@*#?!$-]/) { qdollar = 1; i += 2; continue } + if (q == 2) qb = qb ch + i++; continue + } + top = (depth > 0) ? kind[depth] : "" + noheredoc = (top == "((" || top == "$((" || top == "[" || top == "[s" || top == "$[" || top == "(a" || top == "${") + if (ch == "\\") { # `\-f` is `-f`; an escaped separator is text, never a boundary + if (i == n) { cont = 1; i++; continue } + o = o ((c[i + 1] ~ /[;&|]/) ? " " : (c[i + 1] ~ /[*?\[{]/) ? "\"\"" : c[i + 1]); i += 2; continue + } + if (ch == "$" && c[i + 1] == "\047") { q = 3; qb = ""; qbad = 0; qmark = 0; qdollar = 0; qfresh = fresh(); i += 2; continue } + if (ch == "\047") { q = 1; qb = ""; qbad = 0; qmark = 0; qdollar = 0; qfresh = fresh(); i++; continue } + if (ch == "\"") { q = 2; qb = ""; qbad = 0; qmark = 0; qdollar = 0; qfresh = fresh(); i++; continue } + # a comment starts only where a word starts: after whitespace, a control operator, or the `)` + # that ends a subshell or an `((...))` command. The `)` of `$(...)` / `$((...))` or a closing + # backtick ends a word, and `#` after it is part of that word. + if (ch == "#" && !noheredoc && ((i == 1 && !joined) || (i > 1 && c[i - 1] ~ /[ \t;&|(]/) || (c[i - 1] == ")" && poppos == i - 1 && (poppedkind == "(" || poppedkind == "((")))) break + # `;` `&` `|` separate commands only in code; inside an expansion or arithmetic they are text + # `&` in `2>&1` `>&` `&>` `<&-` and `|` in `>|` belong to a redirection operator, not a boundary + if ((ch == "&" && (c[i - 1] == "<" || c[i - 1] == ">" || c[i + 1] == ">")) || (ch == "|" && c[i - 1] == ">")) { o = o " "; i++; continue } + if (ch == ";" || ch == "&" || ch == "|") { + if (top == "${" || top == "((" || top == "$((" || top == "(a" || top == "$[" || top == "[s") { o = o " "; i++; continue } + # inside `$(...)` a separator ends a command OF THE SUBSTITUTION, never the enclosing one + # (`git push origin "$(a | b)" --force` is one push): a space keeps the flag with its git + if (insubst()) { droptests(); cmdpos = 1; o = o " "; i++; continue } + droptests(); cmdpos = 1; o = o "\001"; i++; continue # a REAL boundary: a byte no command contains + } + if (ch == "{" || ch == "!") { cmdpos = 1; o = o ch; i++; continue } + if (inbrace() && ((ch == "$" && c[i + 1] == "(") || ch == "`")) refuse("a substitution inside ${...}") + if (ch == "$" && c[i + 1] == "(" && c[i + 2] == "(") { push("$((", 0); o = o " "; i += 3; continue } + if (ch == "$" && c[i + 1] == "[") { push("$[", 0); o = o " "; i += 2; continue } + if (ch == "$" && c[i + 1] == "{") { # the `$` stands for the whole word, which may expand to nothing + if (c[i + 2] ~ /[ \t|]/) refuse("a ${ command; } substitution") + outer = !inbrace(); push("${", 0); o = o " $"; if (outer) bmark = length(o); i += 2; continue + } + if (ch == "$" && c[i + 1] == "(") { push("$(", 0); o = o "$( "; i += 2; continue } + # a bare `$name` / `$1` / `$@` parameter expansion may expand to nothing, so it is a word that, + # like `${...}`, must not glue the literal next to it (`git push origin $x--force`): emit a `$` + # mark set off by spaces so a flag or subcommand after it keeps its boundary. + if (ch == "$" && c[i + 1] ~ /[A-Za-z_]/) { i += 2; while (i <= n && c[i] ~ /[A-Za-z0-9_]/) i++; o = o " $ "; continue } + if (ch == "$" && c[i + 1] ~ /[0-9@*#?!$-]/) { o = o " $ "; i += 2; continue } + # `[` in code opens a test bracket; inside an expansion or arithmetic it is an array subscript + # (`${a[1|2]}`), whose `|` and `&` are text, never a command boundary + if (ch == "[" && top == "${") { o = o ch; i++; continue } # text inside `${...}`, like `)`: `${x:-[}` is a value, not a subscript + if (ch == "[") { + if (noheredoc && top != "[") { push("[s", 0); o = o " " } + else if (cmdpos) { push("[", 0); o = o " " } + else if (c[i - 1] ~ /[A-Za-z0-9_]/) { push("[s", 0); o = o ch } # `a[1<<2]=1`: a subscript, its `<<` a shift; kept visible as a pattern too + else o = o ch # `-[n]` is a pattern bash expands when it runs + i++; continue + } + if ((ch == "<" || ch == ">") && c[i + 1] == "(") { push("<(", 0); o = o ch " "; i += 2; continue } # process substitution: a word + if (ch == "(" && noheredoc && top != "${") { push("(a", 0); o = o " "; i++; continue } + if (ch == "(" && c[i + 1] == "(") { push("((", 0); o = o " "; i += 2; continue } + if (ch == "(") { push("(", 0); o = o "( "; i++; continue } + if (ch == ")" && c[i + 1] == ")" && (top == "((" || top == "$((")) { pop(); poppos = i + 1; o = o " "; i += 2; continue } + if (ch == ")" && top == "(a") { pop(); o = o " "; i++; continue } + if (ch == "]" && (top == "[" || top == "[s" || top == "$[")) { pop(); o = o " "; i++; continue } + if (ch == "}" && top == "${") { pop(); if (!inbrace()) o = substr(o, 1, bmark) " "; i++; continue } # the word is its `$` + if (ch == "}" && inbrace()) refuse("a `}` with a paren or bracket still open inside ${...}") # the word must never outlive its `}` + if (ch == ")" && inbrace()) { o = o ch; i++; continue } # text inside `${...}`, like `;` `&` `|` + if (ch == ")") { + if (top == "[" || top == "[s") { # a frame `)` cannot close: unwind to the `(` it does close + while (depth > 0 && kind[depth] != "(" && kind[depth] != "$(" && kind[depth] != "<(" && kind[depth] != "`" && kind[depth] != "((" && kind[depth] != "$((") { + if (save[depth]) refuse("a `)` that unwinds a frame opened inside a quoted string") + depth-- # drops [ [s $[ (a + } + top = (depth > 0) ? kind[depth] : "" + } + if (casec[depth] > 0) { droptests(); o = o ch (insubst() ? " " : "\001"); i++; cmdpos = 1; continue } # a case pattern terminator ends the pattern, as `;;` ends the command + if (top == "(" || top == "$(" || top == "<(") { pop(); o = o ch " "; i++; continue } + o = o ch " "; i++; continue + } + if (ch == "`") { + if (top == "`") { pop(); o = o "` " } else { push("`", 0); o = o " `" } # the backticks mark a word that may expand to nothing + i++; continue + } + if (ch == "<" && c[i + 1] == "<" && c[i + 2] != "<" && (i == 1 || c[i - 1] != "<") && !noheredoc) { + j = i + 2; dash = 0 + if (c[j] == "-") { dash = 1; j++ } + while (c[j] == " " || c[j] == "\t") j++ + w = ""; quoted = 0 + while (j <= n && c[j] !~ /[ \t;&|<>()]/) { + if (c[j] == "$" && (c[j + 1] == "\047" || c[j + 1] == "\"")) j++ # a $-prefixed quoted delimiter is quoted too + if (c[j] == "\047" || c[j] == "\"") { # a quoted delimiter may contain anything up to its closing quote + qc = c[j]; quoted = 1; j++ + while (j <= n && c[j] != qc) { w = w c[j]; j++ } + j++; continue + } + if (c[j] == "\\") { + if (j == n) refuse("a heredoc delimiter continued on the next line") + quoted = 1; j++; w = w c[j]; j++; continue + } + w = w c[j]; j++ + } + if (w != "") { + if (body) refuse("a heredoc nested inside another heredoc body") + if (npend >= 16) refuse("more than 16 heredocs pending at once") + pword[++npend] = w; pdash[npend] = dash; pq[npend] = quoted + } + o = o "<<"; i = j; cmdpos = 0; continue + } + if (ch ~ /[A-Za-z_]/ && ((i == 1 && !joined) || (i > 1 && c[i - 1] !~ /[A-Za-z0-9_]/))) { # a word: track case ... esac in command position + j = i; w = "" + while (j <= n && c[j] ~ /[A-Za-z0-9_]/) { w = w c[j]; j++ } + if (cmdpos && w == "case") casec[depth]++ + else if (cmdpos && w == "esac" && casec[depth] > 0) casec[depth]-- + if (w == "if" || w == "then" || w == "else" || w == "elif" || w == "while" || w == "until" || w == "do") { cmdpos = 1; kwlead = 0 } + else if (w == "time" || w == "coproc") { cmdpos = 1; kwlead = 2 } # `time -p cmd`, `coproc NAME cmd`: the command may be two words on + else if (kwlead > 0) kwlead-- + else cmdpos = 0 + o = o w; i = j; continue + } + if (ch != " " && ch != "\t" && kwlead == 0) cmdpos = 0 + o = o ch; i++ + } + if (q == 1 || q == 2 || q == 3) qbad = 1 # a quoted span crossing a line is never glued + if (q == 0 && !cont) droptests() + # a body starts at the newline that ends the COMMAND (code state, no continuation), as in bash; + # a `${...}` still open, or a `< 0 && inbrace()) refuse("a heredoc marker on a line that ends inside ${...}") + if (!body && npend > 0 && heredocbeforesubst()) refuse("a heredoc marker before a substitution that opens on the same line") + enter = (!body && npend > 0 && q == 0 && !cont) + # A newline separates commands in code at the top level or inside a bare subshell. Inside any + # other frame it does not end the ENCLOSING command: in `$(...)` it separates commands of the + # substitution (`-m "$(cat < 0 || q == 1 || q == 2 || q == 3) refuse("a quote, $(...), ((...)), ${...} or [ left open at the end (or a case with no esac)") }' +} +if ! STRIPPED=$(strip_data <<<"$CMD"); then + echo "Blocked: guard-commit.sh could not parse the command (awk failed). Failing closed." >&2 + exit 2 +fi + + +# Each segment costs a few greps; thousands of `;`-separated segments could outlast the hook +# timeout, and a timed-out PreToolUse hook does not block. No real command has hundreds. +# The walker marks every real command boundary with \001 (a `;` `&` `|` it emits as text is not one). +if ! SEGMENTS=$(tr '\001' '\n' <<<"$STRIPPED") || ! NSEP=$(tr -cd '\001\n' <<<"$STRIPPED" | wc -c) || ! [ "$NSEP" -eq "$NSEP" ] 2>/dev/null; then + echo "Blocked: guard-commit.sh could not split the command into segments (tr/wc failed). Failing closed." >&2 + exit 2 +fi +if [ "$NSEP" -gt 500 ]; then + echo "Blocked: guard-commit.sh will not inspect a git commit/push command with over 500 segments. Split it up." >&2 + exit 2 +fi + +# A shell wrapper runs text as a command, and the strip above just hid that text. The rule, per +# segment of a call that commits or pushes: a shell (`sh`, `bash`, `zsh`, ... by name or path) is a +# wrapper when it is given leading options (`bash -c`, `bash -x script`, `bash -s`), when anything +# is redirected into it (`bash <<'EOF'`, `bash <<<"..."`, `bash < f`, `bash <(...)`), or when it has +# no arguments at all (`... | bash`, `bash` after `exec <`). A shell given a script path and the +# script's own arguments is not (`bash build.sh -c release`); the script body is a listed limit. +# `eval` and `trap` run text by definition; `source`/`.` of a process substitution reads text; +# `env -S` splits a string into a command. Refuse rather than guess: the unwrapped form, in its +# own call, is always available to the agent. +SHW='(^|[[:space:]()])([^[:space:]#!=]*/)?(ba|z|da|k|c|tc|fi|a|mk|ya)?sh[0-9]*' # a shell, by name or path (`ksh93`, `bash5` too); `#!/bin/bash` and `x=/bin/sh` are not commands +RD='([[:space:]]*[0-9]*[<>]+[[:space:]]*[^[:space:]<>]*)*' # redirection words are not arguments +# shellcheck disable=SC2016 # the backtick is a literal in the pattern +XW='([[:space:]]+([^[:space:]]*[$`][^[:space:]]*|\$\(.*\)|`.*`))*' # words holding an expansion may be nothing at all (`bash $a -c`, `sh $(:) -c`) +ENVOPT='([[:space:]]+(-[uCPa][[:space:]]+[^[:space:]]+|--(unset|chdir|argv0)[[:space:]]+[^[:space:]]+|-[A-Za-z]*|--[A-Za-z-]+(=[^[:space:]]*)?|[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|[^[:space:]]*[$`][^[:space:]]*))*' +# every alternative supplies its own right boundary, so `shasum`, `shellcheck`, `bashful` never match +WRAPPER="${SHW}${RD}${XW}${RD}[[:space:]]*[)}]*[[:space:]]*\$|${SHW}${RD}${XW}${RD}[[:space:]]+-|${SHW}[[:space:]]*[0-9]*<|${SHW}[[:space:]].*<|(^|[[:space:]()])(eval|trap)([[:space:]]|\$)|(^|[[:space:]()])(source|\.)[[:space:]].*<|(^|[[:space:]()])env${ENVOPT}[[:space:]]+(-[A-Za-z]*S|--split-string)" -# A shell wrapper runs its quoted argument as a command, which the strip above just hid. -# Refuse rather than guess: the unwrapped form is always available to the agent. -if printf '%s' "$STRIPPED" | grep -qE '(^|[^[:alnum:]_./-])((ba|z|da)?sh[[:space:]]+-[a-z]*c|eval)([^[:alnum:]_./-]|$)'; then - echo "Blocked: git commit/push inside a shell wrapper (sh -c / bash -c / eval) cannot be inspected. Run the git command directly." >&2; exit 2 +# An alias defined in the same call renames a subcommand out of reach of every check below +# (`git -c alias.fp=push fp --force`; the `-c` value is quoted data, so the raw command is read too). +if has '(^|[^[:alnum:]_])alias\.' "$STRIPPED" || has '-c[[:space:]]*["'"'"']?alias\.' "$CMD"; then + echo "Blocked: a git alias defined in the same call as a commit/push cannot be inspected. Define it separately, or use the full subcommand." >&2; exit 2 +fi +# A ${param:-WORD} default/alternate whose value the walk drops can expand, with no prior assignment, +# to a flag, a +refspec, or a config token (`${x:---force}`, `${x:-+main}`, `${x:-user.name=Claude}`). +# A normal default is a plain value; refuse one that is a dash/plus-led flag or that carries an `=`. +if has '\$\{[A-Za-z0-9_!#]*(\[[^]]*\])?:?[-=+]([^A-Za-z0-9]*[-+]|[^}]*=)' "$CMD"; then + echo "Blocked: a \${parameter} default/alternate that is a flag, a +refspec, or a config token cannot be inspected (it may expand to that). Pass it directly, or set the variable first in its own call." >&2; exit 2 +fi +# A committer, author, or hooksPath value built from an expansion (`GIT_AUTHOR_NAME=C$(printf laude)`, +# `-c user.name=$x`, `--author=A${y}`) is out of the walk's sight, so the identity/hooksPath checks +# below never see it. Scan the stripped text (a quoted message is already dropped there, so a prose +# mention is exempt); a real override keeps its literal `KEY=` and the value's expansion mark. A +# case-insensitive key (`User.Name=$x`) is caught by the config-position `-c` refuse in the loop below. +# shellcheck disable=SC2016 # $ and ` are literal regex chars +if hasc '(user\.name=|user\.email=|core\.hooksPath=|--author=|GIT_(AUTHOR|COMMITTER)_(NAME|EMAIL)=)[^[:space:]$`]*[[:space:]]*[$`]' "$STRIPPED"; then + echo "Blocked: a committer, author, or hooksPath value built from a shell expansion cannot be inspected (CLAUDE.md §2). Set it to a literal, or set the variable first in its own call." >&2; exit 2 fi -IS_COMMIT=""; HAS_PUSH=""; HAS_ADD="" +IS_COMMIT=""; HAS_PUSH=""; HAS_ADD=""; HAS_WORKTREE="" while IFS= read -r SEG; do [ -z "$SEG" ] && continue + if hasc "$WRAPPER" "$SEG"; then + echo "Blocked: a shell wrapper (sh -c, bash <&2; exit 2 + fi SEG_COMMIT=""; SEG_PUSH="" - printf '%s' "$SEG" | grep -qiE 'git.*[[:space:]]commit([^[:alnum:]]|$)' && SEG_COMMIT=1 - printf '%s' "$SEG" | grep -qiE 'git.*[[:space:]]push([^[:alnum:]]|$)' && SEG_PUSH=1 - printf '%s' "$SEG" | grep -qiE 'git.*[[:space:]]add([^[:alnum:]]|$)' && HAS_ADD=1 + has 'git.*[[:space:]]commit([^[:alnum:]]|$)' "$SEG" && SEG_COMMIT=1 + has 'git.*[[:space:]]push([^[:alnum:]]|$)' "$SEG" && SEG_PUSH=1 + has 'git.*[[:space:]]add([^[:alnum:]]|$)' "$SEG" && HAS_ADD=1 + # a config-position -c/--config (before the subcommand) whose value carries an expansion cannot be + # inspected (`git -c c$(printf ore.hooksPath=x) commit`); a reuse-message `commit -c $sha` (value + # after the subcommand) and a `-C ` chdir (uppercase) are not this and stay allowed. + # shellcheck disable=SC2016 # $ and ` are literal regex chars + if [ -n "$SEG_COMMIT$SEG_PUSH" ] && hasc '(^|[[:space:]])(-c|--config)[[:space:]]+[^[:space:]$`]*[[:space:]]*[$`].*[[:space:]](commit|push)([^[:alnum:]]|$)' "$SEG"; then + echo "Blocked: a git -c/--config value built from a shell expansion cannot be inspected (it may set core.hooksPath or a committer). Pass a literal, or set it separately." >&2; exit 2 + fi + # an unquoted `*`, `?`, `[`, or `{a,b}` is expanded by bash when the command runs, into words this + # walk never sees (`-*` with a file named `--no-verify` in the directory; `--{force,}`) + if [ -n "$SEG_COMMIT$SEG_PUSH" ] && hasc '[*?[]|\{[^[:space:]{}]*(,|\.\.)[^[:space:]{}]*\}' "$SEG"; then + echo "Blocked: an unquoted *, ?, [, or {a,b} pattern in a git commit/push is expanded when it runs, so it cannot be inspected. Quote it, or list the files." >&2; exit 2 + fi + # a commit with -a/-i/-o, or any bare token after the subcommand that is not the value of a + # message-like option, commits working-tree content, not just the index: scan it + if [ -n "$SEG_COMMIT" ]; then + if hasc '[[:space:]]commit.*[[:space:]](-[A-Za-z]*[aiop][A-Za-z]*|--all|--include|--only|--patch|--interactive|--pathspec-from-file)([^[:alnum:]]|$)' "$SEG"; then HAS_WORKTREE=1; fi + SEEN=""; WANT=""; SUB=0; BT=0 + for T in $SEG; do # (unquoted on purpose: the walker removed every quote and set -f is on) + if [ -n "$SEEN" ]; then + # the words of a `$(...)` or `...` are the substitution's, never pathspecs (`-m "$(cat <]*) # a redirection glued to a word (`tracked.txt>log`, `2>`): the word stays an argument + case "$T" in *[\<\>]) WANT=1 ;; esac # the operator's target is the next token, not a pathspec + P=${T%%[<>]*} + case "$P" in ''|[0-9]*) continue ;; esac # nothing, or a descriptor number, before the operator + T=$P ;; + esac + case "$T" in + -m|-F|-C|-c|-t|--message|--file|--author|--date|--trailer|--template|--fixup|--squash|--reedit-message|--reuse-message|--cleanup) WANT=1 ;; + -S*) ;; # `-S`: the key is attached, nothing follows + -[aiosvqnepz]*[mFCct]) WANT=1 ;; # `-qm fix`: no-value flags then an option that takes the next word + -*) ;; + *) HAS_WORKTREE=1 ;; + esac + fi + [ "$T" = commit ] && SEEN=1 + done + fi [ -n "$SEG_COMMIT" ] && IS_COMMIT=1 [ -n "$SEG_PUSH" ] && HAS_PUSH=1 [ -z "$SEG_COMMIT$SEG_PUSH" ] && continue @@ -60,41 +474,74 @@ while IFS= read -r SEG; do # force-push: --force / --force-with-lease / --mirror, -f alone or inside a flag cluster (-uf), # or a `+refspec` (the refspec form of --force). `--force-if-includes` alone is also caught; # it is a no-op without --force-with-lease, so nothing legitimate is lost. - if [ -n "$SEG_PUSH" ] && printf '%s' "$SEG" | grep -qiE '[[:space:]]push.*[[:space:]](--force-with-lease|--force|--mirror)([^[:alnum:]]|$)|[[:space:]]push.*[[:space:]]-[A-Za-z]*f[A-Za-z]*([^[:alnum:]]|$)|[[:space:]]push.*[[:space:]]\+[^[:space:]]'; then + if [ -n "$SEG_PUSH" ] && { has '[[:space:]]push.*[[:space:]](--force-with-lease|--force|--mirror)([^[:alnum:]]|$)|[[:space:]]push.*[[:space:]]\+[^[:space:]]' "$SEG" || hasc '[[:space:]]push.*[[:space:]]-[A-Za-z]*f[A-Za-z]*([^[:alnum:]]|$)' "$SEG"; }; then echo "Blocked: force-push. Protected-branch discipline (CLAUDE.md §9)." >&2; exit 2 fi # --no-verify skips the pre-commit / pre-push hooks the repo installed on purpose (CLAUDE.md §14), # as does `git commit -n` (the short form, alone or in a cluster like -anm). Fix the hook failure instead. - if printf '%s' "$SEG" | grep -qiE '[[:space:]]--no-verify([^[:alnum:]-]|$)'; then + if has '[[:space:]]--no-verify([^[:alnum:]-]|$)' "$SEG"; then echo "Blocked: --no-verify bypasses the repo's git hooks (CLAUDE.md §14). Fix the hook failure instead." >&2; exit 2 fi - if [ -n "$SEG_COMMIT" ] && printf '%s' "$SEG" | grep -qiE '[[:space:]]commit.*[[:space:]]-[A-Za-z]*n[A-Za-z]*([^[:alnum:]]|$)'; then + if [ -n "$SEG_COMMIT" ] && has '[[:space:]]commit.*[[:space:]]-[A-Za-z]*n[A-Za-z]*([^[:alnum:]]|$)' "$SEG"; then echo "Blocked: git commit -n is --no-verify (CLAUDE.md §14). Fix the hook failure instead." >&2; exit 2 fi -done <<<"$(printf '%s' "$STRIPPED" | tr ';&|' '\n')" +done <<<"$SEGMENTS" [ -z "$IS_COMMIT$HAS_PUSH" ] && exit 0 +# git can take config from the environment (`--config-env`, `GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n`, +# `GIT_CONFIG_PARAMETERS`), where the key or the value is out of every check's sight: refuse. +if has '--config-env|GIT_CONFIG_(COUNT|KEY_[0-9]+|VALUE_[0-9]+|PARAMETERS|GLOBAL|SYSTEM)=|include\.path=|includeIf\.[^[:space:]]*\.path=|(^|[[:space:]])(HOME|XDG_CONFIG_HOME)=' "$STRIPPED"; then + echo "Blocked: git config from the environment or an unreadable file (--config-env, GIT_CONFIG_*, include.path, HOME, XDG_CONFIG_HOME) in the same call as a commit/push cannot be inspected. Set it with -c or in the repo config." >&2; exit 2 +fi # Re-pointing core.hooksPath anywhere in the same call (`git -c core.hooksPath=x commit`, -# `git config core.hooksPath x; git commit`, GIT_CONFIG_PARAMETERS=...) is the same bypass. -if printf '%s' "$STRIPPED" | grep -qiE 'core\.hooksPath|GIT_CONFIG_(PARAMETERS|COUNT|KEY_)'; then +# `git config core.hooksPath x; git commit`) is the same bypass as --no-verify. +# (checked on the raw command as well: a quoted value with a space in it is stripped as data) +if has 'core\.hooksPath' "$STRIPPED" || has '(-c[[:space:]]*["'"'"']?core\.hooksPath|config[^;&|]*core\.hooksPath)' "$CMD"; then echo "Blocked: core.hooksPath override bypasses the repo's git hooks (CLAUDE.md §14)." >&2; exit 2 fi # only inspect commit operations further [ -z "$IS_COMMIT" ] && exit 0 -# committer must be a human — match whole tokens / known bot identities, not substrings +# committer must be a human — match whole tokens / known bot identities, not substrings. +# The identity is the repo config, or an override on the command itself (`-c user.name=`, +# `GIT_COMMITTER_NAME=`, `GIT_AUTHOR_NAME=`, `--author`); the override's value runs to its closing +# quote, or to the end of the line when it is unquoted. +IDENT='(claude|chatgpt|copilot|cursor|cursoragent|github-actions|assistant|bot|ai[-_ ]?(agent|assistant|bot))' NAME=$(git config user.name 2>/dev/null || echo "") -if printf '%s' "$NAME" | grep -qiE '(^|[^[:alnum:]])(claude|chatgpt|copilot|cursor|cursoragent|github-actions|assistant|bot|ai[-_ ]?(agent|assistant|bot))([^[:alnum:]]|$)'; then - echo "Blocked: git user.name '$NAME' is not a human (CLAUDE.md §2)." >&2; exit 2 +if has "(^|[^[:alnum:]])${IDENT}[0-9]*([^[:alnum:]]|\$)" "$NAME"; then + SAFE_NAME=$(tr -d '\n\r' <<<"$NAME" | cut -c1-40) # repo-controlled text: one line, short, before it reaches the model + echo "Blocked: git user.name '$SAFE_NAME' is not a human (CLAUDE.md §2)." >&2; exit 2 +fi +# The stripped text has the unquoted forms with every quote splice undone (`user.name=Cla'ude'`); the +# raw text has the quoted values (`--author="Claude Bot <...>"`), whose closing quote bounds them. +if has "(user\.name=|GIT_(AUTHOR|COMMITTER)_NAME=|--author[= ])([^[:space:]]*[^[:alnum:][:space:]])?${IDENT}[0-9]*([^[:alnum:]]|\$)" "$STRIPPED" || + has "(user\.name=|GIT_(AUTHOR|COMMITTER)_NAME=|--author[= ])[\"']([^\"']*[^[:alnum:]\"'])?${IDENT}[0-9]*([^[:alnum:]]|\$)" "$CMD" || + has "[\"'](user\.name=|GIT_(AUTHOR|COMMITTER)_NAME=|--author=)([^\"']*[^[:alnum:]\"'])?${IDENT}[0-9]*([^[:alnum:]]|\$)" "$CMD"; then + echo "Blocked: the commit sets a committer or author that is not a human (CLAUDE.md §2)." >&2; exit 2 +fi +# A same-call `git config [--flags] [set] user.name|user.email ` write lands after this hook has +# read the pre-execution repo config, so it evades the committer check above — the identity twin of the +# same-call core.hooksPath write. Refuse a config write of a non-human name/email. +CFG_ID="(^|[^[:alnum:]])config([[:space:]]+(--?[A-Za-z][A-Za-z-]*(=[^[:space:]]*)?([[:space:]]+[^[:space:]-][^[:space:]]*)?|set))*[[:space:]]+user\\.(name|email)[[:space:]]+" +if has "${CFG_ID}[\"']?([^\"']*[^[:alnum:]\"'])?${IDENT}[0-9]*([^[:alnum:]]|[\"']|$)" "$CMD" || + has "${CFG_ID}[\"']?([^\"']*[^[:alnum:]\"'])?${IDENT}[0-9]*([^[:alnum:]]|[\"']|$)" "$STRIPPED"; then + echo "Blocked: a same-call 'git config user.name/email' sets a committer that is not a human (CLAUDE.md §2). Set the identity to a literal human name, in its own call." >&2; exit 2 +fi +# The value can also be built from an expansion (`git config user.name C$(printf laude)`, `... $BOT`), +# which the literal check cannot read. On the stripped text the value keeps a `$`/backtick mark; a +# quoted message is dropped there, so a prose mention is exempt. +# shellcheck disable=SC2016 # $ and ` are literal regex chars +if has "${CFG_ID}[^[:space:]$\`]*[[:space:]]*[$\`]" "$STRIPPED"; then + echo "Blocked: a same-call 'git config user.name/email' whose value is built from a shell expansion cannot be inspected (CLAUDE.md §2). Set it to a literal human name, in its own call." >&2; exit 2 fi # no AI attribution in the commit message — scan the command only, not the staged diff. # (This is a repo *about* Claude Code; legit messages will say "Claude Code". Only # attribution-SHAPED phrases are blocked, not bare mentions of a tool.) -if printf '%s' "$CMD" | grep -qiE 'co-authored-by:.*(claude|gpt|copilot|cursor)|generated with (claude|chatgpt|copilot|ai)|🤖|AI[- ](assisted|generated)'; then +if has 'co-authored-by:.*(claude|gpt|copilot|cursor)|generated with (claude|chatgpt|copilot|ai)|🤖|AI[- ](assisted|generated)' "$CMD"; then echo "Blocked: AI attribution detected in commit message (CLAUDE.md §2). Remove it." >&2; exit 2 fi @@ -106,23 +553,43 @@ SECRETS='(api[_-]?key|secret[_-]?key|access[_-]?key|private[_-]?key|password|tok # explicitly whitelisted for commit (e.g. `!.env.example` in the scaffolder .gitignore). EXCL=(':(exclude)*.example' ':(exclude)*.sample' ':(exclude)*.dist' ':(exclude)*.tmpl') # `--no-ext-diff` and `core.fsmonitor=false` so a repo-local config cannot make the guard run a program. -GIT_DIFF=(git -c core.fsmonitor=false diff --no-ext-diff) -DIFF=$("${GIT_DIFF[@]}" --cached 2>/dev/null -- "${EXCL[@]}") -if [ -n "$HAS_ADD" ] || printf '%s' "$STRIPPED" | grep -qiE '[[:space:]](--all|-[A-Za-z]*a[A-Za-z]*)([[:space:]]|$)'; then - DIFF="$DIFF"$'\n'"$("${GIT_DIFF[@]}" 2>/dev/null -- "${EXCL[@]}")" +# Repo config must not reshape what the guard reads: no external diff, no textconv, no color, +# no relative paths, and binary-looking content (a `-diff` attribute, a NUL byte) still as text. +# Added lines are marked `>` by git itself (--output-indicator-new), so a content line that merely +# looks like a `+++ b/file` header cannot be mistaken for one. +GIT_DIFF=(git -c core.fsmonitor=false diff --no-ext-diff --no-textconv --no-color --no-relative --text --output-indicator-new='>') +gitfail() { echo "Blocked: guard-commit.sh could not read the diff ($1 failed). Failing closed." >&2; exit 2; } +DIFF=$("${GIT_DIFF[@]}" --cached 2>/dev/null -- "${EXCL[@]}") || gitfail "git diff --cached" +if [ -n "$HAS_ADD$HAS_WORKTREE" ]; then + WT=$("${GIT_DIFF[@]}" 2>/dev/null -- "${EXCL[@]}") || gitfail "git diff" + DIFF="$DIFF"$'\n'"$WT" fi if [ -n "$HAS_ADD" ]; then # (a plain function, not `case` inside $(...): bash 3.2 cannot parse the `)` of a case pattern there) + # NUL-separated paths so a newline in a filename cannot hide a file; a grep failure marks the scan. scan_untracked() { - git ls-files --others --exclude-standard 2>/dev/null | while IFS= read -r f; do - printf '%s' "$f" | grep -qE '\.(example|sample|dist|tmpl)$' && continue - grep -sIE "$SECRETS" -- "$f" | sed 's/^/+/' + git ls-files -z --others --exclude-standard 2>/dev/null | while IFS= read -r -d '' f; do + case "$f" in *.example|*.sample|*.dist|*.tmpl) continue ;; esac # (a whole-name match: a newline in the name cannot fool it) + [ -L "$f" ] && continue # git stores a symlink's target text, never the target + [ -f "$f" ] || continue + grep -saE -i -e "$SECRETS" -- "$f" | sed 's/^/>/' # -a: a NUL byte must not make grep skip the file + [ "${PIPESTATUS[0]}" -gt 1 ] && printf '>GUARD_SCAN_FAILED %s\n' "$f" done + [ "${PIPESTATUS[0]}" -eq 0 ] || printf '>GUARD_SCAN_FAILED ls-files\n' } - DIFF="$DIFF"$'\n'"$(scan_untracked)" + UT=$(scan_untracked) + if hasc '^>GUARD_SCAN_FAILED' "$UT"; then gitfail "scanning an untracked file"; fi + DIFF="$DIFF"$'\n'"$UT" fi -# Only ADDED lines count: removing a leaked secret from a file must stay committable (§11: rotate, then scrub). -if printf '%s' "$DIFF" | grep '^+' | grep -v '^+++' | grep -qiE "$SECRETS"; then - echo "Blocked: a secret appears to be staged (CLAUDE.md §11). Unstage it." >&2; exit 2 +if [ ${#DIFF} -gt 16777216 ]; then + echo "Blocked: guard-commit.sh will not scan a diff over 16 MB for secrets. Commit large binaries separately (or via LFS)." >&2; exit 2 fi +# Only ADDED lines (`>`) count: removing a leaked secret from a file must stay committable +# (§11: rotate, then scrub). One grep, no `-q`, so it reads everything; 1 = clean. +grep -iE -e "^>.*($SECRETS)" <<<"$DIFF" >/dev/null +case $? in + 0) echo "Blocked: a secret appears to be staged (CLAUDE.md §11). Unstage it." >&2; exit 2 ;; + 1) ;; + *) echo "Blocked: guard-commit.sh could not scan the staged diff for secrets (grep failed). Failing closed." >&2; exit 2 ;; +esac exit 0 diff --git a/tests/hooks/test-guard-commit.sh b/tests/hooks/test-guard-commit.sh index 1215297..9d123df 100755 --- a/tests/hooks/test-guard-commit.sh +++ b/tests/hooks/test-guard-commit.sh @@ -8,6 +8,7 @@ set -u HERE=$(cd "$(dirname "$0")" && pwd) SELF="$HERE/$(basename "$0")" HOOK="$HERE/../../hooks/guard-commit.sh" +BASH=/bin/bash; [ -x "$BASH" ] || BASH=bash # the macOS system bash 3.2 when present, whatever is on PATH otherwise PASS=0 FAIL=0 TMP=$(mktemp -d) @@ -32,16 +33,27 @@ record() { # run # Never call this inside a subshell: PASS/FAIL must be counted in this shell. run() { - printf '%s' "$3" | jq -Rs '{tool_input:{command:.}}' | bash "$HOOK" >/dev/null 2>"$TMP/err" + printf '%s' "$3" | jq -Rs '{tool_input:{command:.}}' | "$BASH" "$HOOK" >/dev/null 2>"$TMP/err" record "$1" "$2" $? "$3" } # run_raw run_raw() { - printf '%s' "$3" | bash "$HOOK" >/dev/null 2>"$TMP/err" + printf '%s' "$3" | "$BASH" "$HOOK" >/dev/null 2>"$TMP/err" record "$1" "$2" $? "$3" } +# run_err : also asserts exactly one "cannot classify" line +run_err() { + printf '%s' "$4" | jq -Rs '{tool_input:{command:.}}' | "$BASH" "$HOOK" >"$TMP/out" 2>"$TMP/err" + rc=$? + if grep -qF -- "$3" "$TMP/err" && [ "$(grep -c 'cannot classify' "$TMP/err")" -eq 1 ] && [ ! -s "$TMP/out" ]; then + record "$1" "$2" "$rc" "$4" + else + FAIL=$((FAIL + 1)); echo "FAIL: $1 (stderr lacks '$3', or not exactly one reason line, or stdout not empty): $4"; sed 's/^/ stderr: /' "$TMP/err" + fi +} + # A throwaway repo with the given committer, one tracked file, and a clean tree. mkrepo() { d=$(mktemp -d "$TMP/repo.XXXXXX") @@ -113,6 +125,584 @@ run "multi-line -m message" 0 'git commit -m "docs: x why git push -f is blocked"' +# --- heredoc bodies are data, not commands ----------------------------------------------------- +run "heredoc file write mentioning the hook" 0 'cat > notes.md <<'"'"'EOF'"'"' +The hook refuses sh -c "git commit" wrappers and blocks git push -f and --no-verify. +EOF' +run "heredoc file write, unquoted marker" 0 'cat > notes.md < x.md < x.md <<'"'"'END-OF-FILE'"'"' +body +END-OF-FILE +git push -f origin main' +run "marker inside a comment" 2 '# see < x.md <<\EOF +never git push -f +EOF' +run "apostrophe in a quoted arg before a heredoc" 0 'echo "don'"'"'t" && cat > x.md < x.md < notes.md < notes.md <<'"'"'EOF'"'"' +$(git push -f origin main) is not run here +EOF' +run "body line ending in backslash" 2 'cat <<'"'"'EOF'"'"' +foo\ +EOF +git push -f origin main' +run "paren nested inside arithmetic" 2 'echo $(( (1<<2) )) +git push -f origin main' +run "paren nested inside (( )) command" 2 '(( x = (1<<2) )) +git push -f origin main' +run "two heredocs on one line, push after" 2 'cat <&1 before --force" 2 'git push origin main 2>&1 --force' +run "2>&1 before --force-with-lease" 2 'git push origin main 2>&1 --force-with-lease' +run "2>&1 before +refspec" 2 'git push origin 2>&1 +main' +run ">& before --force" 2 'git push origin main >&/dev/null --force' +run "<&- before --force" 2 'git push origin main <&- --force' +run "3>&2 before --force" 2 'git push origin main 3>&2 --force' +run "&> before --force" 2 'git push origin main &>/dev/null --force' +run ">| before --force" 2 'git push origin main >|/tmp/log --force' +run "2>&1 before --no-verify" 2 'git commit -m x 2>&1 --no-verify' +run "2>&1 before -n" 2 'git commit -m x 2>&1 -n' +run ">| before --no-verify" 2 'git commit -m x >|/tmp/log --no-verify' +run "2>&1 then a real ; then plain push" 0 'git commit -m x 2>&1; git push origin feat/x' +run "wrapper directly inside a subshell" 2 '(eval "git push -f origin main")' +run "bash -c directly inside a subshell" 2 '(bash -c "git push -f origin main")' +run "trap directly inside a subshell" 2 '(trap "git push -f origin main" EXIT; true)' +run "eval directly inside \$( )" 2 'x=$(eval "git push -f origin main")' +run "bash -c directly inside \$( )" 2 'x=$(bash -c "git push -f origin main")' +run "bash heredoc directly inside \$( )" 2 'x=$(bash <<'"'"'EOF'"'"' +git push -f origin main +EOF +)' +run "bash 2>&1 -c wrapper" 2 'bash 2>&1 -c "git push -f origin main"' +run "bash 3>&2 -c wrapper" 2 'bash 3>&2 -c "git commit --no-verify -m x"' +run "sh -c right after (" 2 '(sh -c "git push --force origin main")' +run "bash heredoc right after (" 2 '(bash <<'"'"'EOF'"'"' +git push --force origin main +EOF +)' +run "pipe into (bash)" 2 'echo "git push --force origin main" | (bash)' +run "sh -c right after \$(" 2 'x=$(sh -c "git push --force origin main")' +run "here-string bash inside a message substitution" 2 'git commit -m "$(bash <<<"git push --force origin main")"' +run "eval inside a message substitution" 2 'git commit -m "$(eval "git push --force origin main")"' +run "bash -c right after a case pattern" 2 'case x in x)bash -c "git push --force origin main";; esac' +run "bare bash then 2>/dev/null" 2 'echo "git push --force origin main" | bash 2>/dev/null' +run "bare bash then >/dev/null 2>&1" 2 'echo "git push --force origin main" | bash >/dev/null 2>&1' +run "bare bash then > log" 2 'echo "git push --force origin main" | bash > log' +run "bare bash then 2>&1 | tee" 2 'echo "git push --force origin main" | bash 2>&1 | tee log' +run "heredoc glued to bash" 2 'bash<<'"'"'EOF'"'"' +git push --force origin main +EOF' +run "here-string glued to bash" 2 'bash<<<"git push --force origin main"' +run "file glued to sh then commit" 2 'sh" -m x' +run "-c whole-arg quoted user.name to a human" 0 'git -c "user.name=Dennis Vorobyov" commit -m x' +run "bot name with a trailing digit" 2 'git -c user.name=Claude2 commit -m x' +run "GIT_COMMITTER_NAME override to a bot" 2 'GIT_COMMITTER_NAME=Claude git commit -m x' +run "GIT_AUTHOR_NAME override to a bot" 2 'GIT_AUTHOR_NAME=Cursor git commit -m x' +run "--author override to a bot" 2 'git commit --author="Claude " -m x' +run "--author space form to a bot" 2 'git commit --author "github-actions " -m x' +run "-c user.name override to a human" 0 'git -c user.name="Dennis Vorobyov" commit -m "docs: Claude Code hooks"' +run "GIT_COMMITTER_NAME human then a quoted message naming a tool" 0 'GIT_COMMITTER_NAME=Dennis git commit -m "docs: Claude Code hooks"' +run "--author human with a tool in the message" 0 'git commit --author="Dennis V " -m "explain Claude Code"' +run "test bracket then push -f still blocks" 2 '[ -f x ] && git push -f origin main' +run "double bracket then push -f still blocks" 2 '[[ -n x && -n y ]] && git push -f origin main' +run "commit with an unquoted glob flag" 2 'git commit -m x -*' +run "commit with an unquoted ? word" 2 'git commit -m x ?' +run "push with a brace-expanded flag" 2 'git push origin main --{force,}' +run "push with a brace range" 2 'git push origin main --{f..f}' +run "quoted * message is allowed" 0 'git commit -m "*"' +run "quoted brace message is allowed" 0 'git commit -m "fix {a,b}"' +run "escaped * message is allowed" 0 'git commit -m \*' +run "brace group around a plain push" 0 '{ git push origin feat/x; }' +run "git add * in its own segment then commit" 0 'git add * && git commit -m x' +run "commit with a bracket pattern flag" 2 'git commit -m x -[n]' +run "push with a bracket pattern flag" 2 'git push origin main -[f]' +run "commit with a word-glued bracket pattern" 2 'git commit -m x f[ab]' +run "array assignment then a plain commit" 0 'a[1]=x; git commit -m y' +run "[ in a brace default then push -f" 2 'echo ${x:-[} ; git push -f origin main ; : ]}' +run "[ in a brace default then push --force" 2 'echo ${x:-[} && git push --force origin main && : ]}' +run "[ in a brace replacement then --no-verify" 2 'echo ${x/a/[} ; git commit --no-verify -m x ; : ]}' +run "[ in a brace default then a bot committer" 2 'git status ${x:-[} ; git -c user.name=Claude commit -m x ; : ]}' +run "[ in a subscripted brace default then push -f" 2 'echo ${a[0]:-[} ; git push -f origin main ; : ]}' +run "[] in a brace default then a plain commit" 0 'echo ${x:-[]}; git commit -m x' +run "( left open inside a brace is refused" 2 'echo ${x:-(} ; git commit -m x' +run "bash with a three-line \${ } then -c" 2 'a=; bash ${a- +foo +bar} -c "git push -f origin main"' +run "bash with a continued \${ } then -c" 2 'bash ${x:-a\ +b} -c "git push -f origin main"' +run "case pattern then a plain commit" 0 'case "$1" in build) make;; *) git commit -m y;; esac' +run "case pattern with ? then a plain push" 0 'case x in a?) git push origin main;; esac' +run "case pattern inside a message substitution" 0 'git commit -m "$(case x in a) echo y;; esac)"' +run "GIT_CONFIG_VALUE user.name is refused" 2 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=user.email GIT_CONFIG_VALUE_0=bot@example.com git commit -m x' +run "GIT_CONFIG_COUNT on a push is refused" 2 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/tmp git push origin main' +run "GIT_CONFIG_GLOBAL inline on a commit is refused" 2 'GIT_CONFIG_GLOBAL=/tmp/cfg git commit -m x' +run "include.path on a commit is refused" 2 'git -c include.path=/tmp/cfg commit -m x' +run "includeIf on a commit is refused" 2 'git -c includeIf.gitdir:/x.path=/tmp/cfg commit -m x' +run "a file named includeIf.md is allowed" 0 'git commit -m x includeIf.md' +run "XDG_CONFIG_HOME on a commit is refused" 2 'XDG_CONFIG_HOME=/tmp/evil git commit -m x' +run "XDG_CONFIG_HOME on a push is refused" 2 'XDG_CONFIG_HOME=/tmp/evil git push origin main' +run "a message mentioning XDG_CONFIG_HOME with spaces is allowed" 0 'git commit -m "set XDG_CONFIG_HOME first"' +run "HOME= on a commit is refused" 2 'HOME=/tmp git commit -m x' +run "GIT_CONFIG_GLOBAL exported by the harness is invisible" 0 'git commit -m x' +run "substitution inside a quoted \${ } is refused" 2 'git commit -m "${v:-"$(git describe)"}"' +run "heredoc marker on a line ending inside \${ }" 2 'cat < run.sh && git add run.sh && git commit -m x' +run "SHELL assignment then commit is allowed" 0 'export SHELL=/bin/zsh && git commit -m x' +run "bash >out script.sh is allowed" 0 'bash >out script.sh && git commit -m x' +run "which bash then commit (documented false block)" 2 'which bash && git commit -m x' +run "git add zsh bash fish (documented false block)" 2 'git add fish bash zsh && git commit -m "add shell configs"' +run "message mentioning alias.md is allowed" 0 'git commit -m "add alias.md"' +run "heredoc body mentioning an alias then commit" 0 'cat > notes.md <<'"'"'EOF'"'"' +use git config alias.co checkout once +EOF +git commit -m x' + +# --- process substitution is a word of the enclosing command ------------------------------------ +run "pipe inside <( ) before --force-with-lease" 2 'git push origin main <(git log --oneline | head -5) --force-with-lease' +run "; inside <( ) before --force" 2 'git push origin main <(echo a;echo b) --force' +run "; inside <( ) before +refspec" 2 'git push origin <(echo a;echo b) +main' +run "; inside <( ) before --no-verify" 2 'git commit -m x <(echo a;echo b) --no-verify' +run "&& inside <( ) before -n" 2 'git commit -m x <(echo a&&echo b) -n' +run "newline inside >( ) before --force" 2 'git push origin main >(cat +) --force' +run "<( ) control without a separator" 2 'git push origin main <(echo a) --force' +run "<( ) in a plain diff then commit" 0 'diff <(sort a) <(sort b); git commit -m "x"' +DENSE=$(for _ in $(seq 1 600); do printf ' "key": "value",\n'; done) +run "quote-dense non-git heredoc is allowed" 0 "cat > package.json <<'EOF' +{ +$DENSE +} +EOF" +run "quoted message with spaces stays data" 0 'git commit -m "never git push -f"' +MANY=$(for _ in $(seq 1 20); do printf ' < n.md <>tracked.txt run "dirty tracked secret, plain commit" 0 'git commit -m "x"' run "dirty tracked secret, commit -am" 2 'git commit -am "x"' run "dirty tracked secret, commit --all" 2 'git commit --all -m "x"' +run "dirty tracked secret, -a then ;" 2 'git commit -m x -a;git push origin x' +run "dirty tracked secret, pathspec commit" 2 'git commit -m "x" tracked.txt' +run "dirty tracked secret, --include" 2 'git commit --include tracked.txt -m "x"' +run "dirty tracked secret, 2>&1 then -a" 2 'git commit -m x 2>&1 -a' +run "dirty tracked secret, 2>&1 then --all" 2 'git commit -m x 2>&1 --all' +run "dirty tracked secret, escaped ; then -a" 2 'git commit -m x\;y -a' +run "dirty tracked secret, extension-less pathspec" 2 'git commit -m "x" tracked' +run "dirty tracked secret, -m value is not a pathspec" 0 'git commit -m fix' +run "dirty tracked secret, two-word message then pathspec" 2 'git commit -m "fix: two words" tracked.txt' +run "dirty tracked secret, multi-line message then pathspec" 2 'git commit -m "$(cat <<'"'"'EOF'"'"' +fix: x + +body +EOF +)" tracked.txt' +run "dirty tracked secret, -S then pathspec" 2 'git commit -S tracked.txt -m x' +run "dirty tracked secret, --gpg-sign then pathspec" 2 'git commit --gpg-sign tracked.txt -m x' +run "dirty tracked secret, -m -F then pathspec" 2 'git commit -m -F tracked.txt' +run "dirty tracked secret, plain commit with 2>&1" 0 'git commit -m x 2>&1' +run "dirty tracked secret, plain commit to /dev/null" 0 'git commit -m x >/dev/null 2>&1' +run "dirty tracked secret, -qm cluster takes a value" 0 'git commit -qm fix' +run "dirty tracked secret, -sm cluster takes a value" 0 'git commit -sm fix' +run "dirty tracked secret, quoted a>b is a pathspec" 2 'git commit -m x "a>b"' +run "dirty tracked secret, heredoc message, no pathspec" 0 'git commit -m "$(cat <<'"'"'EOF'"'"' +fix: x + +body +EOF +)"' +run "dirty tracked secret, \${msg} message, no pathspec" 0 'git commit -m "${msg}"' +run "dirty tracked secret, prefixed \${x} message, no pathspec" 0 'git commit -m "fix: ${x}"' +run "dirty tracked secret, backtick message, no pathspec" 0 'git commit -m "`date`"' +run "dirty tracked secret, backtick message then pathspec" 2 'git commit -m "`date`" tracked.txt' +run "dirty tracked secret, substitution message then pathspec" 2 'git commit -m "$(date)" tracked.txt' +run "dirty tracked secret, -SDEADBEEF is not -a" 0 'git commit -SDEADBEEF -m x' +run "dirty tracked secret, backtick with parens then pathspec" 2 'git commit -m "`(echo x)`" tracked.txt' +run "dirty tracked secret, backtick with <( ) then pathspec" 2 'git commit -m "`cat <(date)`" tracked.txt' +run "dirty tracked secret, pathspec glued to a redirect" 2 'git commit -m x tracked.txt>log' +run "dirty tracked secret, pathspec glued to 2>&1" 2 'git commit -m x tracked.txt2>&1' +run "dirty tracked secret, --pathspec-from-file" 2 'git commit -m x --pathspec-from-file=list' +run "dirty tracked secret, --patch" 2 'git commit -p -m x' +run "dirty tracked secret, redirect to a file, no pathspec" 0 'git commit -m x >log 2>&1' +run "dirty tracked secret, -m \$(date) then -- pathspec" 2 'git commit -m "$(date)" -- tracked.txt' +run "dirty tracked secret, quoted backtick word then pathspec" 2 'git commit -m '"'"'a`b'"'"' tracked.txt' +run "dirty tracked secret, literal \$( in a word then pathspec" 2 'git commit -m x$\(y tracked.txt' +run "dirty tracked secret, empty message then pathspec" 2 'git commit -m "" tracked.txt' +run "empty quote glued before push" 2 'git ""push --force origin main' +run "empty quote glued before the force flag" 2 'git push ""--force origin main' +run "empty quote glued before commit" 2 'git ""commit --no-verify -m x' +run "empty quote glued before commit, dirty secret" 2 'git ""commit -am x' +run "single empty quote glued before commit" 2 "git ''commit --no-verify -m x" +run "empty quote glued before a bash wrapper" 2 '""bash -c "git push --force origin main"' +run "empty quote before commit, then chained force-push" 2 'git commit -m x && git ""push --force origin main' +run "empty quote glued before a word is not a phantom" 0 'git ""status' +run "standalone empty arg then a plain commit" 0 'echo "" && git commit -m x' +run "bare \$x glued before the force flag" 2 'git push origin $x--force' +run "bare \$x glued before -f" 2 'git push origin $x-f' +run "bare \$x glued before --mirror" 2 'git push $x--mirror' +run "bare \$x glued before --no-verify" 2 'git commit $x--no-verify -m x' +run "bare \$x glued before -n" 2 'git commit -a $x-n -m x' +run "bare \$x then empty quote before --force" 2 'git push origin $x""--force' +run "bare \$x before a bash wrapper flag" 2 'bash $x-c "git push --force origin main"' +run "bare \$x in a normal push is allowed" 0 'git push origin $branch' +run "bare \$1 glued before --force" 2 'git push origin $1--force' +run "quoted \$x glued before --force" 2 'git push origin "$x--force"' +run "quoted \${x} glued before --force" 2 'git push origin "${x}--force"' +run "quoted \$x, two spans, before --force" 2 'git push origin "$x""--force"' +run "quoted \$x span then unquoted --force" 2 'git push origin "$x"--force' +run "quoted \$@ glued before --force" 2 'git push origin "$@--force"' +run "quoted \$x glued before -f" 2 'git push "$x-f" origin' +run "quoted \$x glued before --no-verify" 2 'git push "$x--no-verify"' +run "quoted \$x after -m ok before --no-verify" 2 'git commit -m ok "$x--no-verify"' +run "quoted \$x glued before -n" 2 'git commit "$x-n" -m ok' +run "quoted \${x} split-span bot author is refused (value from an expansion)" 2 'git commit --author="$x""Claude Bot " -m x' +run "single-span quoted bot author is still caught" 2 'git commit --author="Claude Bot " -m x' +run "quoted \$x normal refspec is allowed" 0 'git push origin "$branch"' +run "quoted \${x} prefix message is allowed" 0 'git commit -m "${x} done"' +run "quoted substitution message, no pathspec, dirty secret" 0 'git commit -m "$(date)"' +run "quoted \${x} message then pathspec, dirty secret" 2 'git commit -m "${x}" tracked.txt' +run "quoted \$x message then pathspec, dirty secret" 2 'git commit -m "$x" tracked.txt' +run "quoted \${x:-} default is a flag" 2 'git push origin "${x:---force}"' +run "unquoted \${x:-} default is a flag" 2 'git push origin ${x:---force}' +run "\${x:=} default is --no-verify" 2 'git commit "${x:=--no-verify}" -m x' +run "\${x=} default is a flag" 2 'git push origin ${x=--force}' +run "\${VERSION:-0.0.0} default is allowed" 0 'git commit -m "${VERSION:-0.0.0}"' +run "\${BRANCH:-main} default is allowed" 0 'git push origin "${BRANCH:-main}"' +run "\${x:-HEAD} default is allowed" 0 'git push origin ${x:-HEAD}' +run "\${#files} length is allowed" 0 'git commit -m "count ${#files}"' +run "\${x//a/b} replacement is allowed" 0 'git commit -m "${x//a/b}"' +run "\${HOME:+--force} alternate is a flag" 2 'git push origin "${HOME:+--force}"' +run "\${x:+-n} alternate is a flag" 2 'git commit -m ok "${x:+-n}"' +run "\${x+--force} bare alternate is a flag" 2 'git push origin ${x+--force}' +run "\${x:- space then flag} default is a flag" 2 'git push origin ${x:- --force}' +run "\${a[0]:---force} subscript default is a flag" 2 'git push origin "${a[0]:---force}"' +run "\${x:+value} alternate value is allowed" 0 'git commit -m "${x:+ready}"' +run "\${x/a/b} replacement is allowed (indirection limit)" 0 'git commit -m "${x/a/b}"' +run "\${x//-/_} dash-strip replacement is allowed" 0 'git commit -m "${x//-/_}"' +run "\${x:-a-b} hyphenated default value is allowed" 0 'git commit -m "${x:-a-b}"' +run "\${x:-\"--force\"} quoted default is a flag" 2 'git push origin ${x:-"--force"}' +run "\${x:-'"'"'--force'"'"'} single-quoted default is a flag" 2 'git push origin ${x:-'"'"'--force'"'"'}' +run "\${x:-\\-\\-force} backslashed default is a flag" 2 'git push origin ${x:-\-\-force}' +run "\${x:-\"0.0.0\"} quoted non-flag default is allowed" 0 'git commit -m "${x:-"0.0.0"}"' +run "\${x:-v1.0} versiony default is allowed" 0 'git commit -m "${x:-v1.0}"' +run "\${x:-+main} refspec default is force" 2 'git push origin ${x:-+main}' +run "\${x:-\"+main\"} quoted refspec default is force" 2 'git push origin ${x:-"+main"}' +run "\${x:-+HEAD:main} refspec default is force" 2 'git push origin ${x:-+HEAD:main}' +run "-c value from an expansion is refused" 2 'git -c ${x:-core.hooksPath=/tmp} commit -m x' +run "-c user.name from an expansion is refused" 2 'git -c ${x:-user.name=Claude} commit -m x' +run "user.name= value from an expansion is refused" 2 'git -c user.name=${x:-Claude} commit -m x' +run "GIT_AUTHOR_NAME from an expansion is refused" 2 'GIT_AUTHOR_NAME=${x:-Claude} git commit -m x' +run "GIT_COMMITTER_EMAIL from an expansion is refused" 2 'GIT_COMMITTER_EMAIL=${x} git commit -m x' +run "--author from an expansion is refused" 2 'git commit --author=${x:-Claude} -m x' +run "core.hooksPath from an expansion is refused" 2 'git -c core.hooksPath=$HOME commit -m x' +run "-c with a literal human name is allowed" 0 'git -c user.name="Dennis Vorobyov" commit -m x' +run "commit -c reuse-message sha is allowed" 0 'git commit -c abc1234 -m x' +run "message mentioning user.name in prose is allowed" 0 'git commit -m "note about user.name settings"' +run "hooksPath key split across a substitution" 2 'git -c c"$(printf ore.hooksPath=/tmp/h)" commit -m x' +run "user.name key split across a substitution" 2 'git -c u"$(printf ser.name=Claude)" commit -m x' +run "hooksPath key split across a brace default" 2 'git -c c"${x:-ore.hooksPath=/tmp/h}" commit -m x' +run "GIT_AUTHOR_NAME value split across a substitution" 2 'GIT_AUTHOR_NAME=C"$(printf laude)" git commit -m x' +run "GIT_COMMITTER_NAME value split across a substitution" 2 'GIT_COMMITTER_NAME=C"$(printf laude)" git commit -m x' +run "--author value split across a substitution" 2 'git commit --author=A"$(printf laude)" -m x' +run "--config value from a substitution" 2 'git --config c"$(printf ore.hooksPath=/tmp)" commit -m x' +run "user.email value from an expansion is refused" 2 'git -c user.email=${x:-bot@example.com} commit -m x' +run "prose message mentioning core.hooksPath= is allowed" 0 'git commit -m "docs: core.hooksPath=$HOME is risky"' +run "reuse-message commit -c with a variable sha is allowed" 0 'git commit -c ${sha} -m x' +run "reuse-message commit -C with a substitution sha is allowed" 0 'git commit -C "$(git rev-parse HEAD)" -m x' +run "git -C chdir from a variable is allowed" 0 'git -C "$repo" push origin main' +run "commit then chained push, reuse sha, allowed" 0 'git commit -c ${sha} -m x && git push origin main' +run "git config user.name to a bot then commit" 2 'git config user.name Claude && git commit -am x' +run "git config user.name quoted bot then commit" 2 'git config user.name "Claude" && git commit -m x' +run "git config user.name two-word bot then commit" 2 'git config user.name "Claude Bot" && git commit -m x' +run "git config --local user.name bot then commit" 2 'git config --local user.name Cursor && git commit -am x' +run "git config set user.name bot then commit" 2 'git config set user.name Claude && git commit -m x' +run "git config user.email bot then commit" 2 'git config user.email bot@anthropic.com && git commit -m x' +run "git config user.name github-actions then commit" 2 'git config user.name "github-actions" && git commit -m x' +run "case-mismatched -c User.Name from expansion" 2 'git -c User.Name=${x:-Claude} commit -m x' +run "git config user.name human then commit" 0 'git config user.name "Dennis Vorobyov" && git commit -m x' +run "git config core.editor then commit" 0 'git config core.editor vim && git commit -m x' +run "git config user.email human then commit" 0 'git config user.email denny@example.com && git commit -m x' +run "git config commit.gpgsign then commit" 0 'git config commit.gpgsign true && git commit -m x' +run "brace default carrying = is a config token" 2 'git commit --trailer ${x:-Acked-by=me} -m x' +run "git config user.name value from a substitution" 2 'git config user.name C"$(printf laude)" && git commit -m x' +run "git config user.name value from a bare var" 2 'git config user.name $BOT && git commit -m x' +run "git config user.name value from a brace default" 2 'git config user.name ${x:-Claude} && git commit -m x' +run "git config user.name split key then bot value" 2 'git config user.na"me" Claude && git commit -m x' +run "git config user.name value from a var, human intent, still refused" 2 'git config user.name "$USER" && git commit -m x' +run "git config core.editor from a var is allowed" 0 'git config core.editor $EDITOR && git commit -m x' +run "git config -f file user.name bot then commit" 2 'git config -f cfg user.name Claude && git commit -m x' +run "git config --file user.name bot then commit" 2 'git config --file cfg user.name Claude && git commit -m x' +run "git config -f file core.editor is allowed" 0 'git config -f cfg core.editor vim && git commit -m x' +run "git config --type=path user.name bot then commit" 2 'git config --type=path user.name Claude && git commit -m x' +run "git config --type=path user.email bot then commit" 2 'git config --type=path user.email bot@evil.com && git commit -m x' +run "git config --file= user.name value from a substitution" 2 'git config --file=cfg user.name C"$(printf laude)" && git commit -m x' +run "git config --file=.git/config core.editor is allowed" 0 'git config --file=.git/config core.editor vim && git commit -m x' +run "git config --replace-all user.name human is allowed" 0 'git config --replace-all user.name Dennis && git commit -m x' +run "git config uppercase key bot literal then commit" 2 'git config User.name Claude && git commit -m x' +run "git config uppercase key value from a substitution" 2 'git config User.name C"$(printf laude)" && git commit -m x' +run "git config uppercase key core.editor is allowed" 0 'git config Core.editor $EDITOR && git commit -m x' +run "heredoc marker before a substitution on the same line" 2 'cat <shout.txt +run "untracked uppercase secret, add && commit" 2 'git add shout.txt && git commit -m "x"' +rm shout.txt + +git config diff.algorithm bogus +run "git diff failure fails closed" 2 'git commit -m "x"' +git config --unset diff.algorithm + +printf 'aws_key=%s\n' "$AWS_KEY" >color.txt +git add color.txt +git config color.ui always +run "color.ui=always does not hide a staged secret" 2 'git commit -m "x"' +git config --unset color.ui +git rm -q --cached color.txt && rm color.txt + +ln -s /nonexistent-target dangling +run "dangling symlink does not break add && commit" 0 'git add dangling && git commit -m "x"' +rm dangling + +printf '*.txt -diff\n' >.gitattributes +printf 'aws_key=%s\n' "$AWS_KEY" >nodiff.txt +git add .gitattributes nodiff.txt +run "-diff attribute does not hide a staged secret" 2 'git commit -m "x"' +git rm -q --cached .gitattributes nodiff.txt && rm .gitattributes nodiff.txt + +printf 'aws_key=%s\n\0\n' "$AWS_KEY" >nul.txt +git add nul.txt +run "NUL byte does not hide a staged secret" 2 'git commit -m "x"' +git rm -q --cached nul.txt +run "NUL byte does not hide an untracked secret" 2 'git add nul.txt && git commit -m "x"' +rm nul.txt + +printf '*.txt diff=hide\n' >.gitattributes +git config diff.hide.textconv 'sed s/AKIA/XXXX/' +printf 'aws_key=%s\n' "$AWS_KEY" >conv.txt +git add .gitattributes conv.txt +run "textconv does not rewrite the staged secret" 2 'git commit -m "x"' +git config --unset diff.hide.textconv +git rm -q --cached .gitattributes conv.txt && rm .gitattributes conv.txt + printf 'aws_key=%s\n' "$AWS_KEY" >config.txt git add config.txt run "staged secret" 2 'git commit -m "x"' @@ -190,6 +991,22 @@ git add key.txt run "staged private key header" 2 'git commit -m "x"' git rm -q --cached key.txt && rm key.txt +# A secret on line 1 of a staged diff far larger than a pipe buffer must still be seen. +{ printf 'aws_key=%s\n' "$AWS_KEY"; head -c 200000 /dev/zero | tr '\0' 'b' | fold -w 80; } >big.txt +git add big.txt +run "secret at the top of a 200 KB staged diff" 2 'git commit -m "x"' +git rm -q --cached big.txt && rm big.txt + +printf '++aws_key=%s\n' "$AWS_KEY" >plus.txt +git add plus.txt +run "secret on an added line starting with ++" 2 'git commit -m "x"' +git rm -q --cached plus.txt && rm plus.txt + +printf '++ b/x aws_key=%s\n' "$AWS_KEY" >header.txt +git add header.txt +run "secret on a line shaped like a diff header" 2 'git commit -m "x"' +git rm -q --cached header.txt && rm header.txt + # Scrubbing a leaked secret (a removed line) must stay committable; git is called directly here, # the hook is not involved in seeding the leak. printf 'aws_key=%s\n' "$AWS_KEY" >leaked.txt @@ -208,14 +1025,44 @@ for t in bash sh grep git sed tr printf cat; do done run_nojq() { printf '%s' "$3" | jq -Rs '{tool_input:{command:.}}' >"$TMP/payload" - PATH="$NOJQ" bash "$HOOK" <"$TMP/payload" >/dev/null 2>"$TMP/err" + PATH="$NOJQ" "$BASH" "$HOOK" <"$TMP/payload" >/dev/null 2>"$TMP/err" record "$1" "$2" $? "$3" } run_nojq "no jq: commit fails closed" 2 'git commit -m "x"' run_nojq "no jq: unrelated command allowed" 0 'ls' +# --- missing awk: the strip cannot run, so a git commit/push fails closed --------------------- +NOAWK="$TMP/noawk" +mkdir -p "$NOAWK" +for t in bash sh grep git sed tr printf cat jq wc; do + p=$(command -v "$t") && ln -s "$p" "$NOAWK/$t" +done +run_noawk() { + printf '%s' "$3" | jq -Rs '{tool_input:{command:.}}' >"$TMP/payload" + PATH="$NOAWK" "$BASH" "$HOOK" <"$TMP/payload" >/dev/null 2>"$TMP/err" + record "$1" "$2" $? "$3" +} +run_noawk "no awk: commit fails closed" 2 'git commit -m "x"' +run_noawk "no awk: unrelated command allowed" 0 'ls' + +# --- missing tr or wc: the segment split cannot run, so a git commit/push fails closed ---------- +mktoolbox() { # mktoolbox + mkdir -p "$1"; d=$1; shift + for t in "$@"; do p=$(command -v "$t") && ln -s "$p" "$d/$t"; done +} +mktoolbox "$TMP/notr" bash sh grep git sed awk printf cat jq wc +mktoolbox "$TMP/nowc" bash sh grep git sed awk printf cat jq tr +run_without() { # run_without + printf '%s' "$4" | jq -Rs '{tool_input:{command:.}}' >"$TMP/payload" + PATH="$1" "$BASH" "$HOOK" <"$TMP/payload" >/dev/null 2>"$TMP/err" + record "$2" "$3" $? "$4" +} +run_without "$TMP/notr" "no tr: push fails closed" 2 'git push --force origin main' +run_without "$TMP/nowc" "no wc: commit fails closed" 2 'git commit --no-verify -m x' +run_without "$TMP/notr" "no tr: unrelated command allowed" 0 'ls' + # Every case in this file must have been counted: a case that ran in a subshell would be lost. -EXPECTED=$(grep -cE '^(run|run_raw|run_nojq) ' "$SELF") +EXPECTED=$(grep -cE '^(run|run_raw|run_err|run_nojq|run_noawk|run_without) ' "$SELF") if [ "$((PASS + FAIL))" -ne "$EXPECTED" ]; then echo "FAIL: $EXPECTED cases defined, $((PASS + FAIL)) counted" FAIL=$((FAIL + 1))