A CI check that fails when commits, pull requests, branch names, or changed files carry AI attribution or hidden watermarks. It's meant for projects whose contribution policy doesn't allow them.
It doesn't try to guess whether code looks machine-written. It only looks for explicit markers: trailers, footers, bot identities, session links, branch prefixes, invisible Unicode, look-alike letters, and AI provenance metadata in images and documents.
It works in any repository regardless of language: Node, Go, TypeScript,
Common Lisp, or a folder of Markdown. It reads git objects and the event
payload and never runs your project's toolchain. The scanner is a
standard-library-only Python package that needs Python 3.11 or newer. The
action uses the runner's Python when it's new enough, and otherwise installs
one with actions/setup-python without changing the job's PATH.
What it knows about AI tools (names, model families, bot accounts, branch prefixes, session links) is data in no_ai_marks/data/tools.toml, not code. When a new model or agent ships, keeping up means editing that file, or adding an entry to your own config until a release catches up.
| Rule | Default | Checks | Example |
|---|---|---|---|
ai-trailer |
error | commits, PR body | Co-authored-by: Claude <noreply@anthropic.com>, Assisted-by: ..., AI-Assisted: yes |
ai-identity |
error | commit author and committer, emails in files | Copilot <...+Copilot@users.noreply.github.com>, Jane (aider), cursoragent@cursor.com |
ai-footer |
error | commits, PR title and body, files | Generated with [Claude Code](...), ;;; Written by ChatGPT, @author Copilot, <meta name="generator" content="ChatGPT"> |
ai-tag |
error | commits, PR title and body | [AI], (AI-generated), (via Claude) |
ai-emoji |
error | commits, PR title and body | the robot emoji (U+1F916) |
ai-session-link |
error | commits, PR text, files | claude.ai/code/..., chatgpt.com/share/... |
ai-branch |
error | branch name | claude/..., copilot/..., codex/..., cursor/... |
ai-metadata |
error | binary files, SVG/XMP | IPTC trainedAlgorithmicMedia, Stable Diffusion PNG parameters, EXIF/PDF/Office fields naming a generator |
c2pa-manifest |
warning | binary files | C2PA content credentials (cameras sign these too, so this is a warning) |
invisible-char |
error | everything | zero-width spaces, bidi overrides (Trojan Source), tag characters, variation selector runs, soft hyphens, terminal control characters |
homoglyph |
error | everything | p<U+0430>ypal: a Cyrillic a inside a Latin word |
unusual-space |
warning | everything | no-break and other non-ASCII spaces |
private-use |
warning | everything | Private Use Area code points |
It tries hard not to flag legitimate text:
- Humans named Claude, Devin, or Jules pass.
Claude Shannonis a person,Claude Opus 4.5isn't. - Policy text passes: "Code generated by AI tools is not accepted."
- Ordinary generated-code headers pass:
Code generated by protoc-gen-go. - ZWJ is allowed inside emoji sequences, and ZWJ/ZWNJ between letters of scripts that need them (Persian, Hindi, ...). Right-to-left marks and balanced bidi isolates are allowed in lines with right-to-left text, and Mongolian variation selectors after Mongolian letters. Subdivision flags, a leading BOM, form feeds (Lisp page breaks), and no-break spaces inside numbers ("12 000", "10 %") are fine too.
- Only lines added by the change are checked by default, so existing files don't block unrelated pull requests.
When it finds text hidden in tag characters, variation selectors, or zero-width binary (the "ASCII smuggling" and "emoji smuggling" tricks), it decodes the text and shows it in the finding.
tests/corpus/ holds about 760 regression cases: the test cases of other projects that block AI attribution or hidden characters, and real commits, pull requests, and links from AI tools. Where this project deliberately doesn't flag something another one does, the case says why.
on:
pull_request:
types: [opened, edited, reopened, synchronize] # "edited" re-checks the description
push:
branches: [main]
merge_group:
permissions:
contents: read
jobs:
no-ai-marks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # the commit range needs history
persist-credentials: false
- uses: mishan/no-ai-marks@v0Inputs, all optional: config, checks (commits,pr,branch,files), scope
(changed or all), fail-on (error or warning), base, head, and
python. Outputs: errors, warnings, and report (a JSON file).
Findings show up as annotations on the pull request and in the job summary.
on:
pull_request:
types: [opened, edited, reopened, synchronize]
jobs:
no-ai-marks:
uses: mishan/no-ai-marks/.github/workflows/no-ai-marks.yml@v0
with:
scope: changedWith pre-commit:
repos:
- repo: https://github.com/mishan/no-ai-marks
rev: v0.1.1
hooks:
- id: no-ai-marks-commit-msg
- id: no-ai-marks-stagedWithout it, point plain git hooks at a checkout:
# .git/hooks/commit-msg
exec python3 /path/to/no-ai-marks/bin/no-ai-marks message "$1"The command line also works on its own:
no-ai-marks range origin/main # commits and added lines since main
no-ai-marks staged # what you're about to commit
no-ai-marks files docs/ assets/ # whole files
gh pr view 42 --json body -q .body | no-ai-marks text
no-ai-marks rules # list the rulesExit status is 0 when clean, 1 when there are findings at or above
fail-on, and 2 on usage, config, or git errors.
Put a TOML file at .github/no-ai-marks.toml or .no-ai-marks.toml. Every
setting is optional; see examples/no-ai-marks.toml.
fail-on = "error"
exclude = ["vendor/", "testdata/"]
disable-tools = ["cursor"]
[rules]
unusual-space = "off"
[unicode]
allow = ["U+00A0"]Your config file takes the same [[tool]] entries as the built-in list.
The fields are described at the top of
no_ai_marks/data/tools.toml.
# A tool the built-in list doesn't know yet.
[[tool]]
id = "acme-agent"
names = ["Acme Agent"]
emails = ["agent@acme.example"]
branch-prefixes = ["acme"]
# Extending a built-in tool: lists are appended, other values replaced.
[[tool]]
id = "claude"
models = ["Mythos"]no-ai-marks tools lists every tool your config ends up with, and
disable-tools switches off built-in ones by id.
A file that has to quote a marker, such as a contribution guide with an
example trailer, can skip a line with no-ai-marks: allow anywhere on it.
Commit messages and pull request text can't opt out.
| Event | Commits | Files | Branch | PR title and body |
|---|---|---|---|---|
pull_request, pull_request_target |
base..head | lines added since the merge base | head branch | yes |
merge_group |
base..head | lines added | ||
push |
before..after (new branch: since the default branch) | lines added | pushed branch | |
| anything else | every file, with scope: all |
current branch |
- In pull requests, the config comes from the base commit. A pull request can't switch off the rules it's checked against. If it edits the config, a notice says so, and the edit takes effect after merge.
- Nothing from the pull request runs. The action only reads git objects,
so it's also safe under
pull_request_target. That event checks out the base branch, so fetch the head first:git fetch origin pull/${{ github.event.number }}/head. - Output is escaped. Commit messages and PR bodies are untrusted, so everything printed as a workflow command is escaped to stop a crafted message from injecting commands of its own.
- Inputs go through environment variables, never interpolated into the shell script.
- It only finds explicit markers. Statistical watermarks that live in word choice (such as SynthID for text) can't be detected without the vendor's key, and no heuristic can reliably tell you who wrote a piece of code.
- It's a policy aid, not an adversarial detector. It catches tool defaults and honest mistakes. Someone who rewords a footer can get past it.
- The tool list needs upkeep. Agents change their bot accounts and trailers, and new models keep shipping. Add entries in your config file, and send them upstream (see CONTRIBUTING.md).
python3 -m unittest discover -s testsSee CONTRIBUTING.md for adding tools and rules. The source and tests write every invisible or look-alike character as an escape, so this repository passes its own check.
MIT. The regression corpus includes test inputs from other projects, and the look-alike letter tables come from Unicode data; see THIRD_PARTY_NOTICES.md.
0 comments
log in to comment.