Skip to content

Detect carriers

wm-hook --detect answers one question: is a carrier present? A carrier is a character you cannot see that hides data. --detect only reads files, and it never writes.

For install and a first run, see Quickstart.

Which files are scanned

Point it at a directory and it walks the tree. It skips .git, node_modules, .venv, dist, build, target and a built site/.

Dot files and dot directories are skipped too. Without that, scanning a project root reports on .claude/, .kiro/ and every other tool's configuration. On this repository that was 74 of 161 files, none of them written by the author.

wm-hook --detect .                        # your files
wm-hook --detect --include-hidden-files . # plus .claude/, .kiro/ and friends

A file you name directly is always scanned, whatever its extension and whether or not it starts with a dot. Asking for a specific file is an instruction.

The five verdict levels

Every file gets one level. Two of them are positives.

Level What it means Exit code
none No invisible characters at all. 0
benign Invisible characters are present, and the tool explains all of them. 0
anomaly Invisible characters are unexplained, but isolated and unstructured. 0
carrier Unexplained characters form a structure. Something embedded hidden data. 1
payload The same, and the hidden data decodes into readable text. 1

Exit code 2 means a file could not be read, or that no text files were found at all. The second case is an error on purpose. A scan that never happened must not report "0 of 0 files are clean".

A positive says that something embedded hidden data. It does not say who, and it does not say an AI wrote the text. A clean result says even less: see What it means.

Why structure decides the verdict, not presence

Almost every invisible character in a real source tree is legitimate. Counting characters alone reports roughly a hundred times more hits than the truth.

So the tool first explains what it can, using the rules in Invisible characters. Whatever is left over goes into a score.

Signal Weight Why it is not an accident
run 2 Debris is isolated. A payload is a block of characters in a row.
binary_alphabet 3 Exactly two codepoints repeating is a bit stream.
byte_aligned 1 A run length that divides by 8 was packed into bytes.
latin_context 2 Joiners belong in Indic, Arabic and Thai text. They never belong between two ASCII letters.
periodic 2 Evenly spaced marks encode one mark per token.
tag_outside_flag 3 The Unicode tag block has one legitimate use, and that use is already exempt.
private_use_in_text 2 These codepoints have no assigned meaning at all.

The weights that fire are added together:

Score Level Confidence
4 or more carrier high
2 or 3 carrier moderate
1 or less anomaly low

There is one shortcut. If the hidden data decodes, the level is payload and the score is not used. Confidence then comes from the decoder: high if the scheme is confirmed, moderate if it is only probable.

Why the anomaly tier exists

An anomaly is an unexplained character with no structure around it. Copy-paste debris from a web page, an editor artifact and a stray BOM inside a string literal all look like this.

They are worth removing. They do not support any conclusion.

$ wm-hook --detect -v pasted.md
ANOMALY  pasted.md: unexplained carrier, but no structure to call it payload
         1 carrier(s), 0 explained, 1 unexplained
         confidence: low; capacity 1 bits

The tool also prints a paragraph after each file saying what the level means, and a run summary at the end. Both are cut from the console blocks on this page.

Without this tier, every stray character becomes a finding, and most findings would be wrong. In the 1,155-file corpus the two unexplained characters both landed here, and zero carriers were established: see Results.

Worked example

-v prints the reasons: the signals from the table above, with their weights.

$ wm-hook --detect -v changelog.md stemmer.py
CARRIER! changelog.md: covert carrier present, and it decodes
         24 carrier(s), 0 explained, 24 unexplained
         + run (+2): 24 consecutive unexplained zero_width at offset 33
         + binary_alphabet (+3): run of 24 uses exactly two codepoints (U+200B U+200C) -- a bit stream
         + byte_aligned (+1): run length 24 is a multiple of 8
         > zero-width binary (ZWSP/ZWNJ) @33 [probable]
           'v41'
         confidence: moderate; capacity 24 bits
CLEAN    stemmer.py: invisible characters present, all legitimate
         1 carrier(s), 1 explained, 0 unexplained
         confidence: n/a; capacity 0 bits

stemmer.py holds a Devanagari joiner after a virama. That joiner is correct spelling, so the file is benign. Getting that right is harder than catching the file that decodes.

JSON output

--detect --json prints an array to standard output, one object per file. The exit codes are the same. A clean file still gets an object, with 0 counters and empty arrays.

Field Type Use it for
path string The file that was scanned.
level string One of the five levels above.
carrier_present boolean True only for carrier and payload. Branch on this field.
confidence string high, moderate, low or n/a.
carriers, explained, unexplained integer Counts of invisible characters.
score integer The total from the weights table.
bits_available integer How many bits the unexplained characters could hold.
by_class object Unexplained characters grouped by family, for example zero_width.
evidence array One entry per signal that fired, with name, weight and detail.
payloads array One entry per decoded payload, with scheme, decoded, offset and identifiers.
headline, means string Text for people to read. Do not parse these.
error string Present only when the file could not be read. No other field is present then.

--detect never writes. Bare wm-hook does.

wm-hook <dir> rewrites the whole tree in place. It damages Devanagari spelling and CJK typography. Read What breaks before you run it.