From ccd735752ddd8702f17713bcd4b05bb5c62e9396 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 1 Oct 2026 17:46:57 +0200 Subject: [PATCH] fix(global-settings): deny an unauthorized git push in every form, before any prompt (v2.7.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The push check ran after the git -C, gh, curl and docker ask guards. An ask exits the hook, so `git -C repo commit -m x && git push` (or the same after `gh pr create`, `curl -X POST`, `docker compose up`) prompted once for the first part and, on approval, ran the push without the phrase. git's global options before the subcommand were not recognised at all: `git -c k=v push`, `git --no-pager push` and `git --work-tree push` passed with no deny and no prompt. The push guard now sits before every ask, next to the production guard, and matches `git` followed by any of its global options (-C/-c , --git-dir/--work-tree/--namespace/... , --flag[=value], -p/-P) and then `push`. With the phrase, the rest of the command is still asked about. data_free_cmd strips -m/--body values on `git commit|tag|notes` lines too, so a message that mentions git push after `git -C commit` is not denied as a push. Tests: 26 fixtures for the forms above plus 9 transcript-backed checks (phrase given → still asks for the chained part; no phrase → denied). Co-Authored-By: Claude Opus 5.5 --- docs/claude/global-claude-settings.md | 2 +- global-settings/README.md | 13 +++- global-settings/VERSION | 2 +- global-settings/block-write-commands.sh | 51 +++++++------ .../tests/test-block-write-commands.sh | 74 ++++++++++++++++++- 5 files changed, 116 insertions(+), 26 deletions(-) diff --git a/docs/claude/global-claude-settings.md b/docs/claude/global-claude-settings.md index 6fcdc3425..ee6799a51 100644 --- a/docs/claude/global-claude-settings.md +++ b/docs/claude/global-claude-settings.md @@ -326,7 +326,7 @@ When both define the same server name, the project-scope entry wins. | ---------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | | **curl** | — | All curl write commands (data/output flags); canonical GitHub raw URL passes when wrapped in a config-file write | — | | **gh api** | — | All gh api commands (not auto-approved) | — | -| **git push** | Last user message contains authorized phrase | — | Blocked otherwise | +| **git push** | Last user message contains authorized phrase | — | Blocked otherwise — in every form (`git -C push`, `git -c k=v push`, `git --no-pager push`) and when chained after a command that prompts (v2.7.2) | | **git -C** | Read-only subcommands | Write subcommands, branch/remote writes | `push` (phrase-authorized) | | **git branch** (bare) | `--list`, `-a`, `-v` (auto-approved) | `-d/-D/-m/-M/-c/-C`, `--delete`, `--move`, `--copy` | — | | **git remote** (bare) | `-v`, `show` (auto-approved) | `add`, `remove`, `rename`, `set-url`, `prune`, `update` | — | diff --git a/global-settings/README.md b/global-settings/README.md index e3510e58d..f59e2e7b1 100644 --- a/global-settings/README.md +++ b/global-settings/README.md @@ -282,10 +282,19 @@ The check runs **before** every `ask` guard. An ask exits the hook, so a product The production check and the `git push` check read the command with the *data* taken out (`data_free_cmd`), so a commit message or PR body that mentions `git push` is no longer hard-denied as a push. Removed, and nothing else: - the body of a heredoc fed to a data sink (`cat`, `tee`, `git`, `gh`, `jq`, `wc`, `sort`, `head`, `tail`, `grep`, `less`, `more`) on a line without a pipe or command substitution; -- the quoted value of `-m`/`--message`/`-b`/`--body`/`-t`/`--title`/`--notes` on a `git commit|tag|notes` or `gh pr|issue|release` line — single-quoted always, double-quoted only without `$(` or a backtick. +- the quoted value of `-m`/`--message`/`-b`/`--body`/`-t`/`--title`/`--notes` on a `git commit|tag|notes` (git's global options such as `-C ` may sit in between, since v2.7.2) or `gh pr|issue|release` line — single-quoted always, double-quoted only without `$(` or a backtick. Still seen: a push or production write inside `bash -c`, `eval`, `$(…)`, backticks, a heredoc fed to `bash`/`sh`/`python`/…, and `cat < push`, `git -c key=value push`, `git --no-pager push`, `git -P push`, `git --git-dir push`, `git --work-tree push`; +- chained after a command that prompts: `git -C repo commit -m … && git push`, `gh pr create … && git push`, `curl -X POST … ; git push`. + +Before v2.7.2 the push check ran after the `git -C`, `gh`, `curl` and `docker` prompts. An ask exits the hook, so approving that prompt also ran the chained push without the phrase. The check now runs before every `ask`, like the production guard, and with the phrase the hook still asks about the rest of the command. + ## ⚠️ Bumping the version — REQUIRED on every change **Any commit that modifies an *installed artifact* in `global-settings/` MUST also increment `VERSION`.** An installed artifact is anything the [Install](#install) steps copy into `~/.claude/` — `settings.json`, the hook scripts, and the `.example` files. @@ -306,7 +315,7 @@ Use the `/verify-global-settings-version` command to check whether a version bum The settings use four independent layers of protection, each catching what the others miss: 1. **Deny-list** (`settings.json` deny rules) — hard-blocks file edits to `~/.claude/` config files and destructive Bash commands. These cannot be overridden from within a Claude session. The rules are `Edit(...)` only: one `Edit(path)` rule covers every file-editing tool (Write, MultiEdit, NotebookEdit), while a `Write(path)` rule is not matched by file-permission checks at all — the seven inert `Write(...)` twins were dropped in v2.4.5. -2. **Bash hook** (`block-write-commands.sh`) — runs on every Bash command. Catches write operations, command chaining, obfuscation, symlink attacks, and (since v1.7.0) `chattr` attempts on protected paths plus script-body scans for invoked scripts that target `~/.claude/`. Can deny (hard block) or ask (prompt the user). Since v2.6.0 it also hard-blocks production writes and ignores *data* in two checks — see [Production is read-only](#production-is-read-only-v260). +2. **Bash hook** (`block-write-commands.sh`) — runs on every Bash command. Catches write operations, command chaining, obfuscation, symlink attacks, and (since v1.7.0) `chattr` attempts on protected paths plus script-body scans for invoked scripts that target `~/.claude/`. Can deny (hard block) or ask (prompt the user). Since v2.6.0 it also hard-blocks production writes and ignores *data* in two checks — see [Production is read-only](#production-is-read-only-v260). Since v2.7.2 the `git push` check, too, runs before every prompt — see [`git push` needs the phrase in every form](#git-push-needs-the-phrase-in-every-form-v272). 3. **Tool hook** (`block-config-tool-writes.sh`, added in v1.7.0) — runs on Write/Edit/MultiEdit tool calls. Denies tools whose `file_path` is a protected `~/.claude/` config file, and denies tools that would create a _script_ whose body, when executed, would write to a protected path. Closes the "write a script then run it" bypass. Since v2.4.6 the body scan exempts the canonical sources in this repo's `global-settings/` directory (matched by their exact filenames, plus `tests/*.sh`) — those files *are* the update mechanism, so their bodies necessarily contain the operations the scan looks for, and without the exemption Claude could never maintain them. The exemption matches on a canonicalized path (`realpath -m`, plus an outright refusal to exempt any path still carrying a `..` component), so neither a traversal nor a symlinked parent can reach an exempt pattern while landing on an installed file. The exemption covers staging only. What still holds after it: the installed copies stay covered by this hook's `file_path` guard and by layer 4, and layer 2 catches the common invocation shapes (`bash `, `source `, bare-path execution) if such a script is run. Layer 2's script-body scan is not exhaustive — it reads the first token of each command segment, so wrapper forms like `nohup bash ` or `timeout 5 bash ` slip past it. That limit is not introduced here: staging an executable payload was already possible pre-exemption via any path a script-extension check doesn't cover (`/tmp/x.txt` with no shebang, then `bash /tmp/x.txt`). Layer 4 is what actually closes it, which is why the relock matters. 4. **Kernel immutability** (`chattr +i`, the new authoritative layer in v1.7.0) — once set, the kernel refuses every write to the file regardless of permissions, regardless of which process attempts it, regardless of any hook outcome. Only `root` can clear the bit, and only `sudo chattr -i` (which Claude is hard-blocked from running) toggles it. diff --git a/global-settings/VERSION b/global-settings/VERSION index 860487ca1..37c2961c2 100644 --- a/global-settings/VERSION +++ b/global-settings/VERSION @@ -1 +1 @@ -2.7.1 +2.7.2 diff --git a/global-settings/block-write-commands.sh b/global-settings/block-write-commands.sh index 1c75d96c1..d72c2f96a 100644 --- a/global-settings/block-write-commands.sh +++ b/global-settings/block-write-commands.sh @@ -74,7 +74,8 @@ PUSH_DENY_MSG="Blocked: git push requires explicit authorization. Include one of # command substitution. A heredoc fed to bash/sh/python/ssh/eval/… is # code and stays, as does `cat < allowed in +# between) or gh pr/issue/release line. Single-quoted always # (never expands); double-quoted only without $( or a backtick. # The config guard above deliberately keeps scanning the full $cmd: there a # false deny is cheaper than a gap (see README → Security model). @@ -83,6 +84,10 @@ data_free_cmd() { BEGIN { split("cat tee git gh jq wc sort head tail grep less more", s, " ") for (i in s) sink[s[i]] = 1 + # `git [global options] commit|tag|notes` or `gh pr|issue|release` + val = "([^ \t;&|\"\047]|\"[^\"]*\"|\047[^\047]*\047)+" + gopts = "([ \t]+(-[Cc]|--(git-dir|work-tree|namespace|config-env|super-prefix|attr-source))[ \t]+" val "|[ \t]+--[a-z][a-z-]*(=" val ")?|[ \t]+-[pP])*" + msgline = "(^|[;&| \t(])(git" gopts "[ \t]+(commit|tag|notes)|gh[ \t]+(pr|issue|release))([ \t]|$)" } hd != "" { t = $0; sub(/^\t+/, "", t) @@ -103,7 +108,7 @@ data_free_cmd() { hd = word strip = (w[1] in sink) && line !~ /\|/ && line !~ /\$\(/ && line !~ /`/ } - if (line ~ /(^|[;&| \t(])(git[ \t]+(commit|tag|notes)|gh[ \t]+(pr|issue|release))([ \t]|$)/) { + if (line ~ msgline) { gsub(/(-m|--message|-b|--body|-t|--title|--notes)(=|[ \t]+)\047[^\047]*\047/, "-m DATA", line) while (match(line, /(-m|--message|-b|--body|-t|--title|--notes)(=|[ \t]+)"[^"]*"/)) { v = substr(line, RSTART, RLENGTH) @@ -328,6 +333,28 @@ while IFS= read -r _seg; do fi done < <(data_free_cmd | sed -E 's/(\|\||&&|;|\|)/\n/g') +# ── git push (HARD BLOCK without the auth phrase) ──────────────────────────── +# Every form: direct, chained (`cd x && git push`, `git -C r commit … && git +# push`), and with git's global options before the subcommand (`git -C +# push`, `git -c k=v push`, `git --no-pager push`, `git --work-tree +# push`). Placed before every `ask` guard: an ask exits the hook, so a push +# chained after, say, `git -C r commit` or `gh pr create` would otherwise run +# on one approval of that prompt, without the phrase. +# Matched on data_free_cmd: a commit message or PR body that mentions +# `git push` is data, not a push. A push inside bash -c, eval or a heredoc +# fed to a shell is still seen. +_GIT_VAL='([^[:space:];&|"'\'']|"[^"]*"|'\''[^'\'']*'\'')+' +# git's own options that take a separate value; any other --option is a flag +# or --option=value. +_GIT_GOPTS='([[:space:]]+(-[Cc]|--(git-dir|work-tree|namespace|config-env|super-prefix|attr-source))[[:space:]]+'"$_GIT_VAL"'|[[:space:]]+--[a-z][a-z-]*(='"$_GIT_VAL"')?|[[:space:]]+-[pP])*' +if data_free_cmd | grep -qE '\bgit'"$_GIT_GOPTS"'[[:space:]]+push\b'; then + if git_push_authorized; then + : # authorized by user message — allow + else + hard_deny "$PUSH_DENY_MSG" + fi +fi + # ── curl ────────────────────────────────────────────────────────────────────── if echo "$cmd" | grep -qE '(^|[;&|]\s*)curl\b'; then # Unambiguous write flags — check the full command string so that piped curl @@ -428,11 +455,7 @@ if echo "$cmd" | grep -qE '(^|[;&|]\s*)git\b' && echo "$cmd" | grep -qE '\s-C\s' fi ;; push) - if git_push_authorized; then - : # authorized by user message — allow - else - hard_deny "$PUSH_DENY_MSG" - fi + : # reached only when authorized — the git push guard above denies otherwise ;; branch) if echo "$cmd" | grep -qE '\bbranch\b.*-[a-zA-Z]*[dDmMcC]'; then @@ -453,20 +476,6 @@ if echo "$cmd" | grep -qE '(^|[;&|]\s*)git\b' && echo "$cmd" | grep -qE '\s-C\s' esac fi -# ── git push (all forms: direct, chained with &&/;, etc.) ──────────────────── -# Uses \b word boundary (not ^ anchor) so it also catches "cd /path && git push". -# git -C ... push is handled above; this catches everything else. -# Matched on data_free_cmd: a commit message or PR body that mentions -# `git push` is data, not a push. A push inside bash -c, eval or a heredoc -# fed to a shell is still seen. -if data_free_cmd | grep -qE '\bgit\s+push\b'; then - if git_push_authorized; then - : # authorized by user message — allow - else - hard_deny "$PUSH_DENY_MSG" - fi -fi - # ── git branch (prompt for write flags, bare — without -C) ─────────────────── if echo "$cmd" | grep -qE '(^|[;&|]\s*)git\s+branch\b' && ! echo "$cmd" | grep -qE '\s-C\s'; then if echo "$cmd" | grep -qE '\bbranch\b.*-[a-zA-Z]*[dDmMcC]' \ diff --git a/global-settings/tests/test-block-write-commands.sh b/global-settings/tests/test-block-write-commands.sh index ca246762a..130d26c42 100644 --- a/global-settings/tests/test-block-write-commands.sh +++ b/global-settings/tests/test-block-write-commands.sh @@ -540,6 +540,38 @@ add_deny "push: backticks in -m" "git commit -m \"\`git push\`\"" add_deny "push: bash -c" "bash -c 'git push'" add_deny "push: after the heredoc ends" $'git commit -F - <<\'EOF\'\nmsg\nEOF\ngit push origin main' +# ── v2.7.2 git push: every form, checked before any ask ─────────────────────── +# An ask exits the hook. Before v2.7.2 the push check came after the git -C, +# gh, curl and docker asks, so one approval of that prompt also ran a chained +# push without the phrase. git's global options before `push` were not +# recognised at all. No auth phrase in these fixtures → every push is denied. +add_deny "push v2.7.2: after git -C commit" "git -C repo commit -m x && git push" +add_deny "push v2.7.2: after git -C add, -C push" "git -C repo add . && git -C repo push origin main" +add_deny "push v2.7.2: git -C push alone" "git -C /home/u/repo push" +add_deny "push v2.7.2: after gh pr create" "gh pr create --title t --body b && git push" +add_deny "push v2.7.2: after gh api POST" "gh api -X POST repos/o/r/issues -f title=t; git push" +add_deny "push v2.7.2: after curl POST" "curl -X POST https://example.test && git push" +add_deny "push v2.7.2: after docker compose up" "docker compose up -d; git push" +add_deny "push v2.7.2: after git branch -D" "git branch -D old && git push origin --delete old" +add_deny "push v2.7.2: after rm" "rm build.log && git push" +add_deny "push v2.7.2: after npm install" "npm install && git push" +add_deny "push v2.7.2: -c key=value" "git -c user.name=x push" +add_deny "push v2.7.2: -c quoted value" "git -c user.name=\"A B\" push origin main" +add_deny "push v2.7.2: -c single-quoted value" "git -c 'core.sshCommand=ssh -i k' push" +add_deny "push v2.7.2: --no-pager" "git --no-pager push" +add_deny "push v2.7.2: -P" "git -P push --force" +add_deny "push v2.7.2: --git-dir=path" "git --git-dir=/r/.git push" +add_deny "push v2.7.2: --git-dir path" "git --git-dir /r/.git push" +add_deny "push v2.7.2: --work-tree path" "git --work-tree /x push" +add_deny "push v2.7.2: -C quoted path" "git -C \"/a b\" push" +add_deny "push v2.7.2: -C and -c stacked" "git -C repo -c a=b --no-pager push" +add_deny "push v2.7.2: message stripped, push kept" "git -C r --no-pager commit -m \"a git push b\" && git push" +add_ask "push v2.7.2: -C commit, message mentions push" "git -C repo commit -m \"explain git push\"" +add_ask "push v2.7.2: -C tag, annotation mentions push" "git -C repo tag -a v1 -m 'then git push --tags'" +add_allow "push v2.7.2: dir named push" "git -C push status" +add_allow "push v2.7.2: --no-pager log --author push" "git --no-pager log --author push" +add_allow "push v2.7.2: -C log --grep push" "git -C repo log --grep push" + # ── runner ──────────────────────────────────────────────────────────────────── pass=0; fail=0; fail_details=() for t in "${TESTS_ALLOW[@]}"; do @@ -794,7 +826,47 @@ for t in "${TESTS_PUSH_DENY[@]}"; do [[ $VERBOSE -eq 1 ]] && echo "FAIL: push deny → $label" fi done -push_total=$(( ${#TESTS_PUSH_ALLOW[@]} + ${#TESTS_PUSH_DENY[@]} )) +# v2.7.2: with the phrase, a push chained after a command that prompts no +# longer short-circuits that prompt — the hook still asks for the other part. +# Without the phrase, the same command is denied (not asked). +declare -a TESTS_PUSH_AUTH_ASK=( + "git -C repo commit -m x && git push" + "gh pr create --title t --body b && git push" + "git -C repo add . && git -C repo push origin main" +) +run_cmd_with_transcript() { # args: cmd transcript → prints hook output, returns hook exit code + jq -c -n --arg cmd "$1" --arg tp "$2" '{tool_input:{command:$cmd}, transcript_path:$tp}' \ + | bash "$HOOK" 2>/dev/null +} +push_extra=0 +for c in "${TESTS_PUSH_AUTH_ASK[@]}"; do + out=$(run_cmd_with_transcript "$c" "$PUSH_TMP/b.jsonl"); ec=$? + if [[ $ec -eq 0 ]] && grep -q '"permissionDecision":"ask"' <<<"$out"; then + push_pass=$((push_pass+1)); [[ $VERBOSE -eq 1 ]] && echo "PASS: push auth+ask → $c" + else + push_fail=$((push_fail+1)); fail_details+=("[PUSH AUTH ASK expected, got ec=$ec] $c") + [[ $VERBOSE -eq 1 ]] && echo "FAIL: push auth+ask → $c" + fi + run_cmd_with_transcript "$c" "$PUSH_TMP/f.jsonl" >/dev/null; ec=$? + if [[ $ec -eq 2 ]]; then + push_pass=$((push_pass+1)); [[ $VERBOSE -eq 1 ]] && echo "PASS: push no-auth deny → $c" + else + push_fail=$((push_fail+1)); fail_details+=("[PUSH DENY expected, got ec=$ec] no phrase: $c") + [[ $VERBOSE -eq 1 ]] && echo "FAIL: push no-auth deny → $c" + fi + push_extra=$((push_extra+2)) +done +for c in "git -c user.name=x push" "git --no-pager push origin main"; do + run_cmd_with_transcript "$c" "$PUSH_TMP/b.jsonl" >/dev/null; ec=$? + if [[ $ec -ne 2 ]]; then + push_pass=$((push_pass+1)); [[ $VERBOSE -eq 1 ]] && echo "PASS: push auth allow → $c" + else + push_fail=$((push_fail+1)); fail_details+=("[PUSH ALLOW expected, DENIED] phrase given: $c") + [[ $VERBOSE -eq 1 ]] && echo "FAIL: push auth allow → $c" + fi + push_extra=$((push_extra+1)) +done +push_total=$(( ${#TESTS_PUSH_ALLOW[@]} + ${#TESTS_PUSH_DENY[@]} + push_extra )) total=$((allow_total + deny_total + ask_total + push_total)) total_pass=$((allow_pass + deny_pass + ask_pass + push_pass))