diff --git a/docs/claude/global-claude-settings.md b/docs/claude/global-claude-settings.md index 7c3bc47d..0c53dbfa 100644 --- a/docs/claude/global-claude-settings.md +++ b/docs/claude/global-claude-settings.md @@ -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`). diff --git a/global-settings/VERSION b/global-settings/VERSION index 437459cd..73462a5a 100644 --- a/global-settings/VERSION +++ b/global-settings/VERSION @@ -1 +1 @@ -2.5.0 +2.5.1 diff --git a/global-settings/block-write-commands.sh b/global-settings/block-write-commands.sh index 168d6cdc..ef346b18 100644 --- a/global-settings/block-write-commands.sh +++ b/global-settings/block-write-commands.sh @@ -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 +# ; 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*")) + | ([capture("(?[\\s\\S]*?)").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)' } diff --git a/global-settings/tests/test-block-write-commands.sh b/global-settings/tests/test-block-write-commands.sh index 6c308e71..0fdea614 100644 --- a/global-settings/tests/test-block-write-commands.sh +++ b/global-settings/tests/test-block-write-commands.sh @@ -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: → user-role message with one text content block -# tool_result → user-role message with one tool_result content block -# assistant: → 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: → user-role message with one text content block +# user_image: → 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:| → typed slash command, stored as a string content +# (//) +# skill_meta: → the isMeta entry holding the loaded skill body +# task_notification: → 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: → assistant-role message (filler; not used by the hook) make_transcript() { local out="$1"; shift : > "$out" @@ -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:("" + ($n | ltrimstr("/")) + "\n" + $n + "\n" + $a + "")}}' >> "$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:("\na1\n" + $t + "\n")}}' >> "$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" ;; @@ -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#* }"