Agent skill + stdlib Python service to strip multi-vendor AI provenance marks from text and files — for privacy and hygiene on content you own. The skill is a thin client: it drives the machinery over HTTP, so the agent host needs no Python.
Skill path: skills/remove-ai-marks/
Service path: service/
(migration: formerly remove-claude-marks; slash alias /remove-claude-marks still documented)
Install (agent skill)
The skill ships no code — it calls the service over HTTP. Install the skill (markdown only) and start the service, then set WATERMARKS_SERVICE_URL if it is not http://127.0.0.1:8765.
Invoke with /remove-ai-marks or ask to 'strip AI watermarks / C2PA / Claude marks / SynthID-class text.'
Optional Cursor text-only skill
skills/clean-user-facing-text/ is a
self-contained Cursor skill for authorized manuscripts, documentation, and web
copy. It excludes image, C2PA, service, and external-model tooling.
Install it into ~/.cursor/skills/clean-user-facing-text:
python3 install_skill.py
On Windows, use py install_skill.py. The install-skill.sh wrapper is
provided for macOS/Linux shells. Existing installations are preserved unless
you pass --force; replacement is staged first and the previous install is
kept as a uniquely named backup.
Skill invocation is model-selected. Projects that explicitly adopt this
workflow can also copy the optional rule:
For all projects, put the same instruction in Cursor User Rules instead.
Rules improve consistency but remain model instructions; Cursor does not expose
a deterministic pre-send filter for final chat responses.
Start the service
The fastest path is a local HTTP server (Python 3.10+ stdlib only — no deps, no Docker):
make serve # http://127.0.0.1:8765# or directly:
python3 service/scripts/server.py --host 127.0.0.1 --port 8765
For the whole infra (core + optional harness/heavy backends), see Docker / compose below.
Optional system tools (auto-used when present — preinstalled in the core Docker image):
Structural PDF rebuild — required for a real PDF strip (see below)
Core scripts need Python 3.10+ stdlib only. Layer B model calls are optional.
Quick use (scripts)
SCRIPTS=service/scripts
# Unified inspect / clean
python3 "$SCRIPTS/inspect_file.py" draft.md
python3 "$SCRIPTS/clean_file.py" draft.md -o draft.cleaned.md
python3 "$SCRIPTS/clean_file.py" photo.png -o photo.cleaned.png
python3 "$SCRIPTS/clean_file.py" notes.docx -o notes.cleaned.docx
# Text Layer A
python3 "$SCRIPTS/inspect_text.py" draft.md
python3 "$SCRIPTS/clean_text.py" draft.md -o draft.cleaned.md --stats
# Layer B rewrite hook (default: print prompt only — no model required)
python3 "$SCRIPTS/rewrite_text.py" draft.md --backend print-prompt --strength paraphrase
# Optional local Ollama (loopback only by default — remote endpoints require# WATERMARKS_REWRITE_ALLOW_REMOTE=1 or --allow-remote):# WATERMARKS_REWRITE_BACKEND=ollama WATERMARKS_REWRITE_MODEL=llama3.2 \# python3 "$SCRIPTS/rewrite_text.py" draft.md -o draft.rewritten.md# API keys are read from WATERMARKS_REWRITE_API_KEY only (never argv).# Images
python3 "$SCRIPTS/inspect_image.py" shot.png
python3 "$SCRIPTS/clean_image.py" shot.png -o shot.cleaned.png
Text tools refuse binary input
inspect_text.py, clean_text.py and rewrite_text.py operate on text. Pointed
at a .docx, .pdf or image they used to decode the compressed bytes and report
whatever codepoints fell out — noise that tracks the compression, not the
content — and clean_text.py then wrote those mangled bytes back, destroying the
file. They now refuse binary input and name the tool that handles it:
python3 "$SCRIPTS/inspect_text.py" report.docx
# refusing to treat report.docx as text: it looks like a ZIP container (DOCX, ODT, …).# Use inspect_file.py / clean_file.py, which route by format,# or pass --force-text to scan the raw bytes anyway.
Detection is by magic number plus a control-byte ratio, so text in encodings
other than UTF-8 keeps working. --force-text overrides it everywhere.
HTTP service
The same machinery runs as a stdlib HTTP service (service/scripts/server.py) — the interface the skill uses and the way any web app can integrate without vendoring:
The service routes by filename extension then magic bytes, so text / image / container are auto-detected. Set WATERMARKS_SERVER_API_KEY to require Authorization: Bearer <key> on every request. Loopback-only bind by default (--host to override); intended for a trusted network.
CtrlRegen pixel removal — never published (noai-watermark ships no LICENSE)
Local build only
watermarks-remover-synthid-scorer:local
reverse-SynthID scorer — never published (non-commercial Research License)
Local build only
Build and run the core service:
make docker-core-build
docker run --rm -p 127.0.0.1:8765:8765 --read-only --tmpfs /tmp watermarks-remover
# any CLI stays runnable by overriding the command:
docker run --rm -v "$(pwd):/data" watermarks-remover \
/app/scripts/clean_file.py /data/notes.md -o /data/notes.cleaned.md
Whole-infra bring-up:
docker compose up -d # core HTTP service only
docker compose --profile harness up -d # + markllm / markdiffusion
docker compose --profile heavy up -d # + ctrlregen / synthid (local builds)
docker compose --profile harness --profile heavy up -d # all services
The compose stack maps the core service to 127.0.0.1:8765. The harness/heavy services are one-shot CLIs — invoke with docker compose run --rm <service> … when you need verification or pixel work.
Validate the running stack (exit code only, no output on success):
make compose-check # or: ./compose-check.sh
Checks wr-core via GET /health and runs each harness/heavy service with --help, requiring exit 0.
Configuration (env vars for docker compose)
Nothing is required to clean arbitrary text — the core service works out of the box:
Everything else is optional and lives in a .env file at the repo root. docker composeauto-loads .env and interpolates the ${VAR} references in compose.yaml from it (shell exports win over .env if both are set).
cp .env.example .env# then edit
docker compose up -d # picks up .env automatically
.env is gitignored (deny-by-default) — never commit it. For host-side CLI runs (rewrite_text.py, the skill), export the same file into the environment:
set -a; . ./.env; set +a; python3 service/scripts/rewrite_text.py /tmp/x.txt -o /tmp/x.rewritten.txt
Var
Reaches
Purpose
WATERMARKS_SERVER_API_KEY
wr-core (via compose environment)
Require Authorization: Bearer <key> on the HTTP API
HF_TOKEN
harness/heavy services
Hugging Face token for gated models
WATERMARKS_SERVICE_URL
client only (skill / curl)
Where to reach the service; default http://127.0.0.1:8765
Layer B is agent-orchestrated in the skill (it rewrites with its own model), so the WATERMARKS_REWRITE_* vars are only needed when driving rewrite_text.py directly.
inspect_image.py and clean_image.py can report a pixel-domain SynthID
confidence score when an external checkout of
aloshdenny/reverse-SynthID
is available. The scorer is not bundled: it is loaded at runtime from your
checkout, and its code remains under the upstream project's non-commercial
Research License.
Option 1: one-command bootstrap (no Docker)
SCRIPTS=service/scripts
# Clones upstream, creates a venv, and installs scorer-only dependencies."$SCRIPTS/setup_synthid.sh"# Score an image (default checkout: ~/reverse-SynthID).
REVERSE_SYNTHID_DIR=~/reverse-SynthID \
~/reverse-SynthID/.venv/bin/python "$SCRIPTS/score_synthid.py" shot.png
# Or surface the score from inspect / clean (same venv Python).
REVERSE_SYNTHID_DIR=~/reverse-SynthID \
~/reverse-SynthID/.venv/bin/python "$SCRIPTS/inspect_image.py" shot.png
setup_synthid.sh accepts --dir PATH, --ref REF, and --full (install the
full upstream requirements.txt, which adds torch/diffusers for the
upstream VAE bypass this project does not use).
On Windows use setup_synthid.ps1 (-Dir, -Ref, -Full), which creates the
venv at .venv\Scripts\ — the layout image_meta.py already looks for on
os.name == "nt".
Option 2: local Docker build
make docker-synthid-build
# Run unprivileged and with a read-only rootfs; the scorer only needs to read# /data and write to stdout/tmp.
docker run --rm \
--user "$(id -u):$(id -g)" \
--read-only --tmpfs /tmp \
-v "$(pwd):/data" \
watermarks-remover-synthid-scorer /data/shot.png
The image is built locally from the upstream source at build time. It is not
published, so it does not redistribute the upstream code.
V4 scoring uses artifacts/spectral_codebook_v4.npz from the upstream checkout
(~220 MB). This is detection/scoring only — it does not remove pixel
watermarks.
Optional CtrlRegen pixel removal
For pixel-domain image watermarks (SynthID-class, StegaStamp, Tree-Ring,
StableSignature), an optional external backend runs the CtrlRegen pipeline
(ControlNet + DINOv2 IP-Adapter controllable regeneration). The backend is
mertizci/noai-watermark, a
maintained reimplementation of the ICLR 2025
CtrlRegen method with automatic tiling.
The backend is not bundled and ships no LICENSE file, so it is treated as
all-rights-reserved: it is cloned at a pinned commit and loaded at runtime.
On Windows use setup_ctrlregen.ps1 (same flags as -Dir, -Ref, -Python);
the venv lands in .venv\Scripts\, which clean_image.py already resolves.
It picks the torch wheel index from the GPU's compute capability rather
than the CUDA version nvidia-smi prints — that number is the maximum the
driver supports, and drivers are backward compatible, so deriving the wheel
tag from it installs cu130 on a Pascal card whose kernels were dropped in
cu128. The script forces cu126 below compute capability 7.5 and then
verifies the result with torch.cuda.get_arch_list().
Order of operations: metadata strip first, then CtrlRegen pixel removal, then
an optional reverse-SynthID before/after score (when REVERSE_SYNTHID_DIR is
also set).
Strength is conservative by default (--ctrlregen-strength 0.25), because
higher strength removes more watermark but regenerates more of the image.
Documented presets: 0.15 minimal / 0.25 default / 0.35 balanced /
0.5 aggressive / 0.7 max (backend default is 0.5). --ctrlregen-steps
defaults to 50 (effective denoising steps ≈ steps × strength).
Image size (512×512 native limit)
CtrlRegen is a 512×512 Stable Diffusion 1.5 ControlNet. The backend resolves
this for arbitrary inputs, so no extra tiling is exposed here:
≤512 px: single pass — center-crop/resize to 512, regenerate, resize back.
>512 px: automatic overlapping tiling (512 px tiles, 192 px overlap),
width/height aligned to multiples of 8, then cosine-blended seams.
Either path: output is resized to the original size and color-matched to
the original image.
Very large images (e.g. 4K) produce many tiles, so runs scale with tile count
(slower and higher VRAM). Pre-downscale large inputs when practical; tile size
and overlap are hardcoded upstream and are not exposed as flags.
Compute, gated models, and verification
Expect ~10 GB of model downloads; a GPU is strongly recommended and CPU runs
are slow. Some upstream models are gated, so export HF_TOKEN (env only —
never argv). clean_ctrlregen.py refuses to auto-install dependencies; run
setup_ctrlregen.sh first.
There is no local detector for StegaStamp/Tree-Ring/StableSignature, so the
only local signal is the reverse-SynthID score (a surrogate). When available,
clean_image.py --remove-pixel ctrlregen reports that score before/after; the
official Google SynthID check remains the final authority.
Docker
make docker-ctrlregen-build
docker run --rm -e HF_TOKEN="$HF_TOKEN" \
--user "$(id -u):$(id -g)" \
-v "$(pwd):/data" \
watermarks-remover-ctrlregen /data/shot.png -o /data/shot.ctrlregen.png
Optional MarkLLM text-watermark verification
For controlled experiments, an optional external harness wraps
THU-BPM/MarkLLM (Apache-2.0) to
watermark test text and re-detect it after a Layer B rewrite — e.g. prove that
a KGW (Kirchenbauer, your "open-LLM" row) or SynthID-Text (Gemini row) mark
disappears under your rewrite. It is a verification harness, not an oracle:
MarkLLM detection is only valid against the same scheme config + keys used at
generation, and it cannot certify a vendor detector will fail.
The backend is not bundled. setup_markllm.sh clones upstream at a pinned
commit, creates a venv, and installs pinned deps (torch + transformers); the
scoring model (default facebook/opt-1.3b, Apache-2.0) downloads from Hugging
Face on first run.
SCRIPTS=service/scripts
# Bootstrap (clones upstream, creates ~/MarkLLM/.venv, installs deps)."$SCRIPTS/setup_markllm.sh"# Generate watermarked + unwatermarked sample text under the KGW scheme.
MARKLLM_DIR=~/MarkLLM \
~/MarkLLM/.venv/bin/python "$SCRIPTS/detect_text_watermark.py" watermark prompt.txt \
--scheme kgw -o wm.txt -o2 plain.txt
# Detect the scheme mark in a text file.
MARKLLM_DIR=~/MarkLLM \
~/MarkLLM/.venv/bin/python "$SCRIPTS/detect_text_watermark.py" detect wm.txt --scheme kgw --json
Verification around a Layer B rewrite: pass --markllm-scheme to
rewrite_text.py (with --markllm-dir), and it records the MarkLLM detection
before/after plus a cleared flag:
If the backend is unconfigured or its deps are missing, the rewrite proceeds
and the report notes verification was unavailable. A GPU is recommended; CPU
runs work but are slow, and the model download is a few GB.
Hardening knobs:
--offline on the adapter (or any MarkLLM run) loads the scoring model from
the Hugging Face cache only — zero network egress; fails fast if not cached.
Custom remote code is never executed (transformers trust_remote_code is
never enabled).
WATERMARKS_MARKLLM_RLIMIT_AS=<bytes> (env, POSIX) applies an address-space
limit to the MarkLLM subprocess spawned by rewrite_text.py. Off by default
because torch/CUDA usually needs large address spaces.
Config files are capped at 1 MiB; the upstream checkout and the base image
are pinned by SHA/digest.
Docker
make docker-markllm-build
docker run --rm --user "$(id -u):$(id -g)" -v "$(pwd):/data" \
watermarks-remover-markllm detect /data/wm.txt --scheme kgw --json
Optional MarkDiffusion image-watermark harness
For controlled experiments on images, an optional external harness wraps
THU-BPM/MarkDiffusion (Apache-2.0),
a generative watermarking toolkit for latent diffusion models (it embeds marks
— it does not remove them). We use it for three things:
Verification harness (like MarkLLM, but for images): watermark a test
image with a scheme, run removal, and re-detect with the same scheme config
— e.g. prove a Tree-Ring-class mark clears under your pipeline. It is a
verification harness, not an oracle: detection requires the generating
model (and keys for key-based schemes), so it cannot certify a vendor
detector will fail on an arbitrary image.
Optional pixel-removal engine: its DiffusionPurification regeneration
attack is exposed as clean_image.py --remove-pixel diffusion, an
alternative to CtrlRegen. It is blind regeneration (no ControlNet
conditioning), so it drifts image content more than CtrlRegen — conservative
strength default (0.3), treated as a fallback/comparison, never a
guarantee.
Local same-scheme detector for Tree-Ring-class marks, partially filling
the "no local detector for StegaStamp/Tree-Ring/StableSignature" gap (it
covers Tree-Ring/Ring-ID/Gaussian-Shading etc., not StegaStamp /
StableSignature / SynthID-media).
The backend is not bundled. setup_markdiffusion.sh creates a venv and
installs markdiffusion==1.0.2 from PyPI (pinned), with torch installed from
the right platform index; --checkout installs an editable clone at a pinned
commit instead. The Stable Diffusion model (default
huanzi05/stable-diffusion-2-1-base) downloads from Hugging Face on first run.
SCRIPTS=service/scripts
# Bootstrap (PyPI pin default; creates ~/markdiffusion/.venv, installs deps)."$SCRIPTS/setup_markdiffusion.sh"# 1. Generate a Tree-Ring watermarked image (+ unwatermarked control).echo"a red fox in snow" > /tmp/prompt.txt
MARKDIFFUSION_DIR=~/markdiffusion \
~/markdiffusion/.venv/bin/python "$SCRIPTS/markdiffusion_harness.py" watermark \
/tmp/prompt.txt -o wm.png -o2 plain.png --scheme tr --json
# 2. Remove with the DiffusionPurification regeneration attack.
MARKDIFFUSION_DIR=~/markdiffusion \
~/markdiffusion/.venv/bin/python "$SCRIPTS/markdiffusion_harness.py" purify \
wm.png -o wm.purified.png --purification-strength 0.3 --json
# 3. Re-detect with the SAME scheme config.
MARKDIFFUSION_DIR=~/markdiffusion \
~/markdiffusion/.venv/bin/python "$SCRIPTS/markdiffusion_harness.py" detect \
wm.purified.png --scheme tr --detector-type l1_distance --json
Or run purification as part of the normal image pipeline:
Hardening knobs mirror the MarkLLM harness: --offline loads the model from
the Hugging Face cache only (zero network egress, no remote code), HF_TOKEN
is env-only (never argv), algorithm configs are capped at 1 MiB, and the
subprocess gets the same higher resource caps as CtrlRegen.
Docker
make docker-markdiffusion-build
docker run --rm --user "$(id -u):$(id -g)" -v "$(pwd):/data" \
watermarks-remover-markdiffusion detect /data/wm.png --scheme tr --json
The image installs a CPU torch; CUDA users should run setup_markdiffusion.sh
on the host instead. Model downloads still hit the HF hub on first run.
Modern LLM watermarks often hide a signal in which tokens are chosen (generative / sampling bias), not only in invisible characters. Edit-based schemes inject Unicode or synonym rules. File schemes attach C2PA or generator metadata.
Layer A removes edit-based Unicode carriers (testable).
Layer B attacks sampling watermarks via heavy rewrite (best-effort; literature-standard attacks such as paraphrase / back-translation).
File cleaners strip C2PA/XMP/props from supported containers.
Until vendors ship public detectors and keys, no tool can honestly certify 'this fails the official check.' Reports must separate verifiable vs best-effort work.
Prefer a non-origin model for Layer B (do not rewrite Claude text with Claude if you are trying to avoid re-stamping).
Disclaimer: what removing a text watermark costs
Text watermarks live in the wording itself: the signal is spread across token choices, so nearly every sentence carries a little of it. Two consequences follow, and they are why Layer B is honestly described as best-effort rather than a magic eraser.
Removal means rewording, not restructuring. Shuffling paragraphs, changing headings, or light touch-ups barely move the signal. Stripping a statistical mark requires rewriting a substantial fraction of the text — sentence by sentence, not section by section.
Rewording degrades the copy. Any rewrite replaces the original word choices with the rewriting model's, which flattens tone, voice, and precision. On production copy (SEO, marketing, client work) that degradation is real and often visible to the people who care most about the writing. It is like taking text from a top-tier model and asking a less capable model to rewrite it from scratch: the result cannot exceed the rewrite model's ceiling.
Which leads to the honest full-circle question:
If the plan is to rewrite the text with a cheaper model anyway, why pay for a premium model in the first place? Generating directly with the cheaper model is simpler, cheaper, and produces the same — or better — end result.
Layer B makes sense when you specifically want the premium model's thinking and drafting and accept a rewrite pass to satisfy a hygiene or privacy requirement — not as a cheap route to mark-free text.
When to skip Layer B:
Quality matters more than hygiene: use the lossless path — Layer A Unicode scrub plus the file metadata cleaners — and keep the original prose.
Rewriting anyway: use a non-origin model (rewriting with the origin model can re-stamp the text), and remember residual risk remains — no tool can certify a vendor detector will fail.
File formats
Format
Inspect
Clean
PNG / JPEG / WebP
C2PA chunks / APP11 / RIFF C2PA, AI XMP hints
Drop metadata segments
SVG
<metadata>, XMP
Strip blocks
PDF
Byte/XMP + optional tools
exiftool then qpdf; degraded without either
DOCX
docProps / customXml
Scrub props, drop customXml
ODT
meta.xml
Drop generator / AI-ish meta
HTML
meta, JSON-LD, data-ai*
Strip tags/attrs
Markdown
YAML frontmatter AI keys
Drop keys + Layer A body
Why PDF needs qpdf, not just exiftool
ExifTool writes PDFs incrementally. exiftool -all= appends a
%BeginExifToolUpdate block that frees the Info object and drops /Info from
the trailer — but the original metadata bytes stay in the file verbatim, and
exiftool itself can undo the edit with -PDF-update:all=. The command exits
0, viewers show no metadata, and the file gets larger, which is the tell.
For a provenance-stripping tool that is a silent leak, so clean_pdf follows
the exiftool pass with qpdf --linearize, which re-serializes the document
from its object graph and drops the now-unreferenced objects. Without qpdf
installed the clean still runs, but it says so:
warning: exiftool PDF edits are incremental — the original metadata bytes
remain recoverable; install qpdf for a structural rewrite
Pixel-domain watermark removal is now available as an optional external CtrlRegen backend (see above); it is a regenerating remover, not a guarantee. C2PA soft binding (in-content watermark that can re-link a remote Content Credentials manifest after metadata is stripped) remains out of scope. Stripping hard-bound C2PA does not clear those channels.
Residual risk after a clean
This tool reports verifiable removals (Unicode counts, metadata actions) and best-effort Layer B rewrites. It cannot certify that vendor detectors will fail.
To check residual signals yourself (optional, external):
Responsible use: This project is for content you own or are authorized to process. Users must adhere to local regulations and use it responsibly. The developers disclaim any liability for potential misuse by users.
Ecosystem
Third-party projects that wrap or complement this repository, listed for discoverability only. They are not maintained, endorsed, or supported by this project. This project does not review their code, vouch for their behavior or guarantees, or take responsibility for anything you install or run from this list. Each project is governed by its own license, maintainers, and documentation — read those before using it.
MetaClean — desktop GUI
MetaClean is an independent MIT-licensed Rust/Tauri desktop application (Windows, macOS, Linux) providing a packaged native GUI for drag-and-drop metadata cleaning, with a system tray and Explorer integration. It is a separate codebase: it does not call this repository's Python service, and its supported formats and cleaning guarantees differ from this project's. See its README for details.
Adding a project
To register a project here, open a PR adding a short entry — project name, what it wraps or adds, and a link to its own repository. Keep entries brief and factual; do not claim compatibility with, or endorsement by, this project.
Tests
python3 -m venv .venv && .venv/bin/pip install pytest
.venv/bin/python -m pytest # or: make test
make smoke # quick CLI smoke on fixtures
Changelog
Unreleased
Fix inspect missing Layer A carriers in markdown/HTML: inspect_container never scanned the document body, so a .md or .html file holding invisible Unicode came back suspicious: false while clean_container went on to strip it — the same bytes saved as .txt were correctly flagged. The scan now runs for exactly the formats clean_container scrubs, so inspect predicts clean. Container reports gain suspicious_total (the same key TextInspectReport uses, so the HTTP suspicious flag and the inspect_file exit code pick it up) and layer_a_hits
v0.5.0 — service & Docker distribution, HTTP API, and verification harnesses
Service / Docker distribution
Skill/service split: the skill (skills/remove-ai-marks/) is now a code-free remote client over HTTP; all implementation moved to service/scripts/ and runs behind server.py, a stdlib HTTP entrypoint (/health, /inspect, /clean, /capabilities)
HTTP service: service/scripts/server.py exposes the cleaning pipeline over JSON/base64; hardening mirrors the CLIs (size caps, binary guard, atomic writes, loopback default, optional WATERMARKS_SERVER_API_KEY bearer auth)
OpenAPI: GET /openapi.json serves a dynamically generated OpenAPI 3.0.3 spec (built from the route table + live config, so it never drifts from the real endpoints); CI validates it with openapi-spec-validator
Core Docker image (service/Dockerfile): full cleaning service with exiftool / qpdf / c2patool preinstalled; any CLI stays runnable by overriding the command
Docker / compose: compose.yaml brings up the whole infra (core always; markllm / markdiffusion behind profile: harness; ctrlregen / synthid behind profile: heavy as local-only builds); services are prefixed wr-; harness/heavy services default to command: ["--help"] so docker compose up --profile harness --profile heavy exits cleanly (one-shot CLIs are run with docker compose run); new make compose-check / compose-check.sh validates the running stack (exit code only)
GHCR publishing: .github/workflows/release-images.yml publishes core, markllm, markdiffusion images on v* tags; ctrlregen / synthid are never published (upstream licensing)
Env configuration: .env.example + service configuration guide; docker compose auto-loads .env; .env is gitignored (deny-by-default)
Repo hygiene: .gitignore and service/.dockerignore are now deny-by-default — only explicitly allowed paths can be committed or sent in a build context (image contexts only ship service/scripts/, which is all the Dockerfiles COPY)
Tests: tests/test_http_server.py (13 cases) for the HTTP service; all suites re-pointed at service/scripts/
MarkDiffusion image-watermark harness (optional)
New optional harness (external THU-BPM/MarkDiffusion, Apache-2.0): markdiffusion_harness.py with watermark / detect / purify subcommands for nine image schemes (Tree-Ring, Ring-ID, ROBIN, WIND, SFW, Gaussian-Shading, GaussMarker, PRC, SEAL)
clean_image.py --remove-pixel diffusion runs the MarkDiffusion DiffusionPurification regeneration attack as an alternative pixel-removal engine (conservative strength 0.3 default)
Mock-based tests (tests/test_markdiffusion_harness.py) — no torch in CI; references/markdiffusion.md reference doc
Docs: same-scheme-only verification caveat (not a vendor-detector oracle) and blind-regeneration drift caveat in README, SKILL.md, removal-matrix.md, markdiffusion.md
MarkLLM text-watermark harness (optional)
New optional harness (external THU-BPM/MarkLLM checkout, Apache-2.0): detect_text_watermark.py with detect / watermark subcommands for KGW and SynthID schemes
rewrite_text.py --markllm-scheme runs before/after detection around a Layer B rewrite (env-gated; reports cleared)
Hardening: --offline cache-only model loading (no HF egress, no remote code), 1 MiB config cap, optional WATERMARKS_MARKLLM_RLIMIT_AS on the rewrite subprocess, pinned torch in the Dockerfile, and clone-SHA verification in Dockerfile.markllm
Mock-based tests (tests/test_markllm_detect.py, 21 cases) — no torch in CI; verification-harness caveat (same-config-only, not a vendor-detector oracle) documented in README, SKILL.md, removal-matrix.md, vendor-notes.md
Fixes and polish
Layer B: rewrite_text.py now sends reasoning_effort: "none" by default for openai-compatible backends (--reasoning-effort / WATERMARKS_REWRITE_REASONING_EFFORT; off omits it). Reasoning models like deepseek-v4-flash otherwise burn ~100s of chain-of-thought on a one-line rewrite (9,894 vs 12 completion tokens)
Fix markllm image build: requirements-markllm.txt pinned tokenizers==0.23.1, which conflicts with transformers==5.15.0 (caps tokenizers<=0.23.0; no 0.23.0 release exists) — now pinned tokenizers==0.22.2; torch moved to the CPU wheel index (torch==2.13.0.*) so the image is CPU-only like Dockerfile.markdiffusion
Fix ctrlregen image build: the 2023-era research pins (safetensors==0.4.3, transformers==4.37.2 → tokenizers<0.19) ship no Python 3.14 wheels, so the base image is now python:3.11-slim (digest-pinned, multi-arch)
Fix harness images at runtime: Dockerfile.markllm and Dockerfile.markdiffusion never copied common.py into /app (pre-existing bug) — added
WebP: stdlib-only inspection and metadata cleaning for RIFF C2PA, XMP, EXIF, and ICC profile chunks (#37)
Filename sanitization: HTTP service refuses unsafe client-supplied output names
Fix markdown frontmatter cleaner crashing on and leaking nested AI keys (#25)
Text tools refuse binary input; --force-text overrides (#24)
--json no longer suppresses the residual-signal exit code (#30)
inspect_file prints the filename in its output (#50)
Preserve mixed-case CMS generator meta tags (#42)
Preserve load-bearing script invisibles, strip PUA in Layer A (#38, #52)
Preserve script joiners, flag emoji, and Arabic Cf marks in Layer A (#28)
Harden website audit against SSRF and gzip bombs (#49)
SECURITY.md only references the private advisories channel (#51)
Windows: PowerShell ports of the setup bootstraps (#40)
Docs: add stars/forks shields and drop star-history chart; add MarkLLM to README references; pull request template; plan for Docker CLI + API deployment
v0.4.0 — pixel removal, finding confidence, Windows & false-positive fixes
Optional pixel-domain watermark removal via an external mertizci/noai-watermark checkout: clean_ctrlregen.py adapter + setup_ctrlregen.sh bootstrap (pinned commit, sparse checkout, venv, SHA verification), plus Dockerfile.ctrlregen and make bootstrap-ctrlregen / docker-ctrlregen-build / smoke-ctrlregen
clean_image.py --remove-pixel ctrlregen runs metadata strip → CtrlRegen removal → optional reverse-SynthID before/after score; inspect_image.py hints at the flag on a high SynthID score
Conservative default strength 0.25 (presets 0.15/0.25/0.35/0.5/0.7); the 512×512-native pipeline is auto-tiled by the backend for larger images; the torch subprocess gets higher env-overridable resource caps
Backend is never bundled: noai-watermark ships no LICENSE file (treated as all-rights-reserved), and its auto-install/restart code paths are bypassed by using CtrlRegenEngine directly
Finding confidence and aggregate audits
Findings are now classified confirmed / probable / informational / likely_false_positive, exposed in text/image/container JSON and human reports
New audit_dir.py (recursive tree) and audit_website.py (sitemap discovery + crawl) aggregate reports; documented in SKILL.md
False-positive fixes
DOCX: scan only docProps/customXml, not the visible body (#14)
Text Layer A: preserve emoji VS16/ZWJ after an emoji base; new --strip-emoji-glue paranoid flag (#22)
HTML: treat CMS generator tags as informational, not AI metadata (#13)
PDF: exclude stream payloads from the AI-marker byte scan (#13)
Safe, atomic output writes: every cleaner now writes via temp-file + atomic rename (safe_write_bytes / safe_write_text), refuses symlinked destinations, and creates .bak backups through the same safe path — pre-placed symlinks (e.g. in /tmp or download dirs) can no longer redirect a clean write onto an arbitrary file
rewrite_text.py HTTP client hardening: redirects are refused outright, so an API key in the Authorization header can never be re-sent to an unvalidated host; non-loopback endpoints are denied by default (opt in with --allow-remote or WATERMARKS_REWRITE_ALLOW_REMOTE=1); only http(s) schemes are accepted; --api-key was removed — keys are env-only via WATERMARKS_REWRITE_API_KEY
Resource caps: default max input 1 GiB → 256 MiB, new 64 MiB stdin cap, DOCX/ODT zip budget 512 MiB → 128 MiB, and RLIMIT_AS/RLIMIT_FSIZE applied to exiftool/c2patool/SynthID subprocesses (all caps env-overridable)
Supply chain: CI actions SHA-pinned with permissions: contents: read, pinned dev deps (requirements-dev.txt), a pip-audit step, and a new CodeQL workflow; the Docker image now runs as an unprivileged user with pip pinned
Scorer deps: Pillow bumped 10.4.0 → 12.3.0 (24 known CVEs); API usage verified against the pinned upstream commit
Tests: 18 new security regression tests (60 total, all passing)
v0.3.1 — stronger Layer B statistical-watermark rewrite
rewrite_text.py default paraphrase now performs an explicit word-choice + syntax attack (clause order, connectors, transition words, sentence boundaries, function words) rather than a generic rewrite
New --strength humanize: zero-shot "write like a human" pass targeting formulaic AI-style phrasing
New --strength code: rewrites comments, docstrings, and string literals, and renames local identifiers while preserving behavior and public API names
Structural pass now emits "natural, varied human prose" instead of AI-typical "clear professional style"
New --temperature (default 0.9) for both Ollama and OpenAI-compatible backends
New --candidates N: generates N rewrites and selects the most lexically diverged (bigram Jaccard distance) with a length-drift guard
Stronger model hygiene: prefer local open-weight models and avoid any known-watermarked vendor, not just the suspected origin
Residual-risk reporting now distinguishes short/highly predictable text (lower risk) from long, high-entropy prose (higher risk)
Docs updated in SKILL.md, removal-matrix.md, and vendor-notes.md; tests cover new prompts, divergence scoring, and candidate selection
Optional pixel-domain SynthID scorer via an external aloshdenny/reverse-SynthID checkout (score_synthid.py); surfaced in inspect_image.py / clean_image.py with REVERSE_SYNTHID_DIR or --synthid-dir
setup_synthid.sh bootstrap (scorer-only dependencies; --full installs upstream requirements); Dockerfile.synthid plus make docker-synthid-build / docker-synthid-help
Makefile smoke-synthid and bootstrap-synthid targets
Tests for the scorer adapter, CLI unavailable path, JSON parsing, and runtime errors
Docs: detection/scoring only (no pixel removal); upstream code is not bundled and remains under its non-commercial Research License
image_meta.py: has_manifest no longer flags Error: No claim found / No JUMBF data found as a manifest (operator-precedence bug: the negative markers now veto every positive branch)
New tests/test_c2patool_report.py (4 cases: no claim, no JUMBF, genuine manifest, tool absent)
Docs: fixed c2patool links (repo moved to contentauth/c2pa-rs); added a disclaimer on the quality cost of text-watermark removal