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
139 changes: 139 additions & 0 deletions .agents/checks/attribution-lines.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# The attribution-line classifier. `.agents/checks/attribution.sh` holds it over every
# reachable commit message and `.agents/checks/attribution-metadata.sh` holds it over a
# pull request's title and body, so one line reaches one verdict wherever it is written.
# This file is sourced, never run.
#
# It reads a whole line at a time, never a substring of one, and holds three rules:
#
# 1. A `Co-Authored-By:` trailer, whoever it names. This repository's commits carry
# one author, so the actor cannot change the verdict.
# 2. An attribution trailer: a trailer whose key attributes the work and whose value
# names a generated actor, such as `Generated-by: Claude Code`.
# 3. A generated footer: a line that is nothing but an attribution phrase, such as
# `Generated with [Claude Code](https://claude.com/claude-code)`.
#
# Rule 3 has no key to recognise, so it has to read the whole line. The line must open
# with the attribution phrase, and every word after it must be a word a footer uses.
# That closed vocabulary is what keeps ordinary prose valid: a sentence about Claude,
# Codex, Gemini, Copilot, or about this detector, carries some word no footer carries,
# so it cannot match. The cost is a bespoke attribution sentence that no known tool
# writes, which this check does not claim to catch.
#
# The rule phrases below name the broken rule and nothing else. A caller reports the
# phrase and the place, so untrusted message content never reaches a log.
#
# Callers must export `LC_ALL=C`: the folding and the character classes here are ASCII,
# and a commit message or a pull request body may hold any bytes at all.

# The words that name a generated actor. Whole words only, because the classifier
# splits a line into words first: `ai` cannot match inside `said`, and `gpt` cannot
# match inside a hash.
attribution_actor_word() {
case $1 in
claude | anthropic | chatgpt | gpt | openai | codex | gemini | copilot | \
llm | llms | ai | agent | agents | assistant | assistants | \
bot | bots | model | models) return 0 ;;
esac
return 1
}

# The rest of a footer's vocabulary: the connectives a footer puts between its words,
# the vendor and edition words that finish an actor's name, and the words that are left
# of a link to the actor's home page once the punctuation is gone.
#
# The bare `o` is an edition word too. A model number is a separator, so a versioned GPT
# name leaves it behind on its own: `GPT-4o` splits into `gpt` and `o`, and `o3` into
# `o`. Without it those footers would carry a word no footer carries and read as
# sentences. It names no actor by itself, so a line still has to name one to match.
attribution_footer_word() {
case $1 in
a | an | the | and | my | our | its | this | of | in | on | at | to | for | \
with | by | using | via | from | \
google | github | microsoft | amazon | mistral | deepseek | \
code | coding | cli | app | chat | desktop | web | studio | \
assist | assistance | assisted | help | tool | tools | \
o | pro | flash | ultra | mini | nano | turbo | preview | latest | \
sonnet | opus | haiku | \
https | http | www | com | org | net | io | dev | sh) return 0 ;;
esac
return 1
}

# The ASCII letter words of an already folded line, in order. Everything else is a
# separator: a footer's version number, its link punctuation, and whatever bytes a
# message carries are noise, and the rules are about the words around them.
attribution_split() {
local IFS=$' \t\n' text=${1//[^a-z]/ }
# The unquoted expansion is the split itself, and the text holds only letters and
# spaces by now, so there is nothing here for a glob to match.
attribution_words=(${text})
}

attribution_names_actor() {
local word
for word in "${attribution_words[@]}"; do
if attribution_actor_word "${word}"; then
return 0
fi
done
return 1
}

# A footer phrase names an actor and says nothing else. One word outside the vocabulary
# is enough to make the line a sentence rather than a footer.
attribution_is_footer_phrase() {
local word named=1
for word in "${attribution_words[@]}"; do
if attribution_actor_word "${word}"; then
named=0
continue
fi
if ! attribution_footer_word "${word}"; then
return 1
fi
done
return "${named}"
}

# The trailer keys that attribute the work. `Co-Authored-By` is rule 1 and is not here,
# because rule 1 needs no actor.
readonly attribution_trailer_key='(generated-by|generated-with|generated-using|created-by|created-with|authored-by|written-by|made-by|made-with|built-by|built-with|assisted-by|committed-by|signed-off-by|co-authored-with|co-written-by|on-behalf-of|attribution|agent|assistant|model|llm|ai|bot|tool)'

# The opening of a generated footer: the decoration a footer leads with, such as the
# robot emoji Claude Code writes or a list marker; an optional subject clause, so
# `This pull request was created by ...` reads as one phrase; then the verb and the
# preposition that start the attribution.
readonly attribution_footer_head='^[^[:alpha:]]*((this|these|it|they|the)[[:blank:]]+([a-z]+[[:blank:]]+){0,2}(was|were|is|are)[[:blank:]]+)?(co-)?(generated|created|authored|written|produced|made|built|drafted|committed|assisted|developed|implemented)[[:blank:]]+(with|by|using|via|from)[[:blank:]]+'

# The one entry point. It sets `attribution_rule` to the phrase that names the broken
# rule and returns 0 when the line is an attribution line, and clears the phrase and
# returns 1 when it is not.
attribution_classify() {
local folded=${1,,}
attribution_rule=''

if [[ ${folded} =~ ^[[:blank:]]*co-authored-by[[:blank:]]*: ]]; then
attribution_rule='a Co-Authored-By trailer'
return 0
fi

# `BASH_REMATCH[0]` ends at the colon, so the rest of the line is the value.
if [[ ${folded} =~ ^[[:blank:]]*${attribution_trailer_key}[[:blank:]]*: ]]; then
attribution_split "${folded:${#BASH_REMATCH[0]}}"
if attribution_names_actor; then
attribution_rule='an attribution trailer that names a generated actor'
return 0
fi
fi

if [[ ${folded} =~ ${attribution_footer_head} ]]; then
attribution_split "${folded:${#BASH_REMATCH[0]}}"
if attribution_is_footer_phrase; then
attribution_rule='a generated attribution footer'
return 0
fi
fi

return 1
}
135 changes: 135 additions & 0 deletions .agents/checks/attribution-metadata.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# The pull request metadata gate. It holds `.agents/checks/attribution-lines.sh`, the
# classifier `.agents/checks/attribution.sh` holds over commit messages, over the title
# and body of the pull request the run is for.
#
# GitHub writes the event as JSON at `GITHUB_EVENT_PATH`. That file carries text a
# stranger wrote, so the text never reaches the shell as anything but a string: `jq`
# parses the file, the fields arrive through a quoted command substitution, they are
# compared against fixed patterns, and no path here expands or evaluates them. `jq`'s
# own diagnostics are dropped for the same reason, because a parse error quotes the
# text that failed to parse. The workflow must not interpolate the title or the body
# into the run step either; it passes only the event name and the event path, which
# GitHub itself controls.
#
# A run that is not for a pull request has no title or body and says so. A run that is
# for one has to be able to read both: a missing, unreadable, malformed, or wrongly
# shaped event fails, because the alternative is a check that passes quietly exactly
# when the metadata it guards cannot be read.
#
# Read-only: it reads one file and writes nothing.
#
# Usage: attribution-metadata.sh
#
# `GITHUB_EVENT_NAME` names the event and `GITHUB_EVENT_PATH` locates it. GitHub CI
# sets both. A local run sets neither, so the check reports that there is nothing to
# read rather than inventing a pull request.
set -euo pipefail

# ASCII case folding and ASCII character classes, whatever locale the host sets. The
# classifier is sourced after it, because it reads that setting rather than its own.
export LC_ALL=C

source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/attribution-lines.sh"

event_name=${GITHUB_EVENT_NAME:-}
event_path=${GITHUB_EVENT_PATH:-}

refuse() {
printf 'error: pull request event: %s\n' "$1" >&2
exit 1
}

# The event name is GitHub's, but a local caller can set anything, so it is printed
# only when it looks like one of the names GitHub uses.
if [[ ${event_name} =~ ^[a-z_]+$ ]]; then
event_label="event ${event_name}"
else
event_label='an event this check cannot name'
fi

case ${event_name} in
pull_request | pull_request_target) ;;
'')
printf 'attribution metadata: no GitHub event is set, so there is no pull request title or body to check\n'
exit 0
;;
*)
printf 'attribution metadata: %s is not a pull request, so there is no title or body to check\n' \
"${event_label}"
exit 0
;;
esac

