diff --git a/.claude/hooks/forbid-claude-attribution.sh b/.claude/hooks/forbid-claude-attribution.sh new file mode 100755 index 0000000000..b3352d81a1 --- /dev/null +++ b/.claude/hooks/forbid-claude-attribution.sh @@ -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 <&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 "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"}' +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 "' +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 ] diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..af52907b3a --- /dev/null +++ b/.claude/settings.json @@ -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" + } + ] + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index 05b6ecb041..bb993d5c99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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: