Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/claude/global-claude-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> 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` | — |
Expand Down
13 changes: 11 additions & 2 deletions global-settings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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 <<EOF | bash`. The config guard is **not** part of this change: it keeps scanning the full command, because there a false deny is cheaper than a gap.

## `git push` needs the phrase in every form (v2.7.2)

Without an authorization phrase in your last message, `block-write-commands.sh` hard-denies a `git push` however it is written:

- with git's global options before `push`: `git -C <path> push`, `git -c key=value push`, `git --no-pager push`, `git -P push`, `git --git-dir <path> push`, `git --work-tree <path> 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.
Expand All @@ -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 <path>`, `source <path>`, 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 <path>` or `timeout 5 bash <path>` 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.

Expand Down
2 changes: 1 addition & 1 deletion global-settings/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.7.1
2.7.2
51 changes: 30 additions & 21 deletions global-settings/block-write-commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF | bash`.
# - 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
# git commit/tag/notes (global options such as -C <path> 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).
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 <path>
# push`, `git -c k=v push`, `git --no-pager push`, `git --work-tree <path>
# 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
Expand Down Expand Up @@ -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
Expand All @@ -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]' \
Expand Down
74 changes: 73 additions & 1 deletion global-settings/tests/test-block-write-commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
Loading