From 66e59c26c72ef4408532bd0c6f663af0798f9754 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Fri, 2 Oct 2026 16:30:01 +0200 Subject: [PATCH] fix(global-settings): a hard deny beats every prompt; git aliases that push need the phrase (v2.7.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ask() printed its prompt and exited, so every hard deny placed below an ask guard was never reached for a chained command. One approval of the first part also ran `date -s`, `--legacy-peer-deps`, a link to a ~/.claude config file, a script that writes one, or a step out of WSL: `gh pr create … && date -s 2020-01-01` only prompted for gh. ask() now records the first reason and the prompt is raised at the end of the hook, after every guard; the order of the parts no longer matters. git push in more forms: - a git alias that resolves to push (`git config --get alias.` in the -C directory or the session cwd, alias chains followed, aliases that shadow a git command ignored); - defining one: `git config alias.p push`, `git -c alias.p=push p`, an [alias] section written into a git config file; - `push` must end the word, so `git push-notes` is no longer denied as a push. Tests: every prompting guard chained with every hard-denied command in three orders, ask-reason checks, push-word, alias definition and alias resolution against a fixture git config. 651 of 8672 checks fail against v2.7.2. Co-Authored-By: Claude Opus 5.5 --- docs/claude/global-claude-settings.md | 6 +- global-settings/README.md | 29 ++- global-settings/VERSION | 2 +- global-settings/block-write-commands.sh | 110 +++++++++-- .../tests/test-block-write-commands.sh | 187 ++++++++++++++++++ 5 files changed, 316 insertions(+), 18 deletions(-) diff --git a/docs/claude/global-claude-settings.md b/docs/claude/global-claude-settings.md index 3143ff37a..c6ac8e0d0 100644 --- a/docs/claude/global-claude-settings.md +++ b/docs/claude/global-claude-settings.md @@ -321,14 +321,14 @@ When both define the same server name, the project-scope entry wins. ## What `block-write-commands.sh` does -- Reads **JSON from stdin** once into a variable, then extracts `cmd` and `transcript_path`. -- On deny, prints `permissionDecision: "deny"` JSON. On ask, prints `permissionDecision: "ask"` JSON. On allow, exits `0`. +- Reads **JSON from stdin** once into a variable, then extracts `cmd`, `transcript_path` and `cwd`. +- On deny, prints `permissionDecision: "deny"` JSON and exits `2`. On ask, prints `permissionDecision: "ask"` JSON — since v2.7.3 only at the end, after every deny check has run, so a hard block anywhere in a chained command wins over a prompt for another part. On allow, exits `0`. | Area | Allowed silently | Prompts for approval | Hard blocked | | ---------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | | **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 — 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 push** | Last user message contains authorized phrase | — | Blocked otherwise — in every form (`git -C push`, `git -c k=v push`, `git --no-pager push`, v2.7.2; a git alias for push, v2.7.3) and when chained with a command that prompts | | **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 6f6ad05ed..f8b7a3465 100644 --- a/global-settings/README.md +++ b/global-settings/README.md @@ -285,7 +285,7 @@ If Claude reports `Permission denied` mid-update, that is this and nothing else. `block-write-commands.sh` hard-denies `kubectl` / `oc` with a mutating verb (`apply`, `create`, `delete`, `edit`, `patch`, `replace`, `scale`, `set`, `label`, `annotate`, `exec`, `cp`, `drain`, `cordon`, `uncordon`, `taint`, `run`, `expose`, `autoscale`, `debug`, `attach`, `rollout` except `status`/`history`) and `helm install|upgrade|uninstall|delete|rollback`, whenever the same command segment names a `*-prod` namespace or context. No phrase unlocks it: Claude gives you the filled-in command and you run it. Reads (`get`, `describe`, `logs`, `top`, `rollout status`) and every non-prod namespace are not this guard's business. -The check runs **before** every `ask` guard. An ask exits the hook, so a production write chained after, for example, `gh pr create` would otherwise get through on one approval of the gh prompt. +The check runs **before** every `ask` guard. Before v2.7.3 an ask exited the hook, so that placement was what kept a production write chained after, for example, `gh pr create` from getting through on one approval of the gh prompt; since v2.7.3 every hard deny wins over every ask regardless of placement — see [A hard deny always beats a prompt](#a-hard-deny-always-beats-a-prompt-v273). ### Data is not a command @@ -303,7 +303,30 @@ Without an authorization phrase in your last message, `block-write-commands.sh` - with git's global options before `push`: `git -C 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. +Before v2.7.2 the push check ran after the `git -C`, `gh`, `curl` and `docker` prompts. An ask exited 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. + +Since v2.7.3 two more forms count as a push: + +- **a git alias that resolves to `push`.** For every git subcommand in the command the hook runs `git config --get alias.` — in the `-C` directory or the session's working directory — and follows alias-to-alias chains up to ten deep: `git p` with `alias.p = push`, `alias.pp = p`, `alias.sp = !git push origin` or `alias.x = -c k=v push` is denied like `git push`. A name git ships as a command is skipped, because git ignores an alias that shadows a command. +- **defining such an alias**: `git config alias.p push`, `git config set alias.p push`, `git -c alias.p=push p`, `git -c 'alias.p=!git push' p`, or an `[alias]` section with `push` written into `.gitconfig`, `.git/config` or `~/.config/git/config` (heredoc included). + +`push` must end the word: `git push-notes` is a different command and is no longer denied as a push (an alias named `push-notes` that pushes still is). + +Not covered: git configuration set through `GIT_CONFIG_*` environment variables or `--config-env`, and a push hidden inside a script that an alias runs. + +## A hard deny always beats a prompt (v2.7.3) + +Before v2.7.3, `ask()` printed its prompt and exited on the spot. Every hard deny placed lower in `block-write-commands.sh` was then never reached for a chained command, so one approval of the first part also ran the rest: + +| Command | Before v2.7.3 | Since v2.7.3 | +|---|---|---| +| `gh pr create … && date -s 2020-01-01` | prompt for `gh` — the clock change ran too | denied (`date -s`) | +| `rm build.log && npm ci --legacy-peer-deps` | prompt for `rm` | denied (`--legacy-peer-deps`) | +| `curl -X POST … && ln -s /tmp/x ~/.claude/settings.json` | prompt for `curl` | denied (link to a config file) | +| `docker compose up -d && bash /tmp/script-that-writes-claude-config.sh` | prompt for `docker` | denied (script-body scan) | +| `git -C repo commit -m x && cd /mnt/c` (or `powershell.exe`, `wsl`) | prompt for `git -C` | denied (WSL boundary) | + +Now `ask()` only records its reason. Every guard runs, a hard deny anywhere wins, and the prompt is raised at the very end of the hook — once, with the reason of the first guard that asked (the same reason you saw before). The order of the parts in the command does not matter either: `date -s … && gh pr create …` is denied the same way. ## ⚠️ Bumping the version — REQUIRED on every change @@ -325,7 +348,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). 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). +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) — and since v2.7.3 a prompt is raised only after every hard deny has had its turn — see [A hard deny always beats a prompt](#a-hard-deny-always-beats-a-prompt-v273). 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 37c2961c2..2c9b4ef42 100644 --- a/global-settings/VERSION +++ b/global-settings/VERSION @@ -1 +1 @@ -2.7.2 +2.7.3 diff --git a/global-settings/block-write-commands.sh b/global-settings/block-write-commands.sh index d72c2f96a..87e81eb0d 100644 --- a/global-settings/block-write-commands.sh +++ b/global-settings/block-write-commands.sh @@ -10,15 +10,23 @@ input=$(cat) cmd=$(echo "$input" | jq -r '.tool_input.command // ""') transcript_path=$(echo "$input" | jq -r '.transcript_path // ""') +hook_cwd=$(echo "$input" | jq -r '.cwd // ""') hard_deny() { printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$*" exit 2 # exit 2 = hard block — tool call refused even if JSON parsing fails } +# ask — record that the command needs approval, and carry on. The prompt is +# emitted at the very end of the hook, after every guard has run, so a hard +# deny anywhere in the file wins over an ask anywhere in the file: one +# approval of `gh pr create … && date -s …` can no longer slip the date -s +# past its hard block. Before v2.7.3 ask() exited on the spot, so every hard +# deny placed below an ask guard was skipped for a chained command. The first +# reason recorded is the one shown, as before. +_ask_reason="" ask() { - printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' "$*" - exit 0 + [ -n "$_ask_reason" ] || _ask_reason="$*" } # Returns 0 (true) if the last human-typed user message in the transcript contains @@ -301,7 +309,8 @@ fi # through on one approval of the gh prompt. # The binary may sit anywhere in a segment (`bash -c "kubectl …"` counts); # only DATA text is ignored (data_free_cmd). Added in v2.6.0; it started -# life as a personal user-hook. +# life as a personal user-hook. (Since v2.7.3 ask() no longer exits, so the +# placement is belt and braces.) _KUBE_WRITE='^(apply|create|delete|edit|patch|replace|scale|set|label|annotate|exec|cp|drain|cordon|uncordon|taint|run|expose|autoscale|debug|attach|rollout)$' _HELM_WRITE='^(install|upgrade|uninstall|delete|rollback)$' while IFS= read -r _seg; do @@ -337,21 +346,94 @@ done < <(data_free_cmd | sed -E 's/(\|\||&&|;|\|)/\n/g') # 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. +# push`). Placed before every `ask` guard (v2.7.2); since v2.7.3 an ask no +# longer exits the hook, so a push chained after a prompting command is +# denied either way. # 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. +# fed to a shell is still seen. `push` must end the word: `git push-notes` +# is another command (an alias that pushes is caught below). +# +# Aliases (v2.7.3): `git p` with alias.p = push is a push. Two checks: +# 1. a command that defines an alias and mentions push (`git config +# alias.p push`, `git -c alias.p=push p`, `alias.p '!git push'`, an +# `[alias]` section written to a git config file, heredoc included) +# needs the phrase too; +# 2. every other git subcommand is looked up with `git config --get +# alias.`, in the -C directory or the session cwd, following +# alias → alias chains up to ten deep. A name git ships as a command +# is never an alias (git ignores aliases that shadow commands). +# Not covered: config set through GIT_CONFIG_* environment variables or +# --config-env, and a push hidden in a script the alias runs. _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 +_PUSH_END='([^A-Za-z0-9_-]|$)' +_dfc=$(data_free_cmd) + +# _git_alias_pushes — 0 when `git `, run in , expands +# to a push through one or more aliases. +_git_alias_pushes() { + local name="$1" dir="$2" val depth + for ((depth = 0; depth < 10; depth++)); do + val=$(git -C "$dir" config --get "alias.$name" 2>/dev/null) || return 1 + [ -n "$val" ] || return 1 + # An alias named after a real git command is never used by git. + git -C "$dir" --list-cmds=main,others 2>/dev/null | grep -qxF -- "$name" && return 1 + if [[ "$val" == '!'* ]]; then + # Shell alias: any push word counts (`!git push`, `!f() { git push; }; f`). + printf '%s' "$val" | grep -qE '(^|[^A-Za-z0-9_-])push'"$_PUSH_END" && return 0 + return 1 + fi + printf ' %s' "$val" | grep -qE '^'"$_GIT_GOPTS"'[[:space:]]+push'"$_PUSH_END" && return 0 + name=$(printf ' %s' "$val" | sed -E 's/^'"$_GIT_GOPTS"'[[:space:]]+//' | awk '{print $1}') + [ -n "$name" ] || return 1 + done + return 1 +} + +_push_how="" +if printf '%s\n' "$_dfc" | grep -qE '\bgit'"$_GIT_GOPTS"'[[:space:]]+push'"$_PUSH_END"; then + _push_how="direct" +elif printf '%s\n' "$_dfc" | grep -qE '\balias\.[A-Za-z0-9_.-]+|\[alias\]' \ + && printf '%s\n' "$_dfc" | grep -qE '(^|[^A-Za-z0-9_-])push'"$_PUSH_END"; then + _push_how="defines a git alias that pushes" +elif printf '%s\n' "$_dfc" | grep -qE '\.gitconfig|\.git/config|/git/config' \ + && printf '%s\n' "$cmd" | grep -qF '[alias]' \ + && printf '%s\n' "$cmd" | grep -qE '(^|[^A-Za-z0-9_-])push'"$_PUSH_END"; then + # A heredoc body is data to data_free_cmd; written into a git config + # file (named outside the data), an [alias] section is configuration + # all the same. + _push_how="writes a git alias that pushes into a git config file" +else + _base="${hook_cwd:-$PWD}" + while IFS= read -r _inv; do + [ -n "$_inv" ] || continue + _sub="${_inv##*[[:space:]]}" + _dir="$_base" + if [[ "$_inv" =~ [[:space:]]-C[[:space:]]+(\"[^\"]*\"|\'[^\']*\'|[^[:space:]]+) ]]; then + _cdir="${BASH_REMATCH[1]}"; _cdir="${_cdir//[\"\']/}" + # shellcheck disable=SC2088 # literal tilde token, expanded by hand + [[ "$_cdir" == '~/'* ]] && _cdir="$HOME/${_cdir#'~/'}" + [[ "$_cdir" == /* ]] || _cdir="$_base/$_cdir" + _dir="$_cdir" + fi + [ -d "$_dir" ] || _dir="$HOME" + if _git_alias_pushes "$_sub" "$_dir"; then + _push_how="runs \`git $_sub\`, a git alias for push" + break + fi + done < <(printf '%s\n' "$_dfc" | grep -oE '\bgit'"$_GIT_GOPTS"'[[:space:]]+[A-Za-z][A-Za-z0-9_.-]*' \ + | grep -vE '[[:space:]](status|log|diff|show|add|commit|fetch|pull|checkout|switch|branch|rebase|merge|stash|rev-parse|config|remote|tag|reset|restore|worktree|ls-files|grep|blame|describe|clean|init|clone|help|version)$') +fi +if [ -n "$_push_how" ]; then if git_push_authorized; then : # authorized by user message — allow - else + elif [ "$_push_how" = "direct" ]; then hard_deny "$PUSH_DENY_MSG" + else + hard_deny "Blocked: this command $_push_how, and ${PUSH_DENY_MSG#Blocked: }" fi fi @@ -606,8 +688,8 @@ fi # ── Generic output redirect guard ──────────────────────────────────────────── # Safety net: catch any command writing to a file via > or >> that was not -# already handled by a specific guard above (those guards exit 0 on match, -# so this only fires for unhandled commands). +# already handled by a specific guard above (a guard that asked first keeps +# its own reason: ask() records only the first one). # The character class [^[:space:]&>/] already excludes fd-to-fd redirects # (>&2) and absolute paths (>/dev/null, >/tmp/…). Do NOT add a blanket # "skip if >/dev/null appears anywhere" — that suppresses the guard for @@ -770,4 +852,10 @@ if echo "$cmd" | grep -qiE '(^|[[:space:]])(wsl)([[:space:]]|$)'; then hard_deny "$WSL_DENY_MSG" fi +# ── Deferred ask ───────────────────────────────────────────────────────────── +# Every hard deny has had its turn; now raise the prompt, if any guard asked. +if [ -n "$_ask_reason" ]; then + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' "$_ask_reason" +fi + exit 0 diff --git a/global-settings/tests/test-block-write-commands.sh b/global-settings/tests/test-block-write-commands.sh index 130d26c42..f364f6765 100644 --- a/global-settings/tests/test-block-write-commands.sh +++ b/global-settings/tests/test-block-write-commands.sh @@ -572,6 +572,95 @@ 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" +# ── v2.7.3 a hard deny wins over an ask, in any order ───────────────────────── +# Before v2.7.3 ask() exited the hook on the spot, so every hard deny placed +# below an ask guard in the file was never reached for a chained command: one +# approval of `gh pr create … && date -s …` also ran the date -s. Each prompting +# command below is chained with each hard-denied one — before, after, and with +# `;` — and every combination must be denied. +ASK_PREFIXES=( + "curl -X POST https://example.test" + "curl -o out.html https://example.test" + "docker run --rm alpine true" + "docker compose up -d" + "gh api -X POST repos/o/r/issues -f title=t" + "gh pr create --title t --body b" + "gh issue comment 1 --body b" + "git -C repo commit -m x" + "git -C repo stash" + "git branch -D old" + "git remote add up https://example.test/r.git" + "env FOO=1 make" + "cat a > b" + "find . -name x -delete" + "sort -o out.txt in.txt" + "awk 'BEGIN{system(\"true\")}'" + "echo x | tee out.txt" + "hostname newname" + "rm build.log" + "rmdir emptydir" + "npm audit fix" + "npm install" + "echo x > out.txt" + "ln -s a b" + "sed -i s/a/b/ f.txt" + "chown u f.txt" + "install -m 644 a b" + "echo aGk= | base64 -d" + "eval true" +) +DENY_SUFFIXES=( + "date -s 2020-01-01" + "date --set=2020-01-01" + "npm ci --legacy-peer-deps" + "ln -s /tmp/x ~/.claude/${PROT_FILES[0]}" + "bash $SCRIPT_TMP/redirect.sh" + "cd /mnt/c/Users" + "cat /mnt/c/Windows/win.ini" + "powershell.exe -c dir" + "wsl -e ls" + "kubectl -n openwoo-prod delete pod x" + "git push origin main" +) +for a in "${ASK_PREFIXES[@]}"; do + add_ask "order v2.7.3: prompts on its own | $a" "$a" + for d in "${DENY_SUFFIXES[@]}"; do + add_deny "order v2.7.3: ask && deny | $a && $d" "$a && $d" + add_deny "order v2.7.3: ask ; deny | $a ; $d" "$a; $d" + add_deny "order v2.7.3: deny && ask | $d && $a" "$d && $a" + done +done +# Two prompting commands still prompt (nothing to deny). +add_ask "order v2.7.3: two asks" "rm build.log && npm install" +add_ask "order v2.7.3: three asks" "gh pr create --title t --body b; docker compose up -d && echo x > out.txt" + +# ── v2.7.3 git push: `push` must end the word ───────────────────────────────── +add_allow "push v2.7.3: git push-notes is another command" "git push-notes" +add_allow "push v2.7.3: -C repo push-notes (asks, no deny)" "git -C repo push-notes" +add_allow "push v2.7.3: --no-pager push_x" "git --no-pager push_x" +add_deny "push v2.7.3: push;" "git push;echo done" +add_deny "push v2.7.3: push&&" "git push&&echo done" +add_deny "push v2.7.3: push in bash -c double quotes" "bash -c \"git push\"" +add_deny "push v2.7.3: push in a subshell" "(git push)" +add_deny "push v2.7.3: push in \$( )" "echo \$(git push)" + +# ── v2.7.3 git alias that pushes: defining one ──────────────────────────────── +# (Using one is tested further down, against a fixture git config.) +add_deny "alias v2.7.3: git config alias.p push" "git config alias.p push" +add_deny "alias v2.7.3: --global" "git config --global alias.p push" +add_deny "alias v2.7.3: git config set (git 2.46+)" "git config set alias.p push" +add_deny "alias v2.7.3: -c alias.p=push p" "git -c alias.p=push p" +add_deny "alias v2.7.3: -c quoted shell alias" "git -c 'alias.p=!git push' p" +add_deny "alias v2.7.3: shell alias, sh -c" "git config alias.p '!sh -c \"git push\"'" +add_deny "alias v2.7.3: [alias] section into a file" $'cat >> ~/.gitconfig <<\'EOF\'\n[alias]\n\tp = push\nEOF' +add_deny "alias v2.7.3: [alias] via printf" "printf '[alias]\\n\\tp = push\\n' >> .git/config" +add_deny "alias v2.7.3: after an ask" "gh pr create --title t --body b && git config alias.p push" +add_deny "alias v2.7.3: [alias] via tee into ~/.config/git" $'tee -a ~/.config/git/config <<\'EOF\'\n[alias]\n pp = push\nEOF' +add_allow "alias v2.7.3: [alias] only in a commit message" $'git commit -F - <<\'EOF\'\ndocs: an [alias] section with push in .gitconfig is blocked\nEOF' +add_allow "alias v2.7.3: alias without push" "git config alias.co checkout" +add_allow "alias v2.7.3: list aliases" "git config --get-regexp alias" +add_allow "alias v2.7.3: commit message mentions it" "git commit -m \"block git config alias.p push\"" + # ── runner ──────────────────────────────────────────────────────────────────── pass=0; fail=0; fail_details=() for t in "${TESTS_ALLOW[@]}"; do @@ -614,6 +703,29 @@ for t in "${TESTS_ASK[@]}"; do done ask_pass=$pass; ask_fail=$fail; ask_total=${#TESTS_ASK[@]} +# v2.7.3: the ask is emitted once, at the end, with the FIRST guard's reason +# (the reason a user saw before v2.7.3, when ask() exited on the spot). +declare -a TESTS_ASK_REASON=( + "curl -X POST https://x.test && rm y"$'\t'"curl write operation" + "rm y && curl -X POST https://x.test"$'\t'"curl write operation" + "gh pr create --title t --body b && npm install"$'\t'"gh write operation" + "npm install && echo x > out.txt"$'\t'"Package manager install" + "echo x > out.txt && ln -s a b"$'\t'"Output redirection" + "git -C repo commit -m x && docker compose up -d"$'\t'"docker write" +) +for t in "${TESTS_ASK_REASON[@]}"; do + c="${t%% *}"; want="${t#* }" + out=$(jq -c -n --arg cmd "$c" '{tool_input:{command:$cmd}, transcript_path:""}' | bash "$HOOK" 2>/dev/null); ec=$? + n=$(printf '%s\n' "$out" | grep -c '"permissionDecision"') + if [[ $ec -eq 0 && $n -eq 1 ]] && grep -q '"permissionDecision":"ask"' <<<"$out" && grep -qF "$want" <<<"$out"; then + ask_pass=$((ask_pass+1)); [[ $VERBOSE -eq 1 ]] && echo "PASS: ask reason → $c" + else + ask_fail=$((ask_fail+1)); fail_details+=("[ASK REASON '$want' expected once, got ec=$ec lines=$n] $c | ${out:0:160}") + [[ $VERBOSE -eq 1 ]] && echo "FAIL: ask reason → $c" + fi + ask_total=$((ask_total+1)) +done + # ── git push auth-phrase tests ──────────────────────────────────────────────── # Verify that git_push_authorized() consults the last *human-typed* user message, # skipping intervening tool_result entries that Claude Code emits between turns. @@ -866,6 +978,81 @@ for c in "git -c user.name=x push" "git --no-pager push origin main"; do fi push_extra=$((push_extra+1)) done + +# v2.7.3: a git alias that resolves to push is a push. Fixture config, read +# through GIT_CONFIG_GLOBAL so the developer's own ~/.gitconfig plays no part; +# the hook gets the session cwd from the payload, as Claude Code sends it. +ALIAS_TMP="$PUSH_TMP/alias" +mkdir -p "$ALIAS_TMP/repo" +cat > "$ALIAS_TMP/gitconfig" <<'CFG' +[alias] + p = push + pp = p + ppp = pp + c1 = c2 + c2 = c3 + c3 = c4 + c4 = c5 + c5 = c6 + c6 = push + sp = !git push origin + fn = "!f() { git fetch && git push; }; f" + opt = -c core.x=y push + status = push + pn = push-notes + lg = log --oneline + co = checkout +CFG +git -C "$ALIAS_TMP/repo" init -q 2>/dev/null +git -C "$ALIAS_TMP/repo" config alias.lp push +run_alias() { # args: cmd cwd transcript → prints hook output, returns hook exit code + jq -c -n --arg cmd "$1" --arg d "$2" --arg tp "${3:-}" \ + '{tool_input:{command:$cmd}, transcript_path:$tp, cwd:$d}' \ + | GIT_CONFIG_GLOBAL="$ALIAS_TMP/gitconfig" GIT_CONFIG_NOSYSTEM=1 bash "$HOOK" 2>/dev/null +} +# cmd cwd expected (deny | pass | ask) [ transcript] +declare -a TESTS_ALIAS=( + "git p"$'\t'"$ALIAS_TMP"$'\t'"deny" + "git pp origin main"$'\t'"$ALIAS_TMP"$'\t'"deny" + "git ppp"$'\t'"$ALIAS_TMP"$'\t'"deny" + "git c1"$'\t'"$ALIAS_TMP"$'\t'"deny" + "git sp"$'\t'"$ALIAS_TMP"$'\t'"deny" + "git fn"$'\t'"$ALIAS_TMP"$'\t'"deny" + "git opt"$'\t'"$ALIAS_TMP"$'\t'"deny" + "git P"$'\t'"$ALIAS_TMP"$'\t'"deny" + "cd sub && git p"$'\t'"$ALIAS_TMP"$'\t'"deny" + "git -c user.name=x p"$'\t'"$ALIAS_TMP"$'\t'"deny" + "git --no-pager pp"$'\t'"$ALIAS_TMP"$'\t'"deny" + "bash -c 'git p'"$'\t'"$ALIAS_TMP"$'\t'"deny" + "gh pr create --title t --body b && git p"$'\t'"$ALIAS_TMP"$'\t'"deny" + "git -C repo commit -m x && git -C repo lp"$'\t'"$ALIAS_TMP"$'\t'"deny" + "git lp"$'\t'"$ALIAS_TMP/repo"$'\t'"deny" + "git -C $ALIAS_TMP/repo lp"$'\t'"/"$'\t'"deny" + "git status"$'\t'"$ALIAS_TMP"$'\t'"pass" + "git lg"$'\t'"$ALIAS_TMP"$'\t'"pass" + "git co main"$'\t'"$ALIAS_TMP"$'\t'"pass" + "git pn"$'\t'"$ALIAS_TMP"$'\t'"pass" + "git lp"$'\t'"$ALIAS_TMP"$'\t'"pass" + "git commit -m \"run git p later\""$'\t'"$ALIAS_TMP"$'\t'"pass" + "git p"$'\t'"$ALIAS_TMP"$'\t'"pass"$'\t'"$PUSH_TMP/b.jsonl" + "git -C repo lp"$'\t'"$ALIAS_TMP"$'\t'"ask"$'\t'"$PUSH_TMP/b.jsonl" + "gh pr create --title t --body b && git p"$'\t'"$ALIAS_TMP"$'\t'"ask"$'\t'"$PUSH_TMP/b.jsonl" + "git p"$'\t'"$ALIAS_TMP"$'\t'"deny"$'\t'"$PUSH_TMP/f.jsonl" +) +for t in "${TESTS_ALIAS[@]}"; do + IFS=$'\t' read -r c d want tp <<<"$t" + out=$(run_alias "$c" "$d" "${tp:-}"); ec=$? + got=pass + [[ $ec -eq 2 ]] && got=deny + [[ $ec -eq 0 ]] && grep -q '"permissionDecision":"ask"' <<<"$out" && got=ask + if [[ "$got" == "$want" ]]; then + push_pass=$((push_pass+1)); [[ $VERBOSE -eq 1 ]] && echo "PASS: alias $want → $c" + else + push_fail=$((push_fail+1)); fail_details+=("[ALIAS $want expected, got $got] $c (cwd ${d})") + [[ $VERBOSE -eq 1 ]] && echo "FAIL: alias $want → $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))