Git & GitHubLesson
Commit and PR attribution
An agent that works through a person's GitHub account shows up as that person on every issue, comment and commit it writes. GitHub can't tell the two apart, so attribution, the mark that says an agent wrote it, has to travel in the content. Terrarium's fix is to put a link to the session that wrote it on each agent post and commit.
Know git hooks and trailers already? Skip to In Terrarium.
Outside the box · General
The concept
The identity a platform shows comes from the credential that made the request. When an agent uses a person's token, the login, the avatar and GitHub's author_association all name that person. The only place left for attribution, saying "an agent wrote this", is the text itself. Such a mark is a claim, not proof: anyone who can type can copy it.
Commit messages have a standard place for such facts. A trailer is a Key: value line at the end of the message, in a block after a blank line, as with Signed-off-by: Alice <alice@example.com>. Tools read trailers without parsing the free text above them, and git interpret-trailers adds or parses them for you. GitHub reads one trailer itself: each Co-authored-by: name <email> line credits a co-author on the commit (GitHub Docs: Creating a commit with multiple authors).
A git hook can add a trailer for you. Hooks are programs in a hooks directory that git runs at fixed points; the directory is .git/hooks unless the core.hooksPath setting names another, and a hook that isn't executable is ignored. The commit-msg hook runs during git commit and git merge with one argument, the path of the file that holds the proposed message. It may edit that file in place, and a nonzero exit aborts the commit; --no-verify skips it. Hooks aren't copied when you clone a repository (Pro Git: Git Hooks), so a project that wants one in every clone commits it to a tracked folder and points core.hooksPath there.
Claude Code adds attribution of its own: by default a Co-Authored-By trailer on each commit it makes and a "Generated with Claude Code" line in each pull request description, plus, from a cloud session, the session's link. Its attribution setting changes or hides each part.
Full referenceGit documentation: githooks
In Terrarium
Agents post to GitHub through the owner's own connection, and author_association reads OWNER on every agent write (Author trust). Each write names its instead, in one shape per surface, and ADR-0017 (docs/adr/0017-provenance-footer-on-agent-authored-content.md) is the rule.
On a GitHub body, such as an issue, a PR description, a comment or a review, the first line is a header. scripts/lib/provenance-header.ts builds it:
30export function provenanceHeader(label: string, sessionUrl: string): string {
31 return `${BOT_ICON} [${label}](${sessionUrl})`
32}
It renders as a robot icon and a link such as 🤖 [Claude](https://claude.ai/code/session_…). A cloud session's link ends in session_<id>; a local session has no web page, and its link carries the raw session id in that place.
On a commit, the message ends with two trailers, built by scripts/session/commit-trailer.ts:
132export function provenanceFooter(modelName: string, sessionUrl: string): string {
133 return [coAuthorLine(modelName), sessionLine(sessionUrl)].join('\n')
134}
The first is a Co-Authored-By: line naming the model at noreply@anthropic.com, the second a Claude-Session: line holding the session's link.
Posts are checked before they go. scripts/hooks/github-provenance-guard.ts is a guard, Terrarium's name for a blocking hook: a PreToolUse hook on each GitHub write tool, listed by name in .claude/settings.json, and on Bash calls that run gh api or curl to GitHub's REST API. It finds this session's id from the session's environment or transcript, reads the one place the mark belongs (line one of a body, or the last Claude-Session: trailer of a commit made through the GitHub tools), and judges it:
108function adjudicate(
109 slotId: string | undefined,
110 text: string,
111 truth: string,
112 tool: string,
113 surface: ProvenanceSurface,
114): ProvenanceFinding | null {
115 if (slotId !== undefined) {
116 return slotId === truth ? null : { kind: 'mismatch', tool, surface, found: slotId, expected: truth }
117 }
118 const ids = sessionIdsIn(text)
119 if (ids.length === 0) return { kind: 'missing', tool, surface, expected: truth }
120 if (ids.includes(truth)) return { kind: 'malformed', tool, surface, expected: truth }
121 return { kind: 'mismatch', tool, surface, found: ids.at(-1)!, expected: truth }
122}
A mark naming another session is a mismatch, this session's link anywhere but its slot is malformed, and no link at all is missing. The guard also denies the call when it can't find this session's id, or can't read a shell command's body. When the guard knows this session's id, its refusal holds the exact line to paste; when it can't find the id, the refusal says to report that instead of typing an id. A call that may carry no text, such as a label change or a bare approval, passes.
Local commits are repaired, not checked. pnpm install runs git config core.hooksPath .githooks from package.json's postinstall, and .githooks/commit-msg hands the message file to commit-trailer.ts:
15root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
⋯# …
20common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
21main_root=$(cd "$(dirname "$common_dir")" && pwd) || exit 0
22"$main_root/node_modules/.bin/tsx" "$root/scripts/session/commit-trailer.ts" "$1" >/dev/null 2>&1 || exit 0
23exit 0
The hook runs the script from the main checkout's node_modules, which a git worktree lacks. The script finds the session from its environment or its transcript. It appends whichever trailer is missing, rewrites a Claude-Session: line that names another session or a Co-Authored-By: line whose model name it doesn't know, and leaves the message alone when it finds no session, as on a person's own commit. Every path through the hook ends in exit 0.
Reading the mark back. Code that asks whether an agent wrote a post calls hasAuthorshipMarker:
73export function hasAuthorshipMarker(text: string): boolean {
74 return PROVENANCE_HEADER.test(text) || LEGACY_PROVENANCE_FOOTER.test(text) || HARNESS_FOOTER.test(text)
75}
It accepts the header only at the very start of the text, the older Co-Authored-By footer anywhere, and the "Generated with Claude Code" line that Claude Code adds itself; a session link elsewhere in the prose doesn't count. scripts/github/check-triage-drift.ts wraps it as isAiAuthored, which, for example, the guest pipeline's scan uses to skip its own earlier replies. The merge script asks a narrower question: verdictRefusal in scripts/github/merge-pr.ts looks for a verdict whose header names this very session (Pull requests).
Why it's built this way
- A mark in the content. The login and
author_associationshow the owner for every agent write, and agents misread who did what. A bot account would fix the login, but GitHub access is a connection set up outside the repo. Any reader can check a line in the text. The cost: anyone can type it, so it never decides who may direct an agent. ADR-0017 (docs/adr/0017-provenance-footer-on-agent-authored-content.md). - A header, not a footer. GitHub posts first carried the mark as a footer, but Claude Code adds its own footer on the last line, and agents dropped theirs. Line one belongs to nobody else. The cost: a body copied from another session brings that session's header along. ADR-0017 (docs/adr/0017-provenance-footer-on-agent-authored-content.md).
- Block posts. A refusal at the call teaches the format where a written rule went unread (Claude Code hooks says why). A guard that added the header was set aside: a hook can rewrite its call, but a post is public at once, so a wrong rewrite can't be fully undone. The cost: a session that can't find its own id can't post at all. ADR-0017 (docs/adr/0017-provenance-footer-on-agent-authored-content.md).
- Repair local commits. A commit hook can fix the message itself, so blocking would buy nothing, and a hook bug would stop every commit. The cost: when the hook fails, the commit lands unmarked. ADR-0017 (docs/adr/0017-provenance-footer-on-agent-authored-content.md).
- Reading is wider than writing. Old posts are never rewritten, so the reader keeps accepting every mark ever written, or old agent posts would read as human. It still refuses a bare session link: a pipeline that skips agent posts would otherwise drop a person's comment that merely pasted a link. The cost: the old shapes stay in the reader for good. scripts/lib/provenance-header.ts.
What to watch for
- Check that the trailer landed. The hook gives up without a word when the main checkout has no
node_modulesornodeisn't on the path, andgit commit --no-verifyskips it. Readgit log -1after a commit, as docs/agents/git-conventions.md says. - A copied body carries the old header. Rewriting an issue body from another session's text keeps that session's line one, and the guard denies it as a mismatch. Replace the header with your own before you post.
- Only the link is checked. The label in brackets is free text; write
Claudethere if you must not name the model. The link must be exactly this session's, taken from the deny message or your own environment, never typed from memory. - Line one or nothing. A header lower down is denied, and quoting another agent's post doesn't make your post look like theirs.
- A mark is not trust. A header proves nothing about who may direct an agent (Author trust).
- The guard reads command text. A
gh apicall inside a script goes unchecked, which is why agents write through the MCP tools (MCP servers).
Try it in your project
Commit a .githooks/commit-msg that adds a trailer only when your agent's session is known, and make it executable:
#!/bin/sh
[ -n "$AGENT_SESSION_URL" ] || exit 0
git interpret-trailers --in-place --if-exists=doNothing \
--trailer "Agent-Session: $AGENT_SESSION_URL" "$1" || exit 0
Run git config core.hooksPath .githooks, make one commit with AGENT_SESSION_URL set and one without, and compare the two messages with git log.
Where next
- LessonAuthor trust
- LessonClaude Code hooks
- LessonPull requests
- LessonMCP servers
- JournalThe best stories so far
Walkthroughs through this Lesson
- WalkthroughAsking an agent for a change
Last updated · Latest source on GitHub ↗