guillaumemeyer/watermarks-remover
A privacy-first app that strips AI watermarks from content you own.
About guillaumemeyer/watermarks-remover
guillaumemeyer/watermarks-remover is an open-source project on GitHub, mainly written in Python. A privacy-first app that strips AI watermarks from content you own. It currently holds 25,893 stars and 2,947 forks with 33 open issues, and was last pushed on 2026-11-01 (repository created 2026-08-11).
Project Overview
AI Homed tracks it on the Today's Trending board.
GitHub Repository Details
README
_ _ _ ____ ___ ____ ____ _ _ ____ ____ _ _ ____ ____ ____ _ _ ____ _ _ ____ ____
| | | |__| | |___ |__/ |\/| |__| |__/ |_/ [__ __ |__/ |___ |\/| | | | | |___ |__/
|_|_| | | | |___ | \ | | | | | \ | \_ ___] | \ |___ | | |__| \/ |___ | \
watermarks-remover
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.
| Layer | Target | How |
| --- | --- | --- |
| A | Invisible Unicode, exotic spaces, bidi, tag chars | Deterministic Python scripts |
| B | Statistical (token-sampling) text watermarks | Agent rewrite + optional rewrite_text.py hook |
| Files | C2PA / EXIF / XMP / doc props | PNG, JPEG, WebP, AVIF, HEIC, BMP, GIF, TIFF, SVG, PDF, DOCX, XLSX, PPTX, EPUB, ODT, HTML, Markdown, MP4/MOV/M4A/M4V, WAV, MP3, FLAC |
Vendors / ecosystems (class-level): Claude, Gemini / SynthID-Text, OpenAI provenance surfaces, open-LLM Kirchenbauer-style (green-list) and keyed-Gumbel / EXP (Aaronson) marks.
Latest release: v0.7.0
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.
In Claude Code, the fastest route is the bundled plugin marketplace — no clone, and it updates in place. Everywhere else, one installer covers every supported host (Python 3.10+ stdlib, no dependencies):
python3 install_skill.py --skill remove-ai-marks --target claude-code
| Host | Target | Lands in |
| --- | --- | --- |
| Claude Code (personal) | --target claude-code | ~/.claude/skills/ (honors CLAUDE_CONFIG_DIR) |
| Claude Code (project) | --target claude-project --project-dir PATH | PATH/.claude/skills/ |
| Codex (personal) | --target codex | ~/.agents/skills/ |
| Codex (project) | --target codex-project --project-dir PATH | PATH/.agents/skills/ |
| Cowork, claude.ai, cloud sessions, routines | --target cowork | dist/.zip to upload under Customize → Skills |
| Cursor | --target cursor (default) | ~/.cursor/skills/ |
Shipped skills: remove-ai-marks (full, service-backed) and
clean-user-facing-text (text only, self-contained). --list prints them.
Existing installations are preserved unless you pass --force; replacement is
staged first and the previous install is kept as a uniquely named backup.
--link symlinks this checkout instead of copying, so edits are picked up
live. On Windows, use py install_skill.py ...; the install-skill.sh wrapper
is provided for macOS/Linux shells.
Before writing anything, the installer validates the skill against the
Agent Skills packaging rules that claude.ai uploads
and the Skills API enforce: spec-only frontmatter (name, description,
license, compatibility, metadata, allowed-tools), a lowercase hyphenated
name of at most 64 characters matching the directory, a non-empty
description of at most 1024 characters. The Cowork bundle additionally has
to fit the 30 MB upload limit, which the packager enforces.
Codex terminal and desktop
Install either shipped skill for your user, or install one into a particular project:
python3 install_skill.py --skill remove-ai-marks --target codex
python3 install_skill.py --skill clean-user-facing-text --target codex
python3 install_skill.py --skill remove-ai-marks --target codex-project --project-dir /path/to/project
On Windows, use py in place of python3. Open the project in Codex. In the
terminal, use /skills or type $ to select a skill; in the desktop app,
select or mention the skill in your prompt. Restart Codex if the new skill does
not appear. The project install is available when Codex runs in that project.
For remove-ai-marks, start the existing HTTP service from this repository
with make serve before using the skill (or on Windows without make, run
py service/scripts/server.py --host 127.0.0.1 --port 8765). It uses
WATERMARKS_SERVICE_URL (default http://127.0.0.1:8765), checks /health,
and calls the existing /inspect and /clean endpoints. If the service
requires an API key, set WATERMARKS_SERVER_API_KEY for the client.
clean-user-facing-text is
self-contained and does not need the service.
Automatic cleaning via hook (deterministic)
A skill is an instruction: the model decides whether to invoke it, and the model is the thing producing the marks. A hook is executed by the harness on every matching tool call, cooperation not required. That makes the hook the deterministic half of this workflow.
The plugin registers a PostToolUse hook on Write|Edit|MultiEdit|NotebookEdit
that runs service/scripts/hook_written_file.py
against the file the agent just wrote. Two modes, matching the pre-commit
convention of check-by-default:
| Mode | Behaviour |
| --- | --- |
| check (default) | Reports provenance marks, leaves the file alone. Findings go to the model (exit 2), so it can offer to clean them. |
| clean | Strips the marks in place, then tells the model the file on disk changed. |
Set the mode from the plugin's settings (Hook mode in /plugin manage,
read by the hook as CLAUDE_PLUGIN_OPTION_HOOK_MODE), or with
WATERMARKS_HOOK_MODE=clean in the environment. The hook command deliberately
does not interpolate ${user_config.hook_mode}: Claude Code refuses to run
a hook that references an option the user has never opened /plugin manage to
set — a declared default does not satisfy it — so interpolating it would mean
the hook silently never runs on a fresh install. Detection reuses audit_lib's
scan_file / is_actionable, so the hook, the pre-commit gate, and the CI
SARIF export agree on what counts as actionable; cleaning shells out to
clean_file.py, so no cleaning logic is duplicated. clean mode writes to a
sibling temp file and swaps only on a real difference, so files that were
already clean keep their mtime and don't retrigger file watchers.
Without the plugin, wire it in ~/.claude/settings.json (or a project
.claude/settings.json) yourself:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["/path/to/watermarks-remover/service/scripts/hook_written_file.py",
"--mode", "check"],
"timeout": 30
}
]
}
]
}
}
On Windows, replace python3 with py.
What a hook cannot do. No hook can rewrite the assistant's chat message
before you read it. Claude Code's Stop hook receives last_assistant_message
read-only, and there is no pre-send filter for final responses — the same limit
this project already documents for Cursor rules. So the deterministic guarantee
covers files the agent writes, plus the
pre-commit gate for anything on its way into git. Text that
only ever exists in the chat transcript still depends on the skill workflow,
which is model-instruction-based and therefore best-effort.
Claude Code plugin (marketplace)
The repository is also a Claude Code plugin and a single-plugin
marketplace (.claude-plugin/), so both skills install and update in two
commands, no clone or script required:
/plugin marketplace add guillaumemeyer/watermarks-remover
/plugin install watermarks-remover@watermarks-remover
The skills then load namespaced: /watermarks-remover:remove-ai-marks and
/watermarks-remover:clean-user-facing-text (the bare /remove-ai-marks also
works when nothing else claims the name). `/plugin marketplace update
watermarks-remover` pulls later versions. The same works from the CLI with
claude plugin marketplace add … / claude plugin install …, and from a local
checkout by passing a path instead of owner/repo.
Maintainers: make plugin-validate runs claude plugin validate . --strict
against both manifests; tests/test_plugin_manifest.py covers the same files
without needing the CLI.
Claude Code
# Personal — available in all your projects
python3 install_skill.py --skill remove-ai-marks --target claude-code
or: make install-claude-code-skill
Project — commit .claude/skills/ to share it with the repo
python3 install_skill.py --skill remove-ai-marks --target claude-project \
--project-dir /path/to/project
or: make install-claude-project-skill PROJECT=/path/to/project
Claude Code picks up personal and project skills without a restart; /skills
lists what it loaded. Invoke with /remove-ai-marks or ask to “strip AI
watermarks / C2PA / Claude marks / SynthID-class text.” A project install is
also what cloud sessions
read, since they clone the repository and load its .claude/skills/.
Cowork (and claude.ai, cloud sessions, routines)
Cowork sessions do not read ~/.claude/skills on your machine — they load
the skills enabled for your claude.ai account, synced when the session starts.
So install there by uploading a bundle:
python3 install_skill.py --skill remove-ai-marks --target cowork
writes dist/remove-ai-marks.zip (make package-cowork-skill)
Then, in the Claude Desktop app, open Customize → Skills → Add and upload
the zip (the same skill settings on claude.ai work too). The bundle is
reproducible and contains a single top-level remove-ai-marks/ directory with
SKILL.md at its root, which is the layout the upload expects.
Service reachability matters more here than in a local install: the skill is a
thin HTTP client, so the session must be able to reach WATERMARKS_SERVICE_URL.
Cowork sessions that run locally on your machine reach a local make serve;
cloud sessions and routines run remotely and need a service URL reachable from
there (and WATERMARKS_SERVER_API_KEY set on it). If you want a skill with no
service at all, upload clean-user-facing-text instead — it is text-only and
ships its own scripts:
python3 install_skill.py --skill clean-user-facing-text --target cowork
Grok
# Grok Build / project-local
mkdir -p .grok/skills
ln -sfn "$(pwd)/skills/remove-ai-marks" .grok/skills/remove-ai-marks
User-global Grok
mkdir -p ~/.grok/skills
ln -sfn "$(pwd)/skills/remove-ai-marks" ~/.grok/skills/remove-ai-marks
Optional text-only skill
skills/clean-user-facing-text/ is a
self-contained skill for authorized manuscripts, documentation, and web
copy. It excludes image, C2PA, service, and external-model tooling, and runs
its own vendored Layer A scripts instead of calling the service.
python3 install_skill.py --skill clean-user-facing-text --target claude-code
python3 install_skill.py --skill clean-user-facing-text --target cursor
Skill invocation is model-selected. Projects that explicitly adopt this workflow in Cursor can also copy the optional rule:
mkdir -p /path/to/project/.cursor/rules
cp integrations/cursor/clean-user-facing-text.mdc \
/path/to/project/.cursor/rules/clean-user-facing-text.mdc
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
Windows (no Docker)
See docs/windows-autostart.md for auto-starting the service at Windows login without Docker.
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):
| Tool | Role |
| --- | --- |
| c2patool | Inspect C2PA manifests |
| exiftool | Residual metadata strip (esp. PDF) |
| qpdf | 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 --tactic 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.
Unrecognized formats are never auto-cleaned
classify() labels bytes that match no supported text, image or container
format as unknown — it no longer falls back to "text". In auto mode
clean_file.py refuses such files (exit 2, no output written) instead of
decoding them as UTF-8 and writing back mangled bytes; --as text or
--force-text are the explicit opt-ins. inspect_file.py reports the file
as unknown (exit 0), and the HTTP service answers /inspect with
kind: "unknown" but rejects /clean of unknown formats (400 — send a
filename with a known extension, e.g. notes.txt).
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:
| Method | Path | Body | Returns |
| --- | --- | --- | --- |
| GET | /health | — | {"ok": true, "version": ...} |
| GET | /capabilities | — | optional tools / backends usable (each tool is version-probed, not just found on PATH) |
| GET | /openapi.json | — | dynamically generated OpenAPI 3.0.3 spec |
| POST | /inspect | {"file": "", "name": "notes.md"} | {"ok", "kind", "suspicious", "report"} |
| POST | /detect | {"file": "", "name": "notes.txt"} | {"ok", "kind", "detections": [...]} |
| POST | /clean | {"file": "", "name": "notes.md", "options": {...}} | {"ok", "kind", "cleaned": "", "report"} |
| POST | /watermark | {"text": "...", "keys": [118, 504, ...], "options": {...}} or {"file": "", ...} | {"ok", "kind", "watermarked_text", "report": {"scheme_used", ...}} |
| POST | /inspect/batch | {"files": [{"file": "", "name": "notes.md"}, ...]} | {"ok", "results": [{"name", "ok", "kind", "suspicious", "report"}, ...]} |
| POST | /detect/batch | {"files": [{"file": "", "name": "notes.txt"}, ...]} | {"ok", "results": [{"name", "ok", "kind", "detections", "report"}, ...]} |
| POST | /clean/batch | {"files": [{"file": "", "name": "notes.md", "options": {...}}, ...]} | {"ok", "results": [{"name", "ok", "kind", "cleaned", "report"}, ...]} |
| POST | /watermark/batch | {"files": [{"text": "...", "keys": [...]}, {"file": ""}, ...]} | {"ok", "results": [{"name", "ok", "kind", "watermarked_text", "report": {"scheme_used", ...}}, ...]} |
Batch endpoints loop the same per-file pipeline as /inspect, /detect, /clean, and /watermark, capped at WATERMARKS_MAX_BATCH_FILES files per request (default 50). A malformed entry (bad base64, unknown option, unrecognized format) surfaces as that entry's "ok": false with an "error" string — it never aborts the rest of the batch.
WM="http://127.0.0.1:8765"
curl -s "$WM/health" # {"ok": true, "version": "..."}
curl -s "$WM/openapi.json" # machine-readable OpenAPI 3.0.3 contract
curl -s -X POST "$WM/clean" -H 'Content-Type: application/json' \
-d "{\"file\": \"$(base64 < notes.md | tr -d '\n')\", \"name\": \"notes.md\"}"
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 on every request. Loopback-only bind by default (--host to override); intended for a trusted network.
Watermark detection (/detect and detect_before / detect_after)
Detection is a separate step from cleaning — the service never calls vendor APIs unless you ask it to:
POST /detectruns the configured watermark detectors on a file.
/inspectaccepts an opt-in"detect": trueflag that appends
suspicious).
/cleanaccepts"detect_before"/"detect_after"options to
/cleanruns the Layer B text rewrite after Layer A by default (it
"strategy" option (an ordered
tactic@intensity list, e.g. "paraphrase@0.8,mlm@0.2") overrides the
default from the strategy config file (see below). When the rewrite
backend/model for a step isn't configured, /clean returns a 400.
Text detectors (see /capabilities → text_detectors):
Text detectors (see /capabilities → text_detectors):
| Detector | Activated by | Notes |
| --- | --- | --- |
| markllm | MARKLLM_DIR (host checkout) | Research harness (KGW / SynthID schemes), same-config-only — not a vendor oracle. |
| gumbel | WATERMARKS_GUMBEL_KEY | Model-free same-key replay of the keyed-Gumbel (Aaronson EXP) scheme (see detect_gumbel.py), stdlib-only — self-hosted engines such as arbi-serve; same-key-only, not a vendor oracle. |
| claude-text | — (placeholder) | Anthropic has announced a watermark detection API; this seam activates when it ships. |
Image scoring: when WATERMARKS_SYNTHID_SCORER_URL is set, the service
scores images through the wr-synthid-score sidecar (heavy profile); with a
local REVERSE_SYNTHID_DIR it uses the checkout directly. Detection is
fail-soft: unconfigured, timed-out, or errored detectors report
{"available": false, "error": ...} and never block cleaning.
Watermark generation (/watermark and /watermark/batch)
Generates watermarked text for benchmark evaluation and round-trip testing.
When WATERMARKS_SYNTHID_TEXT_URL is set, the service delegates generation to the
wr-synthid-text sidecar (harness profile); with a local MARKLLM_DIR it uses the
checkout directly. Like detection, generation is fail-soft: an unconfigured generator
reports {"ok": false, "error": ...}.
Docker / compose
Published images (GHCR):
| Image tag | Contents | Published? |
| --- | --- | --- |
| ghcr.io/guillaumemeyer/watermarks-remover: / :latest | Core HTTP service + all cleaners + exiftool / qpdf / c2patool | Yes |
| …:markllm- / :markllm-latest | MarkLLM text-watermark harness (Apache-2.0 upstream) | Yes |
| …:markdiffusion- / :markdiffusion-latest | MarkDiffusion image harness (Apache-2.0 upstream) | Yes |
| watermarks-remover-ctrlregen:local | 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 (CLI scorer + optional wr-synthid-score HTTP sidecar under the heavy profile) |
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 / wr-synthid-text sidecar
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. Persistent services run as background daemons (wr-core and the wr-synthid-text sidecar under the harness profile). The remaining harness/heavy services are one-shot CLIs — invoke with docker compose run --rm … 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)
Text cleaning requires Layer B configuration — the Layer B rewrite is a
required step for POST /clean on text, so the core service needs the rewrite
backend set up, or text cleaning returns HTTP 400. Image/container metadata
cleaning works out of the box. For text you must configure the Layer B strategy
dependencies: transformers + roberta-large (for the default mlm step) and
the WATERMARKS_REWRITE_* LLM config (for the paraphrase step):
```bash ech