From 7ac4507c1d7e712fcadb1aad03b1c454e6392a66 Mon Sep 17 00:00:00 2001 From: Kim Pohas Date: Thu, 3 Sep 2026 03:19:04 -0700 Subject: [PATCH 1/4] DOCS-1867 - Enforce Claude Code comment attribution with a hook Add a PreToolUse hook that blocks any GitHub or Jira comment posted by Claude Code whose body is missing the "via Claude Code" attribution. Covers gh pr comment, gh issue comment, gh pr review, gh api comment calls, and the Jira addCommentToJiraIssue MCP tool. Broaden the AGENTS.md attribution rule to name GitHub PRs and issues, not just Jira, and point to the hook as the enforcement mechanism. Co-Authored-By: Claude Sonnet 5 --- .claude/hooks/require-claude-attribution.sh | 67 +++++++++++++++++++++ .claude/settings.json | 16 +++++ AGENTS.md | 3 +- 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100755 .claude/hooks/require-claude-attribution.sh create mode 100644 .claude/settings.json diff --git a/.claude/hooks/require-claude-attribution.sh b/.claude/hooks/require-claude-attribution.sh new file mode 100755 index 00000000000..83545d89616 --- /dev/null +++ b/.claude/hooks/require-claude-attribution.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# require-claude-attribution.sh +# +# PreToolUse hook. Blocks any comment-posting tool call whose body does not +# carry the "via Claude Code" attribution required by AGENTS.md. +# +# Covers: +# - gh pr comment / gh issue comment +# - gh pr review (when it includes a --body or --comment) +# - gh api ... /comments|/reviews ... body=... +# - the Jira mcp__atlassian__addCommentToJiraIssue MCP tool +# +# Wired up from .claude/settings.json. See DOCS-1867. + +set -u + +input="$(cat)" +tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null)" + +# Matched case-insensitively, so "— via Claude Code" and "via claude code" both pass. +marker='via claude code' + +missing_marker() { + # Returns 0 (true) when $1 does NOT contain the marker. + ! printf '%s' "$1" | grep -qi "$marker" +} + +deny() { + jq -n --arg reason "$1" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: $reason + } + }' + exit 0 +} + +case "$tool_name" in + Bash) + cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" + + # Only inspect commands that post a comment. + printf '%s' "$cmd" | grep -Eq \ + 'gh +(pr|issue) +comment|gh +pr +review.*(--body|--comment)|gh +api.*(/comments|/reviews).*body=' \ + || exit 0 + + # Body supplied from a file: contents are not visible here, so let it through. + printf '%s' "$cmd" | grep -Eq -- '--body-file|body=@|-F +[A-Za-z_]+=@' && exit 0 + + if missing_marker "$cmd"; then + deny 'This GitHub comment must end with "— via Claude Code" (AGENTS.md comment-attribution rule). Re-run the command with that line appended to the comment body.' + fi + ;; + + mcp__atlassian__addCommentToJiraIssue) + # Concatenate every string value in tool_input so the check does not depend + # on the exact parameter name. + body="$(printf '%s' "$input" | jq -r '[.tool_input | .. | strings] | join("\n")')" + if missing_marker "$body"; then + deny 'This Jira comment must end with "— via Claude Code" (AGENTS.md comment-attribution rule). Re-add the comment with that line appended.' + fi + ;; +esac + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000000..ba8ebb9ac23 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|mcp__atlassian__addCommentToJiraIssue", + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/require-claude-attribution.sh\"", + "statusMessage": "Checking comment attribution" + } + ] + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index 95ad0c7137e..a708fbfed07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,7 @@ Before pushing any commit that changes docs content: - **Creating tickets**: Use one of three approaches — a user-provided description, analysis of the code changes being made, or the file paths touched. (Claude Code: see `.claude/commands/jira.md` for the concrete pattern and Technical Area mappings.) - **Titles**: Sentence case, action verb, specific, under 10 words - **Descriptions**: Benefit-driven, active voice, under 150 words unless complex, markdown format -- **Comment attribution**: Always append `— via Claude Code` to any comment posted to a Jira ticket +- **Comment attribution**: End every comment posted to a Jira ticket with `— via Claude Code`. This is enforced by a PreToolUse hook (see [GitHub Rules](#github-rules) for details); it applies to GitHub PR and issue comments too. - **Status transitions**: Use workflow states: Backlog → To Do → In Progress → Blocked → In Review → On Hold → Published → Closed ### Publishing Checklist @@ -113,6 +113,7 @@ Before transitioning any ticket to Published: ## GitHub Rules - **Assignee**: Assign any new PR to the current user unless otherwise specified +- **Comment attribution**: End every comment Claude posts to a GitHub PR or issue with `— via Claude Code`. This covers `gh pr comment`, `gh issue comment`, `gh pr review`, and `gh api` comment calls. A PreToolUse hook (`.claude/hooks/require-claude-attribution.sh`, wired up in `.claude/settings.json`) blocks any comment that is missing this marker, so it does not depend on remembering the rule. The same requirement applies to Jira comments (see [Jira Rules](#jira-rules-sumo-logic-internal--requires-atlassian-access)). ## Search, crawlers, and LLM-facing files Three pieces work together and should be kept in sync when touching any of them: From ac83e5c41bf68c6e89b6e0111c6deed6b6dfa3e9 Mon Sep 17 00:00:00 2001 From: Kim Pohas Date: Tue, 8 Sep 2026 20:59:13 -0700 Subject: [PATCH 2/4] DOCS-1867 - Forbid Claude Code attribution on comments with a hook Reverse the rule this ticket originally asked for. Claude Code posts through a contributor's own GitHub and Jira account, so on a comment the attribution marker was the only thing revealing that a machine wrote it, and it piled up on teammates' pull requests under a colleague's name. Comments now carry no attribution at all. Rename require-claude-attribution.sh to forbid-claude-attribution.sh and invert it: it denies a call that carries the marker rather than one that omits it. Coverage is GitHub PR, issue and review comments, Jira comments, and Slack messages. Pull request descriptions are not inspected, since a description is not a comment and the harness stamps its own line into PR bodies by default. Four things the naive inversion got wrong, all fixed here: - Match markdown-linked variants, so "Generated with [Claude Code](...)" is caught and not just the bare phrase. - Anchor the GitHub CLI guard to command position, so a command that merely mentions those subcommands is not blocked. Editing this rule's own documentation tripped the hook otherwise. - Inspect file-supplied bodies (--body-file, --input, -F field=@path, quoted or not) by reading the path off the command line. A hook never receives file contents, and anything multi-line has to go through a file, so this was the common path for real review content and it was going unchecked. - Restrict the gh api branch to writes. A read-only scan for existing attribution puts the marker in its own --jq filter, which self-denied. Also document the known gaps, which fail open rather than blocking legitimate work: a body path held in a shell variable, an unreadable path, and stdin sources such as `--input -`. AGENTS.md: rewrite both attribution bullets for the reversed rule, and add a Jira comment format bullet, since Jira comments are stored as ADF and a markdown @mention posts as literal text that notifies nobody. Verified with 22 synthetic tool-call payloads covering the deny and must-not-fire paths, plus live probes confirming the harness applies it. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/hooks/forbid-claude-attribution.sh | 153 ++++++++++++++++++++ .claude/hooks/require-claude-attribution.sh | 67 --------- .claude/settings.json | 6 +- AGENTS.md | 5 +- 4 files changed, 159 insertions(+), 72 deletions(-) create mode 100755 .claude/hooks/forbid-claude-attribution.sh delete mode 100755 .claude/hooks/require-claude-attribution.sh diff --git a/.claude/hooks/forbid-claude-attribution.sh b/.claude/hooks/forbid-claude-attribution.sh new file mode 100755 index 00000000000..ff9e4b7dc74 --- /dev/null +++ b/.claude/hooks/forbid-claude-attribution.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# +# forbid-claude-attribution.sh +# +# PreToolUse hook. Blocks any comment or Slack message that carries a Claude +# attribution marker, which AGENTS.md forbids. +# +# Covers: +# - gh pr comment / gh issue comment +# - gh pr review (when it includes a --body or --comment) +# - gh api ... /comments|/reviews ... +# - Jira: mcp__atlassian__addCommentToJiraIssue +# - Slack: slack_send_message, slack_send_message_draft, +# slack_schedule_message, slack_create_canvas, slack_update_canvas +# +# For Bash calls the body may be inline (--body "text") or supplied from a file +# (--body-file PATH, --input PATH, -F field=@PATH). A hook only receives the +# command string, never file contents, so a file-based body would otherwise slip +# through unchecked. That is the common case for real review content, since +# anything multi-line is painful to inline. This hook therefore reads the path +# off the command line and inspects the file itself. +# +# Deliberately NOT covered: +# - Pull request descriptions. A PR description is not a comment, and the +# Claude Code harness stamps "Generated with Claude Code" into PR bodies +# by default. That line is allowed to stay, so gh pr create and gh pr edit +# are not inspected. +# - Doc content under /docs. This rule is about comments and messages only. +# Docs may discuss Claude Code freely; edits there go through Write/Edit, +# which this hook never matches. +# - The Co-Authored-By trailer on git commits, which is commit metadata +# rather than reader-facing comment text. +# +# Known gaps, all fail open rather than blocking legitimate work: a body path +# held in a shell variable, a path this process cannot read, and stdin sources +# such as `--input -` or a piped heredoc. +# +# Note: edits to this script take effect immediately, but changing the hook +# registration in .claude/settings.json may not apply until a new session. +# +# Wired up from .claude/settings.json. See DOCS-1867. + +set -u + +input="$(cat)" +tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null)" + +# Matched case-insensitively. The [^a-z0-9]* allows for markdown link syntax, +# so the default "Generated with [Claude Code](https://claude.com/claude-code)" +# is caught as well as a bare "via Claude Code". +markers='via [^a-z0-9]*claude code|generated with [^a-z0-9]*claude code' + +has_marker() { + # Returns 0 (true) when $1 DOES contain an attribution marker. + printf '%s' "$1" | grep -qiE "$markers" +} + +deny() { + jq -n --arg reason "$1" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: $reason + } + }' + exit 0 +} + +# Every string value in tool_input, so the check does not depend on the exact +# parameter name (commentBody, text, markdown, and so on). +all_input_strings() { + printf '%s' "$input" | jq -r '[.tool_input | .. | strings] | join("\n")' +} + +# Paths the command feeds in as a body, one per line. Quote characters are +# stripped first, since the path is usually quoted on a real command line and +# a path containing a literal quote is pathological. +body_file_paths() { + local unquoted + unquoted="$(printf '%s' "$1" | tr -d "\"'")" + # --body-file PATH, --body-file=PATH, --input PATH, --input=PATH + printf '%s' "$unquoted" \ + | grep -oE -- '(--body-file|--input)([[:space:]]+|=)[^[:space:];&|)]+' \ + | sed -E 's/^(--body-file|--input)([[:space:]]+|=)+//' + # -f field=@PATH, -F field=@PATH, body=@PATH + printf '%s' "$unquoted" \ + | grep -oE -- '=@[^[:space:];&|)]+' \ + | sed -E 's/^=@//' +} + +case "$tool_name" in + Bash) + cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" + + # Only inspect commands that post a comment. Pull request descriptions are + # deliberately NOT inspected; see the note at the top of this file. The gh + # call must sit in command position (line start, or after ; & | or a paren) + # so that a command whose text merely *mentions* these subcommands does not + # trip the check. Editing this repo's own docs about the rule would + # otherwise be blocked. Trade-off: a gh call inside legacy backtick + # substitution, or after `then`/`do`, is not inspected. + at_cmd='(^|[;&|(])[[:space:]]*' + + # gh pr/issue comment, or gh pr review carrying a body. + posts_comment=1 + printf '%s' "$cmd" | grep -Eq \ + "${at_cmd}gh +(pr|issue) +comment|${at_cmd}gh +pr +review.*(--body|--comment|--body-file)" \ + && posts_comment=0 + + # gh api against a comment or review endpoint, but only for writes. A + # read-only GET must not be inspected: scanning for existing attribution + # puts the marker in the --jq filter, which would otherwise self-deny. + if [ "$posts_comment" -ne 0 ] \ + && printf '%s' "$cmd" | grep -Eq "${at_cmd}gh +api" \ + && printf '%s' "$cmd" | grep -Eq -- '(/comments|/reviews)' \ + && printf '%s' "$cmd" | grep -Eq -- '--method +(POST|PATCH|PUT)|--input|-[fF] +[A-Za-z_]+=|body='; then + posts_comment=0 + fi + + [ "$posts_comment" -eq 0 ] || exit 0 + + # Inline body. + if has_marker "$cmd"; then + deny 'Remove the Claude attribution line. AGENTS.md forbids "via Claude Code" and "Generated with Claude Code" in GitHub comments. Re-run the command without it.' + fi + + # File-supplied body. + while IFS= read -r path; do + [ -n "$path" ] || continue + [ "$path" = "-" ] && continue + [ -r "$path" ] || continue + if has_marker "$(cat "$path" 2>/dev/null)"; then + deny "Remove the Claude attribution line from ${path}. AGENTS.md forbids \"via Claude Code\" and \"Generated with Claude Code\" in GitHub comments. Edit that file and re-run." + fi + done </dev/null)" - -# Matched case-insensitively, so "— via Claude Code" and "via claude code" both pass. -marker='via claude code' - -missing_marker() { - # Returns 0 (true) when $1 does NOT contain the marker. - ! printf '%s' "$1" | grep -qi "$marker" -} - -deny() { - jq -n --arg reason "$1" '{ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: $reason - } - }' - exit 0 -} - -case "$tool_name" in - Bash) - cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" - - # Only inspect commands that post a comment. - printf '%s' "$cmd" | grep -Eq \ - 'gh +(pr|issue) +comment|gh +pr +review.*(--body|--comment)|gh +api.*(/comments|/reviews).*body=' \ - || exit 0 - - # Body supplied from a file: contents are not visible here, so let it through. - printf '%s' "$cmd" | grep -Eq -- '--body-file|body=@|-F +[A-Za-z_]+=@' && exit 0 - - if missing_marker "$cmd"; then - deny 'This GitHub comment must end with "— via Claude Code" (AGENTS.md comment-attribution rule). Re-run the command with that line appended to the comment body.' - fi - ;; - - mcp__atlassian__addCommentToJiraIssue) - # Concatenate every string value in tool_input so the check does not depend - # on the exact parameter name. - body="$(printf '%s' "$input" | jq -r '[.tool_input | .. | strings] | join("\n")')" - if missing_marker "$body"; then - deny 'This Jira comment must end with "— via Claude Code" (AGENTS.md comment-attribution rule). Re-add the comment with that line appended.' - fi - ;; -esac - -exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index ba8ebb9ac23..03d9b0496d5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,12 +2,12 @@ "hooks": { "PreToolUse": [ { - "matcher": "Bash|mcp__atlassian__addCommentToJiraIssue", + "matcher": "Bash|mcp__atlassian__addCommentToJiraIssue|slack_send_message|slack_send_message_draft|slack_schedule_message|slack_create_canvas|slack_update_canvas", "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/require-claude-attribution.sh\"", - "statusMessage": "Checking comment attribution" + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/forbid-claude-attribution.sh\"", + "statusMessage": "Checking for forbidden attribution" } ] } diff --git a/AGENTS.md b/AGENTS.md index a708fbfed07..773349aade0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,8 @@ Before pushing any commit that changes docs content: - **Creating tickets**: Use one of three approaches — a user-provided description, analysis of the code changes being made, or the file paths touched. (Claude Code: see `.claude/commands/jira.md` for the concrete pattern and Technical Area mappings.) - **Titles**: Sentence case, action verb, specific, under 10 words - **Descriptions**: Benefit-driven, active voice, under 150 words unless complex, markdown format -- **Comment attribution**: End every comment posted to a Jira ticket with `— via Claude Code`. This is enforced by a PreToolUse hook (see [GitHub Rules](#github-rules) for details); it applies to GitHub PR and issue comments too. +- **Comment attribution**: Never append Claude attribution to a Jira comment. Do not add `— via Claude Code` or any similar marker. This is enforced by a PreToolUse hook (see [GitHub Rules](#github-rules) for details), and the same rule covers GitHub and Slack. +- **Comment format**: Jira comments are stored as ADF, and `contentFormat: markdown` is a lossy conversion layer over it. Markdown is fine for plain prose, which covers most comments. Use `contentFormat: adf` when the comment needs something markdown cannot express: an `@` mention, an image, or a panel. This matters most for mentions, since a markdown `@Name` posts as literal text and notifies nobody; a real mention is an ADF node carrying an account ID. - **Status transitions**: Use workflow states: Backlog → To Do → In Progress → Blocked → In Review → On Hold → Published → Closed ### Publishing Checklist @@ -113,7 +114,7 @@ Before transitioning any ticket to Published: ## GitHub Rules - **Assignee**: Assign any new PR to the current user unless otherwise specified -- **Comment attribution**: End every comment Claude posts to a GitHub PR or issue with `— via Claude Code`. This covers `gh pr comment`, `gh issue comment`, `gh pr review`, and `gh api` comment calls. A PreToolUse hook (`.claude/hooks/require-claude-attribution.sh`, wired up in `.claude/settings.json`) blocks any comment that is missing this marker, so it does not depend on remembering the rule. The same requirement applies to Jira comments (see [Jira Rules](#jira-rules-sumo-logic-internal--requires-atlassian-access)). +- **Comment attribution**: Never append Claude attribution to a comment. Do not add `— via Claude Code` or any similar marker. This covers GitHub PR review comments, GitHub PR issue comments, Jira ticket comments (see [Jira Rules](#jira-rules-sumo-logic-internal--requires-atlassian-access)), and Slack messages. A PreToolUse hook (`.claude/hooks/forbid-claude-attribution.sh`, wired up in `.claude/settings.json`) blocks any call carrying the marker, so compliance does not depend on remembering the rule. ## Search, crawlers, and LLM-facing files Three pieces work together and should be kept in sync when touching any of them: From aadb88fb09e49a5fd8a9985cf21c936a0b561dbc Mon Sep 17 00:00:00 2001 From: Kim Pohas Date: Tue, 8 Sep 2026 21:26:10 -0700 Subject: [PATCH 3/4] DOCS-1867 - Add test matrix for the attribution hook The hook had no committed test, so anyone changing it had to reconstruct the cases by hand. This adds them as a runnable script next to the hook. 31 cases split by what they protect: attribution must never reach a comment (inline bodies, chained commands, file-supplied bodies in every quoting form, Jira and Slack tools), and the hook must not block legitimate work (clean equivalents, pull request descriptions, commit trailers, doc edits, read-only scans, stdin and unreadable paths). Self-contained and portable. Paths resolve from the script location, fixtures go in a mktemp directory cleaned up on exit, and it exits non-zero on failure so it can run in CI. The marker strings are assembled at runtime so the file does not carry a bare marker that would trip the hook when edited from a shell. Co-Authored-By: Claude Opus 5 (1M context) --- .../hooks/forbid-claude-attribution.test.sh | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100755 .claude/hooks/forbid-claude-attribution.test.sh diff --git a/.claude/hooks/forbid-claude-attribution.test.sh b/.claude/hooks/forbid-claude-attribution.test.sh new file mode 100755 index 00000000000..55596593924 --- /dev/null +++ b/.claude/hooks/forbid-claude-attribution.test.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# Test matrix for forbid-claude-attribution.sh. +# +# Run from anywhere: bash .claude/hooks/forbid-claude-attribution.test.sh +# Exits non-zero if any case fails, so it works in CI. +# +# Each case feeds the hook a synthetic PreToolUse payload and asserts whether it +# denies. Cases are grouped by what they protect: +# - deny: attribution must never reach a comment +# - allow: the hook must not block legitimate work +# +# See DOCS-1867. + +set -u + +hook_dir="$(cd "$(dirname "$0")" && pwd)" +hook="$hook_dir/forbid-claude-attribution.sh" + +if [ ! -r "$hook" ]; then + echo "cannot read hook at $hook" >&2 + exit 2 +fi +for dep in jq grep; do + command -v "$dep" >/dev/null || { echo "missing dependency: $dep" >&2; exit 2; } +done + +fixtures="$(mktemp -d)" +trap 'rm -rf "$fixtures"' EXIT + +# Built up at runtime so this file does not itself contain a bare marker that +# would trip the hook when edited from a shell command. +M="via Claude$(printf ' ')Code" +ROBOT="Generated with [Claude$(printf ' ')Code](https://claude.com/claude-code)" + +printf 'Looks good.\n\nx %s\n' "$M" > "$fixtures/dirty.md" +printf '{"body":"Looks good.\\n\\nx %s"}' "$M" > "$fixtures/dirty.json" +printf 'Looks good.\n' > "$fixtures/clean.md" +printf '{"body":"Looks good."}' > "$fixtures/clean.json" + +pass=0 +fail=0 + +verdict() { + if printf '%s' "$1" | grep -q '"deny"'; then echo DENY; else echo allow; fi +} + +record() { + expected="$1"; got="$2"; desc="$3" + if [ "$got" = "$expected" ]; then + pass=$((pass + 1)) + printf ' ok %-5s %s\n' "$got" "$desc" + else + fail=$((fail + 1)) + printf ' FAIL %-5s %s (expected %s)\n' "$got" "$desc" "$expected" + fi +} + +# bash_case +bash_case() { + payload="$(jq -cn --arg c "$3" '{tool_name:"Bash",tool_input:{command:$c}}')" + record "$1" "$(verdict "$(printf '%s' "$payload" | bash "$hook")")" "$2" +} + +# tool_case +tool_case() { + payload="$(jq -cn --arg t "$3" --argjson i "$4" '{tool_name:$t,tool_input:$i}')" + record "$1" "$(verdict "$(printf '%s' "$payload" | bash "$hook")")" "$2" +} + +echo "deny: inline comment bodies" +bash_case DENY "gh pr comment" "gh pr comment 1 --body \"x $M\"" +bash_case DENY "gh issue comment" "gh issue comment 1 --body \"x $M\"" +bash_case DENY "gh pr review --body" "gh pr review 1 --comment --body \"x $M\"" +bash_case DENY "gh api -f body=" "gh api repos/o/r/issues/1/comments -f body=\"x $M\"" +bash_case DENY "chained after &&" "cd /r && gh pr comment 1 --body \"x $M\"" +bash_case DENY "harness robot line" "gh pr comment 1 --body \"x $ROBOT\"" + +echo "deny: file-supplied comment bodies" +bash_case DENY "--body-file bare" "gh pr comment 1 --body-file $fixtures/dirty.md" +bash_case DENY "--body-file dquoted" "gh pr comment 1 --body-file \"$fixtures/dirty.md\"" +bash_case DENY "--body-file squoted" "gh pr comment 1 --body-file '$fixtures/dirty.md'" +bash_case DENY "api reviews --input" "gh api repos/o/r/pulls/1/reviews --method POST --input $fixtures/dirty.json" +bash_case DENY "api PATCH comment" "gh api repos/o/r/pulls/comments/1 --method PATCH --input $fixtures/dirty.json" +bash_case DENY "-F body=@file" "gh api repos/o/r/issues/1/comments -F body=@$fixtures/dirty.md" + +echo "deny: MCP comment tools" +tool_case DENY "jira comment" mcp__atlassian__addCommentToJiraIssue "{\"commentBody\":\"x $M\"}" +tool_case DENY "slack message" mcp__claude_ai_Slack__slack_send_message "{\"text\":\"x $M\"}" +tool_case DENY "slack canvas" mcp__claude_ai_Slack__slack_update_canvas "{\"markdown\":\"x $M\"}" + +echo "allow: same calls without the marker" +bash_case allow "pr comment clean" "gh pr comment 1 --body \"looks good\"" +bash_case allow "--body-file clean" "gh pr comment 1 --body-file $fixtures/clean.md" +bash_case allow "api reviews clean" "gh api repos/o/r/pulls/1/reviews --method POST --input $fixtures/clean.json" +tool_case allow "jira clean" mcp__atlassian__addCommentToJiraIssue '{"commentBody":"looks good"}' +tool_case allow "slack clean" mcp__claude_ai_Slack__slack_send_message '{"text":"looks good"}' + +echo "allow: out of scope by design" +bash_case allow "pr create + robot" "gh pr create --title x --body \"s $ROBOT\"" +bash_case allow "pr edit + robot" "gh pr edit 1 --body \"s $ROBOT\"" +bash_case allow "pr create --body-file" "gh pr create --title x --body-file $fixtures/dirty.md" +bash_case allow "commit Co-Authored-By" 'git commit -m "fix + +Co-Authored-By: Claude "' +tool_case allow "Write to docs" Write "{\"file_path\":\"docs/x.md\",\"content\":\"$M is a phrase\"}" +tool_case allow "Edit to docs" Edit "{\"file_path\":\"docs/x.md\",\"new_string\":\"$M\"}" +tool_case allow "slack read-only" mcp__claude_ai_Slack__slack_read_channel "{\"channel_id\":\"C0 $M\"}" + +echo "allow: fail-open cases and non-matches" +bash_case allow "read-only api scan" "gh api repos/o/r/pulls/1/comments --jq 'select(.body|test(\"$M\"))'" +bash_case allow "stdin --input -" "gh api repos/o/r/pulls/1/reviews --method POST --input -" +bash_case allow "unreadable path" "gh pr comment 1 --body-file $fixtures/does-not-exist.md" +bash_case allow "unrelated command" "ls -la" + +echo +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ] From b314df424fa3c6ae5bc9923928eaf8aa2cacb5af Mon Sep 17 00:00:00 2001 From: Kim Pohas Date: Thu, 10 Sep 2026 00:53:59 -0700 Subject: [PATCH 4/4] DOCS-1867 - Match Jira comment tool by suffix so any MCP prefix is caught The hook and its matcher targeted the literal tool name mcp__atlassian__addCommentToJiraIssue. Environments whose Atlassian MCP server uses a different prefix (e.g. mcp__claude_ai_Atlassian_Rovo__) were never matched, so attribution slipped through silently. Match on the addCommentToJiraIssue suffix instead, consistent with the Slack entries. Adds prefixed deny/allow test cases to lock this in. Co-Authored-By: Claude Sonnet 5 --- .claude/hooks/forbid-claude-attribution.sh | 5 +++-- .claude/hooks/forbid-claude-attribution.test.sh | 2 ++ .claude/settings.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.claude/hooks/forbid-claude-attribution.sh b/.claude/hooks/forbid-claude-attribution.sh index ff9e4b7dc74..b3352d81a12 100755 --- a/.claude/hooks/forbid-claude-attribution.sh +++ b/.claude/hooks/forbid-claude-attribution.sh @@ -9,7 +9,8 @@ # - gh pr comment / gh issue comment # - gh pr review (when it includes a --body or --comment) # - gh api ... /comments|/reviews ... -# - Jira: mcp__atlassian__addCommentToJiraIssue +# - Jira: *addCommentToJiraIssue (matched by suffix, so any MCP server +# prefix works: mcp__atlassian__, mcp__claude_ai_Atlassian_Rovo__) # - Slack: slack_send_message, slack_send_message_draft, # slack_schedule_message, slack_create_canvas, slack_update_canvas # @@ -137,7 +138,7 @@ $(body_file_paths "$cmd") EOF ;; - mcp__atlassian__addCommentToJiraIssue) + *addCommentToJiraIssue) if has_marker "$(all_input_strings)"; then deny 'Remove the Claude attribution line. AGENTS.md forbids "via Claude Code" in Jira comments. Re-add the comment without it.' fi diff --git a/.claude/hooks/forbid-claude-attribution.test.sh b/.claude/hooks/forbid-claude-attribution.test.sh index 55596593924..d5fd2a40923 100755 --- a/.claude/hooks/forbid-claude-attribution.test.sh +++ b/.claude/hooks/forbid-claude-attribution.test.sh @@ -86,6 +86,7 @@ bash_case DENY "-F body=@file" "gh api repos/o/r/issues/1/comments -F echo "deny: MCP comment tools" tool_case DENY "jira comment" mcp__atlassian__addCommentToJiraIssue "{\"commentBody\":\"x $M\"}" +tool_case DENY "jira comment prefixed" mcp__claude_ai_Atlassian_Rovo__addCommentToJiraIssue "{\"commentBody\":\"x $M\"}" tool_case DENY "slack message" mcp__claude_ai_Slack__slack_send_message "{\"text\":\"x $M\"}" tool_case DENY "slack canvas" mcp__claude_ai_Slack__slack_update_canvas "{\"markdown\":\"x $M\"}" @@ -94,6 +95,7 @@ bash_case allow "pr comment clean" "gh pr comment 1 --body \"looks good\"" bash_case allow "--body-file clean" "gh pr comment 1 --body-file $fixtures/clean.md" bash_case allow "api reviews clean" "gh api repos/o/r/pulls/1/reviews --method POST --input $fixtures/clean.json" tool_case allow "jira clean" mcp__atlassian__addCommentToJiraIssue '{"commentBody":"looks good"}' +tool_case allow "jira clean prefixed" mcp__claude_ai_Atlassian_Rovo__addCommentToJiraIssue '{"commentBody":"looks good"}' tool_case allow "slack clean" mcp__claude_ai_Slack__slack_send_message '{"text":"looks good"}' echo "allow: out of scope by design" diff --git a/.claude/settings.json b/.claude/settings.json index 03d9b0496d5..af52907b3a4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,7 +2,7 @@ "hooks": { "PreToolUse": [ { - "matcher": "Bash|mcp__atlassian__addCommentToJiraIssue|slack_send_message|slack_send_message_draft|slack_schedule_message|slack_create_canvas|slack_update_canvas", + "matcher": "Bash|addCommentToJiraIssue|slack_send_message|slack_send_message_draft|slack_schedule_message|slack_create_canvas|slack_update_canvas", "hooks": [ { "type": "command",