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
154 changes: 154 additions & 0 deletions .claude/hooks/forbid-claude-attribution.sh
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
120 changes: 120 additions & 0 deletions .claude/hooks/forbid-claude-attribution.test.sh
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\"}"
Comment thread
kimsauce marked this conversation as resolved.
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"}'
Comment thread
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 ]
16 changes: 16 additions & 0 deletions .claude/settings.json
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"
}
]
}
Comment thread
kimsauce marked this conversation as resolved.
]
}
}
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,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**: Always append `— via Claude Code` to any comment posted to a Jira ticket
- **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
Expand All @@ -127,6 +128,7 @@ Before transitioning any ticket to Published:

## GitHub Rules
- **Assignee**: Assign any new PR to the current user unless otherwise specified
- **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:
Expand Down