Who the Commit Says Wrote It
Most organisations that use coding agents have written down a rule about attribution. Ours is one sentence: work reads as authored by the human operator. Not a secret — the tools are named openly in our repositories. The rule is about credit.
We enforced it with a CI check that scans commit messages for co-author trailers and "generated with" footers. It worked. It had been running for weeks across thirteen repositories.
Then a pull request merged with an agent named in the author field of one of its commits. The check ran on that branch. It reported that it had stripped trailers. It passed.
It was right to pass, by its own rules. That is the problem.
The trailer is not the claim
A co-author trailer is one way to credit an AI. The author field is the other, and it is the more visible one. git log shows the author. The commit list on a hosting platform shows the author. Neither shows trailers unless you go looking.
So the check was reading the quieter half of the surface and reporting on the whole of it. An agent that authors a commit does not add a trailer crediting itself — it simply commits, and its identity goes in the field reserved for exactly that claim.
The fix is not complicated: read the author and committer name and email alongside the message. What was interesting was everything that went wrong on the way.
Four ways to accidentally refuse a human
Matching vendor names in a commit message is safe, because the check only fires inside an attribution construct — a trailer, a footer. A vendor name in prose is just a word.
An identity field has no construct. The field is the claim. So the matching has to be tighter, and every way we tightened it first was wrong:
- Substring matching. One vendor name sits inside the surname Guillamas. Another sits inside Codexis. A trailer crediting "Guillamas" is not plausible; an author field saying it is entirely plausible.
- Word boundaries. The obvious fix is a word boundary. In most regex flavours that is built on the word-character class, which includes the underscore — so the pattern stopped matching
copilot_swe_agent, which is the commonest shape a bot handle actually takes. The boundary added to protect surnames was skipping the exact identities we were looking for. - Vendor names that are human names. Claude and Devin are names people have. Gemini, Cursor and Codex are ordinary words. A rule that flags them on sight refuses a contributor for being named wrong.
- Marker words. We qualified the ambiguous names by requiring a bot or agent marker beside them — then left agent unbounded, so it matched inside
agentur, the German word for agency, and inside the surname Reagent.
Four attempts, four different ways to fail an outside contribution. The final shape splits the vocabulary: unambiguous product and company names match on their own; ambiguous ones need a bot or agent marker beside them, and every marker is bounded the same way.
The rule we deliberately did not write
Our standing orders also require commits to carry a company address. It would have been trivial to add that here, and it would have been wrong.
This check runs on our published projects. On those, a commit from an address we do not own is an outside contribution — the thing an open-source project exists to receive. A rule that failed every external pull request would be switched off within a week, and would take the authorship half with it.
So the domain rule stays where it belongs: a standing order a maintainer follows, not a gate that refuses strangers. We wrote the reasoning into the function itself, because otherwise the omission looks like something nobody got round to.
The same logic decided that automated dependency and release bots still pass. Not every bot is an authorship claim. The rule is about crediting AI with authorship, not about whether a human pressed the button.
Two bypasses that only existed together
The first version of the identity check split its input on a control character, with a comment explaining that only the message could contain that byte.
An author name is written by whoever makes the commit. On a fork pull request, that is an outside party's choice. A name containing the separator produced the wrong number of fields — and the parser, finding a record it could not split, skipped it and carried on reporting on the rest.
Neither decision is obviously wrong alone. A custom separator is ordinary. Skipping a malformed record is defensive-looking. Together they are: craft a name, skip the scan, and the run still comes back clean.
Both are fixed. The stream is NUL-delimited, which is the one byte the version control system guarantees cannot appear in those fields, and unparseable output now raises instead of being stepped over. A gate that quietly scans fewer commits than it was asked to is indistinguishable from one that found nothing.
The tests were the part that nearly fooled us
The check had no tests at all. It had been reviewed, and reviewing is not the same as exercising.
We wrote tests, and then mutation-tested them: revert each fix in turn and confirm the suite goes red. Twice, it did not.
The first miss was a test for annotation escaping that passed with the escaping removed. It assumed an attacker could put a newline into a commit subject. The version control system will not allow that, so the test was checking a case that cannot occur. The real vector was the percent sign — the CI platform decodes percent escapes in annotation text, so a subject containing the characters that spell an encoded newline becomes a genuine line break, and whatever follows it starts a new line of workflow output.
The second miss was a test for a new error handler that passed with the handler deleted, because the input it used tripped a different, pre-existing handler further up.
Both tests looked right. Both would have shipped as evidence for fixes they never touched. Neither would have been caught by reading them again more carefully — the only thing that caught them was breaking the code on purpose and watching what stayed green.
What we would tell someone starting this
Three things, in the order they bit us.
Enumerate the surfaces before writing the pattern. Credit can land in a commit message, an author field, a committer field, a pull request title, or a pull request body. A control that covers four of five is not eighty per cent of a control; it is a control with a hole, and the hole is wherever the tooling naturally writes.
Assume the fields are hostile. Identity fields are supplied by whoever makes the commit. On a public repository that is anyone. Parsing them with a delimiter they are allowed to contain is the same class of mistake as trusting a form field.
Break your own tests before you trust them. A test that passes is evidence of nothing until you have seen it fail for the right reason. This is more true, not less, for security controls — the failure mode of a bad security test is silence, and silence reads exactly like safety.
Why we are writing this down
We build AI governance tooling, so it is worth being plain about what happened here: we had the policy, we had the check, we had it deployed across every repository, and a bot-authored commit merged clean anyway.
That is not an argument against having the control. It is an argument about what a control is. A policy in a contributing guide is a statement of intent. A policy with a check in front of it is a statement of intent plus an assumption that the check tests what the policy says. The gap between those two is where the interesting failures live, and it does not close because you wrote the policy carefully.
The distance between "we have a rule about this" and "something would notice" is usually larger than it looks from the inside. It is worth measuring on your own estate before you describe it to anyone else.