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
48 changes: 48 additions & 0 deletions .claude/hooks/github-write-guard.sh
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .claude/hooks/mcp-ban.sh
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ case "$tool_name" in
msg='Box MCP banned. Use: box files:get <FILE_ID> --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.'
Expand Down
4 changes: 4 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
38 changes: 38 additions & 0 deletions evals/test-github-write-guard.sh
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions evals/test-mcp-ban.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions hooks/codex-hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions skill-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"conventional-commits-check.sh",
"branch-safety-check.sh",
"bash-verbose-guard.sh",
"github-write-guard.sh",
"rtk-rewrite.sh"
],
"mcp__.*": [
Expand Down