Survey a tree¶
scripts/wm-survey.py scans a whole directory tree and reports rates. It only
reads files. It never writes.
The survey answers a different question from the hook. The hook asks "is this file clean?". The survey asks "how much invisible material is in this tree, and how much of it is legitimate?".
The survey lives in the repository, not in the installed command
wm-hook is the command you install. The survey is a script in the source
tree, and it is not part of the wheel. Clone the repository to run it.
Running it¶
git clone https://github.com/norandom/watermarks-remover
cd watermarks-remover
python scripts/wm-survey.py /path/to/repo
| Argument | What it does |
|---|---|
<path> |
A directory to scan. Repeat it to scan several trees. |
--json |
Write the full report to stdout as JSON. |
--exclude PREFIX |
Skip paths that start with PREFIX. Repeat as needed. |
Always exclude your own carrier test fixtures. This project keeps deliberate
payloads in tests/corpus/, and they dominate every number unless you skip
them.
python scripts/wm-survey.py . --exclude tests/corpus/
python scripts/wm-survey.py repo-a repo-b --json > findings.json
The survey walks directories and skips .git, node_modules, .venv, dist,
build, target and site. It shares that list with the hook.
It differs in one way. The survey also reads dot directories such as .github/
and .claude/, which the hook leaves alone. Your file counts will therefore be
higher than the hook's.
Windows consoles
A decoded payload can hold a character your console cannot print. The
report then stops with a UnicodeEncodeError. Set PYTHONIOENCODING=utf-8
before you run it.
The three numbers¶
A real run over this repository, on 2026-08-16:
$ python scripts/wm-survey.py . --exclude tests/corpus/
files scanned 141
files with any carrier 13 (9.22%)
files with UNEXPLAINED 5 (3.55%)
carriers found 144
explained as legitimate 71 (49.31%)
unexplained (candidates) 73
The first block counts files. The second block counts characters, and it is the part that matters:
| Number | Meaning |
|---|---|
| carriers found | Every invisible or format codepoint in the tree. |
| explained | Those with a documented legitimate cause. |
| unexplained | The rest. These are the only watermark candidates. |
The tool never merges the three into one figure.
Carriers found is not a detection rate
Almost every invisible codepoint in real source code is legitimate. Reporting "carriers found" as a detection rate overstates the result by about two orders of magnitude. See Results for the measured gap.
The counts above are high because this repository studies carriers. Its unexplained ones sit in worked documentation examples and in the detector's own test data. A normal project looks nothing like this.
Reading the unexplained list¶
The survey prints where the unexplained carriers are, worst file first. It shows the 20 worst files and up to five offsets in each:
unexplained detail:
src/wm_hook/payload.py x24
@3389 U+FE00 VARIATION SELECTOR-1 [variation_selector]
@3391 U+FE0F VARIATION SELECTOR-16 [variation_selector]
@4224 U+200B ZERO WIDTH SPACE [zero_width]
Each line gives the character offset in the file, the codepoint, its Unicode
name and its class. Open the file at that offset and judge it yourself. If the
file is test data or a documented example, add it to --exclude and run again.
What counts as explained¶
| Carrier | Explained when |
|---|---|
| Variation selector | it follows an emoji, symbol or ideograph base |
U+200C / U+200D |
it sits between letters of a script that needs it, or at a word boundary after one |
U+200B |
it follows a base in a script that uses it as a word separator |
U+FEFF |
it is at offset zero |
| Space homoglyph | it is typographic, or an ideographic space in CJK |
| Tag characters | they are inside a subdivision flag sequence |
| Directional marks | they are in mixed-direction text |
The survey resolves context against the nearest base character before the
carrier, skipping any other carriers on the way. Without that step the joiner
in ❤️🔥 looks like it follows a variation selector rather than the heart, and
the survey reports it as a payload.
The verdict block¶
The survey runs the same classifier as wm-hook --detect and counts files per
verdict level:
COVERT CARRIER PRESENT?
none 128 file(s)
benign 8 file(s)
anomaly 2 file(s)
carrier 3 file(s)
-> established in 3 file(s)
src/wm_hook/payload.py [carrier/high] run, run, private_use_in_text 76 bits
It reports counts per level and never a percentage. See Detect carriers for what each level means and how the score is built.
The explanation layer is the weak point¶
The survey is only as good as its ability to say "this carrier is legitimate". Explain too much and real marks disappear. Explain too little and every emoji raises an alarm.
The first version got this wrong 15 times across 4 files. It inherited two blind spots from the cleaner:
- Five emoji base characters sit outside the Symbol categories, so their presentation selectors looked like payloads.
- Context resolution looked back exactly one character, so a joiner that followed a variation selector lost its base.
Both are fixed. One limitation remains: source that escapes a character but leaves its combining mark literal.
A file can contain \U0001f441 written as an escape sequence, followed by a
real U+FE0F. No base character exists in the file, so the survey cannot
explain the selector. It reports a false positive, and any tool built this way
will do the same.
Treat the unexplained count as an upper bound that still needs review. It is not a verdict.
Attribution¶
attribution (overt evidence only):
config on disk claude
commit trailers claude=30 (93.8%) of 32 commits
Both sources are overt, which means the agent declared them itself:
| Agent | Config on disk | Commit trailer |
|---|---|---|
| Claude | .claude/, CLAUDE.md, .mcp.json |
Co-Authored-By: Claude, Generated with [Claude Code] |
| Codex | AGENTS.md, .agents/, .specify/, .codex/ |
Co-Authored-By: Codex or ChatGPT |
| Copilot | .github/copilot-instructions.md |
Co-Authored-By: Copilot |
| Cursor | .cursor/, .cursorrules |
Co-Authored-By: Cursor |
| Gemini | .gemini/, GEMINI.md |
none |
| Aider | .aider.conf.yml, .aiderignore |
none |
| Devin | none | Co-Authored-By: Devin |
Neither source survives rm -rf .claude and a rebase. Attribution here shows
only that nobody cleaned up. It is not proof.
A trailer count is not an authorship share
Several agents often work on the same repository, and some leave no trailer at all. See Results for the measured counts.
The survey does not guess an agent from writing style or formatting habits. You cannot prove such a guess wrong, so it is not evidence.
What the survey cannot see¶
The survey reads codepoints. It cannot see a statistical watermark, which lives in the model's choice of words and leaves no codepoint trace. A result of zero does not mean a human wrote the text.
Every run ends with a "what this run could detect" block. It lists each detection channel and its status, including the ones that are switched off. See What it means for the channels, and Limits and disclaimer for the full caveats.