Skip to content
Open
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: 2 additions & 0 deletions docs/claude/global-claude-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ Most guards use `(^|[;&|]\s*)cmd\b` patterns to catch commands both at the start

Authorized git push phrases (case-insensitive): `push for me`, `commit and push`, `please git push`, `push my changes`.

The phrase must be in the last message the human typed — plain text, text sent along with a pasted image, or the arguments of a slash command. Entries Claude Code writes itself are not human input: `isMeta` entries (the `[Image: source: …]` line after a pasted image, the body of an invoked skill), tool results, task notifications, messages from other sessions and compaction summaries neither grant nor revoke push authorization.

## What `check-settings-version.sh` does

- Fires once per session (keyed to the transcript path via a flag file in `$XDG_RUNTIME_DIR` or `~/.claude/`, with `chmod 600`).
Expand Down
2 changes: 1 addition & 1 deletion global-settings/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.5.0
2.5.1
27 changes: 21 additions & 6 deletions global-settings/block-write-commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,33 @@ ask() {
# after Claude runs any tool, the most-recent type=="user" entry is a tool_result
# with no text block, which would otherwise erase a still-valid authorization
# given by the human one turn earlier and deny every retry within the session.
# We must also skip isMeta entries: Claude Code appends one after a message
# with a pasted image ("[Image: source: …/images/1.png]") and one holding the
# body of an invoked skill. Both carry text blocks, so without the filter the
# meta entry became the "last message" — the phrase typed alongside an image
# was lost, and a skill body mentioning a phrase could authorize on its own.
# A typed slash command is stored as string content with the typed text in
# <command-args>; it counts as a human message (so it can both grant and
# revoke). Other string content (task notifications, messages from other
# sessions, compaction summaries) is not human-typed and is ignored.
# NOTE: reads the FULL content of the last human-typed user message (not just
# the last line), so multi-paragraph messages with the auth phrase on any line
# work correctly.
git_push_authorized() {
[[ -z "$transcript_path" || ! -f "$transcript_path" ]] && return 1
# Pipe: (1) emit each user message that has at least one text block, one per
# line (skips tool_result-only entries),
# (2) slurp all, take the last, join all text blocks into one
# searchable string.
# Pipe: (1) emit the searchable text of each human-typed user message as a
# JSON string, one per line (text blocks joined; for a slash
# command, its arguments),
# (2) keep the last one and decode it.
local last_msg
last_msg=$(jq -rc 'select(.type == "user") | select([.message.content[]? | select(.type == "text")] | length > 0)' "$transcript_path" 2>/dev/null \
| jq -rs 'last | [.message.content[] | select(.type == "text") | .text] | join(" ")' 2>/dev/null)
last_msg=$(jq -c 'select(.type == "user" and .isMeta != true) | .message.content
| if type == "string" then
select(test("^\\s*<command-(name|message)>"))
| ([capture("<command-args>(?<a>[\\s\\S]*?)</command-args>").a] | first // "")
elif type == "array" and any(.[]; .type == "text") then
[.[] | select(.type == "text") | .text] | join(" ")
else empty end' "$transcript_path" 2>/dev/null \
| tail -n 1 | jq -r '.' 2>/dev/null)
[[ -z "$last_msg" ]] && return 1
echo "$last_msg" | grep -qiE '(push for me|commit and push|please git push|push my changes)'
}
Expand Down
101 changes: 97 additions & 4 deletions global-settings/tests/test-block-write-commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -544,10 +544,19 @@ declare -a TESTS_PUSH_ALLOW TESTS_PUSH_DENY
add_push_allow() { TESTS_PUSH_ALLOW+=("$1"$'\t'"$2"); }
add_push_deny() { TESTS_PUSH_DENY+=("$1"$'\t'"$2"); }

# Build a JSONL transcript fixture from a list of layout tokens. Tokens:
# user:<text> → user-role message with one text content block
# tool_result → user-role message with one tool_result content block
# assistant:<text> → assistant-role message (filler; not used by the hook)
# Build a JSONL transcript fixture from a list of layout tokens. The shapes
# mirror what Claude Code actually writes to the transcript. Tokens:
# user:<text> → user-role message with one text content block
# user_image:<text> → human message with a pasted image: image block + text block
# image_meta → the isMeta entry Claude Code appends after a pasted
# image ("[Image: source: …/images/1.png]")
# command:<name>|<args> → typed slash command, stored as a string content
# (<command-message>/<command-name>/<command-args>)
# skill_meta:<text> → the isMeta entry holding the loaded skill body
# task_notification:<text> → string content emitted when a background task ends
# attachment → non-message transcript entry (hook output, reminders)
# tool_result → user-role message with one tool_result content block
# assistant:<text> → assistant-role message (filler; not used by the hook)
make_transcript() {
local out="$1"; shift
: > "$out"
Expand All @@ -559,6 +568,24 @@ make_transcript() {
user)
jq -nc --arg t "$body" '{type:"user", message:{role:"user", content:[{type:"text", text:$t}]}}' >> "$out"
;;
user_image)
jq -nc --arg t "$body" '{type:"user", message:{role:"user", content:[{type:"image", source:{type:"base64", media_type:"image/png", data:"iVBORw0KGgo="}}, {type:"text", text:$t}]}}' >> "$out"
;;
image_meta)
jq -nc '{type:"user", isMeta:true, message:{role:"user", content:[{type:"text", text:"[Image: source: /tmp/claude-1000/project/session/images/1.png]"}]}}' >> "$out"
;;
command)
jq -nc --arg n "${body%%|*}" --arg a "${body#*|}" '{type:"user", message:{role:"user", content:("<command-message>" + ($n | ltrimstr("/")) + "</command-message>\n<command-name>" + $n + "</command-name>\n<command-args>" + $a + "</command-args>")}}' >> "$out"
;;
skill_meta)
jq -nc --arg t "$body" '{type:"user", isMeta:true, message:{role:"user", content:[{type:"text", text:("Base directory for this skill: /home/u/.claude/skills/x\n\n" + $t)}]}}' >> "$out"
;;
task_notification)
jq -nc --arg t "$body" '{type:"user", message:{role:"user", content:("<task-notification>\n<task-id>a1</task-id>\n<result>" + $t + "</result>\n</task-notification>")}}' >> "$out"
;;
attachment)
jq -nc '{type:"attachment", attachment:{type:"hook_success", content:"ok"}}' >> "$out"
;;
tool_result)
jq -nc '{type:"user", message:{role:"user", content:[{type:"tool_result", tool_use_id:"x", content:"output"}]}}' >> "$out"
;;
Expand Down Expand Up @@ -630,6 +657,72 @@ add_push_deny "empty transcript" "$PUSH_TMP/g.jsonl"
make_transcript "$PUSH_TMP/h.jsonl" "tool_result" "tool_result"
add_push_deny "transcript has only tool_results" "$PUSH_TMP/h.jsonl"

