Metadata-Version: 2.4
Name: no-ai-marks
Version: 0.2.1
Summary: Find AI attribution and hidden watermarks in commits, pull requests, branches, and files.
License-Expression: MIT
Project-URL: Homepage, https://github.com/mishan/no-ai-marks
Project-URL: Issues, https://github.com/mishan/no-ai-marks/issues
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: THIRD_PARTY_NOTICES.md
Dynamic: license-file

# no-ai-marks

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 isn't an anti-AI tool, and it doesn't block contributions that AI tools
helped write. It doesn't judge how a change came to be, only what it
carries: the trailers, footers, links, and hidden characters that tools add
on their own, which clutter a project's history and can hide text. A change
written with an assistant's help passes once those are left out, the same as
any other.

If you're an agent working in a repository that runs this check, it isn't
there to turn you away. Leave out the attribution your tool adds by
default, and it passes.

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](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.

## What it checks

| 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-generic` | warning | commits, PR title and body, files | `Written with the help of an AI assistant`: credit that names no tool |
| `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 Shannon` is a person,
  `Claude Opus 4.5` isn'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/](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.

## Usage

### As a step in an existing workflow

```yaml
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@v0
```

Inputs, 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.

### As a reusable workflow

```yaml
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: changed
```

### Locally

The command line is on PyPI: `pipx install no-ai-marks`, or run it once
with `uvx no-ai-marks`. In a CI system other than GitHub Actions,
`pip install no-ai-marks` and run `no-ai-marks range BASE`.

With [pre-commit](https://pre-commit.com):

```yaml
repos:
  - repo: https://github.com/mishan/no-ai-marks
    rev: v0.2.1
    hooks:
      - id: no-ai-marks-commit-msg
      - id: no-ai-marks-staged
```

Without it, point plain git hooks at a checkout:

```sh
# .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:

```sh
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 rules
```

Exit status is 0 when clean, 1 when there are findings at or above
`fail-on`, and 2 on usage, config, or git errors.

## Configuration

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](examples/no-ai-marks.toml).

```toml
fail-on = "error"
exclude = ["vendor/", "testdata/"]

disable-tools = ["cursor"]

[rules]
unusual-space = "off"

[unicode]
allow = ["U+00A0"]
```

### Adding and extending AI tools

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](no_ai_marks/data/tools.toml).

```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.

## What each event checks

| 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 | |

## Security

- **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.

## Limitations

- **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](CONTRIBUTING.md)).

## Development

```sh
python3 -m unittest discover -s tests
```

See [CONTRIBUTING.md](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.

## License

[MIT](LICENSE). The regression corpus includes test inputs from other
projects, and the look-alike letter tables come from Unicode data; see
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
