-
Notifications
You must be signed in to change notification settings - Fork 246
DOCS-1867 - Forbid Claude Code attribution on comments with a hook #7099
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
7ac4507
DOCS-1867 - Enforce Claude Code comment attribution with a hook
kimsauce f33f03f
Merge branch 'main' into DOCS-1867
kimsauce ac83e5c
DOCS-1867 - Forbid Claude Code attribution on comments with a hook
kimsauce 563a70d
Merge remote-tracking branch 'origin/main' into DOCS-1867
kimsauce aadb88f
DOCS-1867 - Add test matrix for the attribution hook
kimsauce b314df4
DOCS-1867 - Match Jira comment tool by suffix so any MCP prefix is ca…
kimsauce e7393ef
Merge branch 'main' into DOCS-1867
kimsauce ff1f080
Merge branch 'main' into DOCS-1867
kimsauce File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| #!/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: *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 | ||
| # | ||
| # 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 <<EOF | ||
| $(body_file_paths "$cmd") | ||
| EOF | ||
| ;; | ||
|
|
||
| *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 | ||
| ;; | ||
|
|
||
| *slack_send_message|*slack_send_message_draft|*slack_schedule_message|*slack_create_canvas|*slack_update_canvas) | ||
| if has_marker "$(all_input_strings)"; then | ||
| deny 'Remove the Claude attribution line. AGENTS.md forbids "via Claude Code" and "Generated with Claude Code" in Slack messages. Re-send without it.' | ||
| fi | ||
| ;; | ||
| esac | ||
|
|
||
| exit 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| #!/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 <expected> <description> <command string> | ||
| 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 <expected> <description> <tool name> <tool_input JSON> | ||
| 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 "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\"}" | ||
|
|
||
| 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"}' | ||
|
kimsauce marked this conversation as resolved.
|
||
| 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" | ||
| 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 <noreply@anthropic.com>"' | ||
| 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 ] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| { | ||
| "hooks": { | ||
| "PreToolUse": [ | ||
| { | ||
| "matcher": "Bash|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/forbid-claude-attribution.sh\"", | ||
| "statusMessage": "Checking for forbidden attribution" | ||
| } | ||
| ] | ||
| } | ||
|
kimsauce marked this conversation as resolved.
|
||
| ] | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.