# Layout I — bug fixture: the auth phrase arrives in a message with a pasted
# image. Claude Code appends an isMeta "[Image: source: …]" entry right after
# it; the pre-fix hook took that entry as the last human message and denied.
make_transcript "$PUSH_TMP/i.jsonl" \
"user_image:zie afbeelding, daarna commit and push" \
"image_meta" \
"attachment" \
"assistant:running" \
"tool_result"
add_push_allow "auth phrase in a message with a pasted image" "$PUSH_TMP/i.jsonl"

# Layout J — a later image message without the phrase still revokes.
make_transcript "$PUSH_TMP/j.jsonl" \
"user:please git push" \
"tool_result" \
"user_image:wacht, zie eerst deze afbeelding" \
"image_meta"
add_push_deny "later image message without phrase supersedes auth" "$PUSH_TMP/j.jsonl"

# Layout K — auth phrase in the arguments of a typed slash command. The
# command is a string-content entry and the skill body follows as isMeta; the
# pre-fix hook skipped the former and read the latter.
make_transcript "$PUSH_TMP/k.jsonl" \
"command:/opsx-apply|WOO-1, push for me when done" \
"skill_meta:# Apply" \
"assistant:running" \
"tool_result"
add_push_allow "auth phrase in slash-command arguments" "$PUSH_TMP/k.jsonl"

# Layout L — a slash command without the phrase revokes an earlier auth. With
# isMeta skipped but string content still ignored, the old phrase would leak.
make_transcript "$PUSH_TMP/l.jsonl" \
"user:please git push" \
"tool_result" \
"command:/review-pr|https://github.com/o/r/pull/1" \
"skill_meta:# PR Review"
add_push_deny "later slash command without phrase supersedes auth" "$PUSH_TMP/l.jsonl"

# Layout M — a skill body that itself mentions a phrase must not authorize;
# the human never typed it. The pre-fix hook allowed this.
make_transcript "$PUSH_TMP/m.jsonl" \
"command:/review-pr|https://github.com/o/r/pull/1" \
"skill_meta:Step 9: commit and push the fixes"
add_push_deny "auth phrase only inside a skill body" "$PUSH_TMP/m.jsonl"

# Layout N — an image-source path that happens to contain a phrase is not
# human text either (defence against the meta entry ever carrying one).
make_transcript "$PUSH_TMP/n.jsonl" \
"user_image:zie afbeelding" \
"image_meta"
jq -nc '{type:"user", isMeta:true, message:{role:"user", content:[{type:"text", text:"[Image: source: /tmp/push my changes/1.png]"}]}}' >> "$PUSH_TMP/n.jsonl"
add_push_deny "auth phrase only inside an isMeta entry" "$PUSH_TMP/n.jsonl"

# Layout O — a background-task notification is not human input: it neither
# grants auth nor revokes an auth the human gave before it arrived.
make_transcript "$PUSH_TMP/o.jsonl" \
"user:push my changes" \
"tool_result" \
"task_notification:agent finished"
add_push_allow "task notification does not revoke auth" "$PUSH_TMP/o.jsonl"

make_transcript "$PUSH_TMP/p.jsonl" \
"user:kijk naar de output" \
"task_notification:done, please git push next"
add_push_deny "auth phrase only inside a task notification" "$PUSH_TMP/p.jsonl"

push_pass=0; push_fail=0
for t in "${TESTS_PUSH_ALLOW[@]}"; do
label="${t%% *}"; transcript="${t#* }"
Expand Down
Loading