From a84bf2bff53eca5dd38cd365090fe8954c86065d Mon Sep 17 00:00:00 2001 From: Beniamin Malinski Date: Tue, 19 May 2026 17:18:34 +0200 Subject: [PATCH] fix(hooks): block pr thread replies --- .claude/hooks/github-write-guard.sh | 48 +++++++++++++++++++++++++++++ .claude/hooks/mcp-ban.sh | 7 +++++ .claude/settings.json | 4 +++ .codex/hooks.json | 4 +++ evals/test-github-write-guard.sh | 38 +++++++++++++++++++++++ evals/test-mcp-ban.sh | 1 + hooks/codex-hooks.json | 4 +++ hooks/hooks.json | 4 +++ skill-manifest.json | 1 + 9 files changed, 111 insertions(+) create mode 100755 .claude/hooks/github-write-guard.sh create mode 100644 evals/test-github-write-guard.sh diff --git a/.claude/hooks/github-write-guard.sh b/.claude/hooks/github-write-guard.sh new file mode 100755 index 0000000..60db14f --- /dev/null +++ b/.claude/hooks/github-write-guard.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -eo pipefail + +# PreToolUse(Bash): block GitHub PR review-thread replies by default. +# +# Why: replying inside an existing PR discussion/thread can make the agent +# carry on a conversation as the authenticated human. Keep existing top-level +# PR behavior (for example `gh pr comment ... "@claude review"`), but stop +# threaded replies unless the user explicitly opts into that exact action. +# +# Escape hatch: prefix the command with CLAUDE_ALLOW_PR_THREAD_REPLY=1 after +# the user explicitly asks for a PR thread reply. + +source "$(dirname "$0")/source-hook-lib.sh" 2>/dev/null || true + +hook_parse_bash + +case "$command" in + CLAUDE_ALLOW_PR_THREAD_REPLY=1\ *|*" CLAUDE_ALLOW_PR_THREAD_REPLY=1 "*) exit 0 ;; +esac + +_compact=$(printf '%s' "$command" | tr '\n\r\t' ' ' | sed -E 's/[[:space:]]+/ /g') + +_is_thread_reply=false +_reason="" + +# REST: create a reply for a PR review comment. +# POST /repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies +if echo "$_compact" | grep -Eq '(^|[;&|[:space:]])gh api '; then + if echo "$_compact" | grep -Eq 'repos/[^[:space:]]+/[^[:space:]]+/pulls/[0-9]+/comments/[0-9]+/replies'; then + if echo "$_compact" | grep -Eq '(^|[[:space:]])(-f|--field|-F|--raw-field|--method[=[:space:]]*(POST|PATCH|PUT)|-X[[:space:]]*(POST|PATCH|PUT))([[:space:]]|$)'; then + _is_thread_reply=true + _reason="gh api is creating a PR review comment reply" + fi + fi + + # GraphQL: addPullRequestReviewThreadReply mutation. + if echo "$_compact" | grep -qi 'addPullRequestReviewThreadReply'; then + _is_thread_reply=true + _reason="gh api graphql is adding a PR review thread reply" + fi +fi + +if [ "$_is_thread_reply" = true ]; then + hook_deny "Refusing PR thread reply: $_reason. Agents may keep top-level PR behavior, but must not reply inside existing PR discussions/threads in your name. If you explicitly approved this exact reply, rerun with CLAUDE_ALLOW_PR_THREAD_REPLY=1 prefixed." "github-write-guard" +fi + +exit 0 diff --git a/.claude/hooks/mcp-ban.sh b/.claude/hooks/mcp-ban.sh index d24cb33..66be0f0 100755 --- a/.claude/hooks/mcp-ban.sh +++ b/.claude/hooks/mcp-ban.sh @@ -144,6 +144,13 @@ case "$tool_name" in msg='Box MCP banned. Use: box files:get --json / box folders:items 0 --json. Install: brew install boxcli.' ;; + # ── GitHub PR thread replies ───────────────────────────────── + # Keep top-level PR behavior, but don't let agents continue existing PR + # review discussions/threads as the authenticated human. + mcp__*github*__*thread*reply*|mcp__*GitHub*__*thread*reply*|mcp__*github*__*reply*thread*|mcp__*GitHub*__*reply*thread*) + msg='GitHub PR thread reply MCP banned. Ask the user first; for an explicitly approved reply use Bash with CLAUDE_ALLOW_PR_THREAD_REPLY=1 and the gh api replies endpoint.' + ;; + # ── Microsoft 365 → m365 CLI ── mcp__claude_ai_Microsoft_365__*|mcp__microsoft365__*|mcp__m365__*) msg='M365 MCP banned. Use: m365 teams/outlook/sharepoint with -o json. Install: npm i -g @pnp/cli-microsoft365. Auth: m365 login.' diff --git a/.claude/settings.json b/.claude/settings.json index fbc1ce8..37c1471 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -177,6 +177,10 @@ "type": "command", "command": "f=$(git rev-parse --show-toplevel 2>/dev/null)/.claude/hooks/bash-verbose-guard.sh; [ -x \"$f\" ] && exec \"$f\"; exit 0" }, + { + "type": "command", + "command": "f=$(git rev-parse --show-toplevel 2>/dev/null)/.claude/hooks/github-write-guard.sh; [ -x \"$f\" ] && exec \"$f\"; exit 0" + }, { "type": "command", "command": "f=$(git rev-parse --show-toplevel 2>/dev/null)/.claude/hooks/rtk-rewrite.sh; [ -x \"$f\" ] && exec \"$f\"; exit 0" diff --git a/.codex/hooks.json b/.codex/hooks.json index 46321ca..d00e5db 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -65,6 +65,10 @@ "type": "command", "command": "f=$(git rev-parse --show-toplevel 2>/dev/null)/.claude/hooks/bash-verbose-guard.sh; [ -x \"$f\" ] && exec \"$f\"; exit 0" }, + { + "type": "command", + "command": "f=$(git rev-parse --show-toplevel 2>/dev/null)/.claude/hooks/github-write-guard.sh; [ -x \"$f\" ] && exec \"$f\"; exit 0" + }, { "type": "command", "command": "f=$(git rev-parse --show-toplevel 2>/dev/null)/.claude/hooks/rtk-rewrite.sh; [ -x \"$f\" ] && exec \"$f\"; exit 0" diff --git a/evals/test-github-write-guard.sh b/evals/test-github-write-guard.sh new file mode 100644 index 0000000..f93ccfa --- /dev/null +++ b/evals/test-github-write-guard.sh @@ -0,0 +1,38 @@ +# Evals for github-write-guard.sh — PreToolUse Bash deny on GitHub PR thread replies. + +HOOK="$REPO_ROOT/.claude/hooks/github-write-guard.sh" + +run_file_eval "$HOOK" "github-write-guard.sh exists" +run_executable_eval "$HOOK" "github-write-guard.sh executable" +run_content_eval "$REPO_ROOT/skill-manifest.json" "github-write-guard.sh" \ + "manifest registers github-write-guard" +run_content_eval "$REPO_ROOT/.claude/settings.json" "github-write-guard.sh" \ + "settings registers github-write-guard" +run_content_eval "$REPO_ROOT/hooks/hooks.json" "github-write-guard.sh" \ + "plugin hooks register github-write-guard" +run_content_eval "$REPO_ROOT/.codex/hooks.json" "github-write-guard.sh" \ + "codex hooks register github-write-guard" + +run_hook_eval "$HOOK" \ + '{"tool_name":"Bash","tool_input":{"command":"gh pr comment 123 --body \"@claude review\""}}' \ + 0 "allow: top-level gh pr comment" + +run_hook_eval "$HOOK" \ + '{"tool_name":"Bash","tool_input":{"command":"gh issue comment 42 --body \"triage\""}}' \ + 0 "allow: top-level gh issue comment" + +run_hook_eval "$HOOK" \ + '{"tool_name":"Bash","tool_input":{"command":"gh api repos/o/r/pulls/1/comments/99/replies -f body=reply"}}' \ + 2 "deny: gh api PR review comment reply" "Refusing PR thread reply" + +run_hook_eval "$HOOK" \ + '{"tool_name":"Bash","tool_input":{"command":"gh pr view 123 --json url"}}' \ + 0 "allow: read-only gh pr view" + +run_hook_eval "$HOOK" \ + '{"tool_name":"Bash","tool_input":{"command":"gh api graphql -f query=\"mutation { addPullRequestReviewThreadReply(input:{}) { clientMutationId } }\""}}' \ + 2 "deny: GraphQL PR review thread reply" "Refusing PR thread reply" + +run_hook_eval "$HOOK" \ + '{"tool_name":"Bash","tool_input":{"command":"CLAUDE_ALLOW_PR_THREAD_REPLY=1 gh api repos/o/r/pulls/1/comments/99/replies -f body=reply"}}' \ + 0 "allow: explicit env override" diff --git a/evals/test-mcp-ban.sh b/evals/test-mcp-ban.sh index 85603f8..1858902 100644 --- a/evals/test-mcp-ban.sh +++ b/evals/test-mcp-ban.sh @@ -57,6 +57,7 @@ _assert_denied "mcp__claude_ai_Google_Drive__files_list" "gws drive" "Drive -> g _assert_denied "mcp__claude_ai_Buildkite_read-only__list" "bk" "Buildkite -> bk" _assert_denied "mcp__claude_ai_Box__files_list" "box" "Box -> box" _assert_denied "mcp__claude_ai_Microsoft_365__teams" "m365" "M365 -> m365" +_assert_denied "mcp__github__add_pull_request_review_thread_reply" "PR thread reply" "GitHub PR thread reply MCP denied" # JSON validity on every deny — prevent regressions from unescaped quotes _run_mcp "mcp__claude_ai_Gmail__gmail_search_messages" diff --git a/hooks/codex-hooks.json b/hooks/codex-hooks.json index 782c128..ae7571d 100644 --- a/hooks/codex-hooks.json +++ b/hooks/codex-hooks.json @@ -65,6 +65,10 @@ "type": "command", "command": "f=\"${CLAUDE_PLUGIN_ROOT}/.claude/hooks/bash-verbose-guard.sh\"; [ -x \"$f\" ] && exec \"$f\"; exit 0" }, + { + "type": "command", + "command": "f=\"${CLAUDE_PLUGIN_ROOT}/.claude/hooks/github-write-guard.sh\"; [ -x \"$f\" ] && exec \"$f\"; exit 0" + }, { "type": "command", "command": "f=\"${CLAUDE_PLUGIN_ROOT}/.claude/hooks/rtk-rewrite.sh\"; [ -x \"$f\" ] && exec \"$f\"; exit 0" diff --git a/hooks/hooks.json b/hooks/hooks.json index 414df7c..ff2b1da 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -163,6 +163,10 @@ "type": "command", "command": "f=\"${CLAUDE_PLUGIN_ROOT}/.claude/hooks/bash-verbose-guard.sh\"; [ -x \"$f\" ] && exec \"$f\"; exit 0" }, + { + "type": "command", + "command": "f=\"${CLAUDE_PLUGIN_ROOT}/.claude/hooks/github-write-guard.sh\"; [ -x \"$f\" ] && exec \"$f\"; exit 0" + }, { "type": "command", "command": "f=\"${CLAUDE_PLUGIN_ROOT}/.claude/hooks/rtk-rewrite.sh\"; [ -x \"$f\" ] && exec \"$f\"; exit 0" diff --git a/skill-manifest.json b/skill-manifest.json index c7c1c83..2e53288 100644 --- a/skill-manifest.json +++ b/skill-manifest.json @@ -67,6 +67,7 @@ "conventional-commits-check.sh", "branch-safety-check.sh", "bash-verbose-guard.sh", + "github-write-guard.sh", "rtk-rewrite.sh" ], "mcp__.*": [