if ! command -v jq >/dev/null 2>&1; then
refuse 'jq is not on PATH, so the event file cannot be parsed'
fi

if [[ -z ${event_path} ]]; then
refuse 'GITHUB_EVENT_PATH is not set, so the title and body cannot be read'
fi

if [[ ! -f ${event_path} || ! -r ${event_path} ]]; then
refuse 'the event file is missing or unreadable'
fi

# One pass over the event that reports a fixed word for each way it can fail to hold a
# title and a body. Only that word crosses back into the shell. A null body is the
# empty description GitHub writes for a pull request that has none, so it is a body;
# any other type means the file is not the payload it claims to be.
shape=$(jq -r '
if (type != "object") then "event-not-an-object"
elif (has("pull_request") | not) then "no-pull-request"
elif ((.pull_request | type) != "object") then "pull-request-not-an-object"
elif ((.pull_request | has("title")) | not) then "no-title"
elif ((.pull_request.title | type) != "string") then "title-not-a-string"
elif ((.pull_request | has("body")) | not) then "no-body"
elif ((.pull_request.body | type) as $t | ($t != "string" and $t != "null")) then "body-not-a-string"
else "ok"
end' <"${event_path}" 2>/dev/null) || shape='not-json'

case ${shape} in
ok) ;;
not-json) refuse 'the event file is not valid JSON' ;;
event-not-an-object) refuse 'the event file is not a JSON object' ;;
no-pull-request) refuse 'the event has no pull_request object' ;;
pull-request-not-an-object) refuse 'the event pull_request value is not an object' ;;
no-title) refuse 'the pull request has no title field' ;;
title-not-a-string) refuse 'the pull request title is not a string' ;;
no-body) refuse 'the pull request has no body field' ;;
body-not-a-string) refuse 'the pull request body is neither a string nor null' ;;
*) refuse 'the event file could not be read' ;;
esac

# The report names the field and the rule, never the line: the title and the body are
# untrusted text, and a run's own metadata already locates them.
check_field() {
local field=$1 filter=$2 text line status=0
if ! text=$(jq -r "${filter}" <"${event_path}" 2>/dev/null); then
printf 'error: pull request event: the %s could not be read\n' "${field}" >&2
return 1
fi
# A title with a newline in it is several lines, and each of them is a line the
# rules are about. A body edited through a browser arrives with carriage returns.
while IFS= read -r line; do
line=${line%$'\r'}
if attribution_classify "${line}"; then
printf 'error: the pull request %s has %s\n' "${field}" "${attribution_rule}" >&2
status=1
break
fi
done <<<"${text}"
return "${status}"
}

failures=0
check_field title '.pull_request.title' || failures=$((failures + 1))
check_field body '.pull_request.body // ""' || failures=$((failures + 1))

if ((failures > 0)); then
printf 'error: attribution metadata: %d of the 2 pull request fields break a rule\n' \
"${failures}" >&2
exit 1
fi

printf 'attribution metadata: the pull request title and body carry no attribution line\n'
Loading