Merge branch 'main' into refactor/format-dispatch

This commit is contained in:
Guillaume Meyer (The Opinionated Man)
2026-08-14 10:58:18 -07:00
committed by GitHub
11 changed files with 1290 additions and 3 deletions
+69
View File
@@ -0,0 +1,69 @@
# Optional local Docker image for the MarkLLM text-watermark harness.
#
# Build from the repository root:
# docker build -f Dockerfile.markllm -t watermarks-remover-markllm .
#
# The upstream code is fetched from source at build time and is NOT
# redistributed by this repository. Upstream is Apache-2.0.
#
# Vendored fork hardening:
# - base image pinned by digest (no moving tag drift)
# - upstream checkout pinned to a commit SHA (no moving branch)
# - deps pinned exactly in requirements-markllm.txt
# - pip itself pinned (no unpinned bootstrap step)
# - runs as an unprivileged user (a parser bug in a crafted file can no
# longer write files as root inside the container)
# Pinned upstream commit (2026-07-10). Keep in sync with setup_markllm.sh.
ARG MARKLLM_REF=c45ddc40f7b761beabe55a1b8dc4690e531d1c6d
# python:3.14-slim linux/amd64 digest.
FROM python:3.14-slim@sha256:ce40764625a4ff50df3548277632e7f96c4e77fe75fa848aae9885476e7df5a4
ARG MARKLLM_REF
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
libgl1 \
libglib2.0-0 \
passwd \
&& rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 --filter=blob:none --sparse \
https://github.com/THU-BPM/MarkLLM.git /opt/markllm \
&& cd /opt/markllm \
&& git fetch --depth 1 origin "${MARKLLM_REF}" \
&& git checkout --detach "${MARKLLM_REF}" \
&& git sparse-checkout set --no-cone \
'/watermark/' \
'/config/' \
'/utils/' \
'/exceptions/' \
'/evaluation/dataset.py' \
'/LICENSE' \
'/README.md' \
&& test "$(git -C /opt/markllm rev-parse HEAD)" = "${MARKLLM_REF}"
COPY skills/remove-ai-marks/scripts/requirements-markllm.txt /app/requirements-markllm.txt
COPY skills/remove-ai-marks/scripts/detect_text_watermark.py /app/detect_text_watermark.py
# torch is pinned (with everything else) in requirements-markllm.txt; there is
# no separate unpinned install step. No GPU wheel index inside the image —
# CUDA users should run setup_markllm.sh on the host instead.
RUN python3 -m pip install --no-cache-dir "pip==26.2.1" \
&& python3 -m pip install --no-cache-dir -r /app/requirements-markllm.txt
# Unprivileged runtime user. The harness only reads input files and writes to
# stdout, so nothing under /opt, /app, or the mounted data dir needs root.
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin markllm
USER markllm
ENV MARKLLM_DIR=/opt/markllm \
HOME=/home/markllm \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
HF_HOME=/home/markllm/.cache/huggingface
WORKDIR /app
ENTRYPOINT ["python3", "/app/detect_text_watermark.py"]
+18 -1
View File
@@ -1,5 +1,6 @@
.PHONY: test smoke smoke-synthid bootstrap-synthid docker-synthid-build docker-synthid-help \
smoke-ctrlregen bootstrap-ctrlregen docker-ctrlregen-build docker-ctrlregen-help install-skill clean
smoke-ctrlregen bootstrap-ctrlregen docker-ctrlregen-build docker-ctrlregen-help \
smoke-markllm bootstrap-markllm docker-markllm-build docker-markllm-help install-skill clean
SCRIPTS := skills/remove-ai-marks/scripts
PYTHON ?= $(shell if [ -x .venv/bin/python ]; then echo .venv/bin/python; else echo python3; fi)
@@ -49,6 +50,22 @@ docker-ctrlregen-build:
docker-ctrlregen-help:
docker run --rm watermarks-remover-ctrlregen --help
smoke-markllm:
@if [ -z "$(MARKLLM_DIR)" ]; then \
echo "smoke-markllm skipped (set MARKLLM_DIR)"; \
else \
$(PYTHON) $(SCRIPTS)/detect_text_watermark.py --help >/dev/null && echo "detect_text_watermark adapter present"; \
fi
bootstrap-markllm:
./skills/remove-ai-marks/scripts/setup_markllm.sh
docker-markllm-build:
docker build -f Dockerfile.markllm -t watermarks-remover-markllm .
docker-markllm-help:
docker run --rm watermarks-remover-markllm --help
install-skill:
mkdir -p $(HOME)/.grok/skills
ln -sfn $(CURDIR)/skills/remove-ai-marks $(HOME)/.grok/skills/remove-ai-marks
+72
View File
@@ -241,6 +241,72 @@ docker run --rm -e HF_TOKEN="$HF_TOKEN" \
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`](https://github.com/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.
```bash
SCRIPTS=skills/remove-ai-marks/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:
```bash
export WATERMARKS_REWRITE_BACKEND=ollama WATERMARKS_REWRITE_MODEL=llama3.2
MARKLLM_DIR=~/MarkLLM \
python3 "$SCRIPTS/rewrite_text.py" wm.txt -o wm.rewritten.txt \
--markllm-scheme kgw --markllm-dir "$HOME/MarkLLM" --json-stats
```
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
```bash
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
```
## Coverage matrix
| Channel | Claude | Gemini/SynthID | OpenAI | Open-LLM |
@@ -369,6 +435,12 @@ make smoke # quick CLI smoke on fixtures
### Unreleased
- Add stdlib-only WebP inspection and metadata cleaning for RIFF `C2PA`, XMP, EXIF, and ICC profile chunks
- New optional MarkLLM 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`)
- `setup_markllm.sh` bootstrap + `requirements-markllm.txt` (pinned deps) + `Dockerfile.markllm` and Makefile `bootstrap-markllm` / `smoke-markllm` / `docker-markllm-build` / `docker-markllm-help`
- Mock-based tests (`tests/test_markllm_detect.py`, 21 cases) — no torch in CI
- Docs: verification-harness caveat (same-config-only, not a vendor-detector oracle) in README, SKILL.md, `removal-matrix.md`, `vendor-notes.md`
- 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`
### [v0.4.0](https://github.com/guillaumemeyer/watermarks-remover/releases/tag/v0.4.0) — pixel removal, finding confidence, Windows & false-positive fixes
+22 -1
View File
@@ -35,6 +35,8 @@ python3 "$SCRIPTS/clean_image.py" ...
python3 "$SCRIPTS/clean_ctrlregen.py" ... # optional external pixel removal (bootstrap first)
"$SCRIPTS/setup_ctrlregen.sh" # one-command bootstrap (Windows: setup_ctrlregen.ps1)
python3 "$SCRIPTS/rewrite_text.py" ...
python3 "$SCRIPTS/detect_text_watermark.py" ... # optional external MarkLLM verification harness (bootstrap first)
"$SCRIPTS/setup_markllm.sh" # one-command bootstrap for the above
python3 "$SCRIPTS/audit_dir.py" ...
python3 "$SCRIPTS/audit_website.py" ...
```
@@ -154,6 +156,17 @@ python3 "$SCRIPTS/rewrite_text.py" draft.md -o draft.rewritten.md --strength par
If the hook is not configured, run the prompts below yourself (agent-orchestrated).
**Optional MarkLLM verification:** to test a specific scheme (KGW / SynthID)
under a config you control, bootstrap the external MarkLLM checkout with
`scripts/setup_markllm.sh`, then either run
`detect_text_watermark.py watermark`/`detect` directly or pass
`--markllm-scheme kgw|synthid` to `rewrite_text.py` for a before/after
detection report. This is a **controlled-experiment harness** — detection is
only valid against the same scheme config + keys used at generation and cannot
certify a vendor detector. Add `--offline` to load the scoring model from the
HF cache only (no network); `WATERMARKS_MARKLLM_RLIMIT_AS` (env, POSIX)
optionally caps the subprocess address space.
**Code files:** Prefer formatter (`prettier`, `black`, `gofmt`, …) + Layer A. Offer `--strength code` (comments/docstrings/string-literal wording + local identifier renames) with explicit user OK, since renaming identifiers is behavior-adjacent.
#### Rewrite prompts (use as-is)
@@ -235,7 +248,7 @@ Always state:
## Limitations
- Layer A does **not** remove token-sampling watermarks.
- Layer B cannot be gold-verified without vendor detectors / keys.
- Layer B cannot be gold-verified without vendor detectors / keys. The optional MarkLLM harness (`detect_text_watermark.py` / `rewrite_text.py --markllm-scheme`) verifies a specific scheme config before/after a rewrite, but it is same-config-only and not a vendor-detector oracle; its backend is external (Apache-2.0), never bundled, and pulls torch + a few GB of model weights.
- PDF strip is best-effort without `exiftool`, and incomplete without `qpdf`: exiftool alone leaves the freed metadata objects in the byte stream.
- Pixel-domain **image** watermarks can be removed optionally via the external CtrlRegen backend (`clean_image.py --remove-pixel ctrlregen`); audio/video watermarks remain out of scope for removal.
- The CtrlRegen backend is external, all-rights-reserved (no LICENSE file), never bundled, heavy (~10 GB model downloads), and a regenerating remover — no local detector certifies StegaStamp/Tree-Ring/StableSignature removal.
@@ -256,6 +269,7 @@ python3 scripts/clean_file.py deck.docx -o deck.cleaned.docx
python3 scripts/inspect_text.py notes.md
python3 scripts/clean_text.py notes.md -o notes.cleaned.md --stats
python3 scripts/rewrite_text.py notes.md --backend print-prompt --strength paraphrase
python3 scripts/rewrite_text.py notes.md --markllm-scheme kgw --markllm-dir ~/MarkLLM # optional before/after verification
# Images only
python3 scripts/inspect_image.py shot.png
@@ -267,6 +281,13 @@ NOAI_WATERMARK_DIR=~/noai-watermark \
~/noai-watermark/.venv/bin/python scripts/clean_image.py shot.png \
-o shot.cleaned.png --remove-pixel ctrlregen
# Optional MarkLLM verification (external backend; bootstrap first)
scripts/setup_markllm.sh
MARKLLM_DIR=~/MarkLLM \
~/MarkLLM/.venv/bin/python scripts/detect_text_watermark.py detect notes.md --scheme kgw
MARKLLM_DIR=~/MarkLLM \
~/MarkLLM/.venv/bin/python scripts/detect_text_watermark.py watermark prompt.txt --scheme synthid -o wm.txt
# Aggregate audits
python3 scripts/audit_dir.py ./src --json
python3 scripts/audit_website.py --sitemap https://example.com/sitemap.xml --json
@@ -3,7 +3,7 @@
| Target | Method | Script / action | Side effects | Verifiable today? |
| --- | --- | --- | --- | --- |
| Invisible Unicode / exotic spaces / bidi / tags | Strip / normalize | `inspect_text.py`, `clean_text.py`, `clean_file.py` | Minimal | Yes (codepoint report) |
| Statistical text watermark (SynthID-class / Kirchenbauer) | Multi-pass paraphrase / humanize / back-translate / structural | Agent Layer B + optional `rewrite_text.py` | Meaning/style drift | No without vendor key/detector |
| Statistical text watermark (SynthID-class / Kirchenbauer) | Multi-pass paraphrase / humanize / back-translate / structural | Agent Layer B + optional `rewrite_text.py` | Meaning/style drift | No without vendor key/detector; **MarkLLM harness** (`detect_text_watermark.py`) verifies a specific scheme config before/after |
| C2PA on PNG/JPEG/WebP | Drop APP11 / PNG `caBX` / RIFF `C2PA` / exiftool | `clean_image.py` | Loses provenance metadata | Yes |
| SVG metadata / XMP | Drop `<metadata>`, xmpmeta | `clean_file.py` | Loses SVG metadata | Yes (re-inspect) |
| PDF XMP / info | exiftool `-all=` preferred | `clean_file.py` | Loses PDF metadata; degraded without exiftool | Partial |
@@ -24,6 +24,7 @@
4. Prefer a **non-origin, open-weight** rewrite model when available (avoid re-stamping).
5. Layer A again after rewrite.
6. Report: Layer B is best-effort; residual risk remains.
7. **Optional verification:** `rewrite_text.py --markllm-scheme kgw|synthid` runs a MarkLLM before/after detection (external `detect_text_watermark.py` harness) to show a specific scheme config clears. Same-config-only; not a vendor-detector oracle.
## Code vs prose
@@ -32,6 +32,7 @@ Source: [How Claude marks AI-generated content](https://support.claude.com/en/ar
- **Data-driven / backdoor** (trigger phrases) → **out of scope**
- **Generative** (sampling) → Layer B best-effort
- Productionized in Gemini-scale systems; open research code exists, but **production keys are not public** — this skill does **not** ship a SynthID detector.
- Optional external verification harness: [`THU-BPM/MarkLLM`](https://github.com/THU-BPM/MarkLLM) (Apache-2.0) reimplements SynthID-Text among other schemes with configurable keys; wired as `detect_text_watermark.py` / `rewrite_text.py --markllm-scheme`. Same-config-only — it verifies a mark you generated under a known config, not Google's production keying.
- Current frontier production watermarks are **token-by-token** (streaming constraint); paragraph-level robust methods (SemStamp / PostMark) are not deployed yet, which keeps paraphrase-class attacks effective today.
- Optional external reference: [`aloshdenny/reverse-SynthID`](https://github.com/aloshdenny/reverse-SynthID) provides a reverse-engineered pixel-domain scorer. It is **not bundled** here, is best-effort, and is under a non-commercial Research License; it is not the official Google detector.
- Optional pixel-domain removal: [`mertizci/noai-watermark`](https://github.com/mertizci/noai-watermark)'s CtrlRegen profile is wired through `clean_image.py --remove-pixel ctrlregen` / `clean_ctrlregen.py`. It is **not bundled** (no LICENSE file → all-rights-reserved), and no local detector certifies the result; the official Google check is the final authority.
@@ -50,6 +51,7 @@ Source: [How Claude marks AI-generated content](https://support.claude.com/en/ar
- Classic green-list / red-list sampling bias (Kirchenbauer et al.) and variants.
- Detectable with the **key** and tokenizer; removal still relies on heavy paraphrase or regeneration.
- Optional external harness: `MarkLLM` (`detect_text_watermark.py --scheme kgw`) reproduces KGW detection under a config you control, for controlled before/after experiments.
**Skill mapping:** Layer B multi-pass; prefer rewrite with a **different** model family when possible.
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env python3
"""Optional MarkLLM text-watermark harness backed by an external THU-BPM/MarkLLM checkout.
This script does NOT vendor upstream code. It imports ``AutoWatermark`` from a
user-provided checkout (https://github.com/THU-BPM/MarkLLM) at runtime, using
that environment's optional dependencies (torch, transformers, datasets, ...).
MarkLLM is Apache-2.0. It is a research/verification harness: detection is only
valid against the SAME scheme config + keys used at generation. It cannot
certify that a vendor detector will fail on the given text.
Subcommands:
detect run detection on a text file with a known scheme/config
watermark generate watermarked (and optionally unwatermarked) sample text
from a prompt, for controlled before/after experiments
Exit codes:
0 success
1 runtime error (model load, detection/generation failure)
2 bad input (missing/unreadable file, binary input, bad args)
3 unavailable (not configured / missing checkout / missing deps)
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPTS_DIR))
from common import eprint, emit_json, read_text_input, safe_write_text # noqa: E402
# Scheme name as the user types it -> MarkLLM algorithm name (config/{ALG}.json).
SCHEMES = {
"kgw": "KGW",
"synthid": "SynthID",
"synthid-text": "SynthID",
}
DEFAULT_MODEL = "facebook/opt-1.3b"
# Algorithm configs are ~200 B (KGW/SynthID). Cap well above that so a crafted
# or accidental huge file is refused before either this script or upstream
# reads it into memory.
MAX_CONFIG_BYTES = 1 << 20
class _Unavailable(RuntimeError):
"""Backend present but unusable (unconfigured checkout, missing deps)."""
def resolve_upstream(raw: str | None) -> Path | None:
if not raw:
return None
upstream = Path(raw).expanduser().resolve()
if not upstream.is_dir():
return None
return upstream
def resolve_device(raw: str | None) -> str:
"""Resolve the ``auto`` device hint to a concrete torch device."""
if raw and raw != "auto":
return raw
try:
import torch
if torch.cuda.is_available():
return "cuda"
mps = getattr(torch.backends, "mps", None)
if mps is not None and mps.is_available():
return "mps"
except Exception:
pass
return "cpu"
def _load_algorithm(
upstream: Path, alg: str, config: Path, model: str, device: str, offline: bool = False
):
"""Import the checkout and build an ``AutoWatermark`` instance."""
sys.path.insert(0, str(upstream))
try:
from transformers import AutoModelForCausalLM, AutoTokenizer # noqa: E402
from utils.transformers_config import TransformersConfig # noqa: E402
from watermark.auto_watermark import AutoWatermark # noqa: E402
except ImportError as e:
raise _Unavailable(f"MarkLLM dependencies missing: {e}") from e
# --offline: never contact the HF hub. local_files_only makes transformers
# fail fast instead of hanging, and HF_HUB_OFFLINE covers the lower-level
# hub calls. Custom-code execution is not possible either way: transformers
# only honors auto_map/trust_remote_code when explicitly enabled, which is
# never done here.
if offline:
os.environ.setdefault("HF_HUB_OFFLINE", "1")
load_kwargs = {"local_files_only": True} if offline else {}
tokenizer = AutoTokenizer.from_pretrained(model, **load_kwargs)
lm = AutoModelForCausalLM.from_pretrained(model, **load_kwargs).to(device)
transformers_config = TransformersConfig(
model=lm,
tokenizer=tokenizer,
device=device,
max_new_tokens=200,
min_length=0,
do_sample=True,
no_repeat_ngram_size=4,
)
return AutoWatermark.load(
alg,
algorithm_config=str(config),
transformers_config=transformers_config,
)
def _threshold_from_config(config: Path) -> float | None:
try:
data = json.loads(config.read_text("utf-8"))
except (OSError, ValueError):
return None
for key in ("threshold", "z_threshold"):
value = data.get(key)
if isinstance(value, (int, float)):
return float(value)
return None
def _resolve_config(upstream: Path, alg: str, config: str | None) -> Path:
if config:
path = Path(config).expanduser().resolve()
else:
path = upstream / "config" / f"{alg}.json"
if not path.is_file():
raise _Unavailable(f"MarkLLM config not found: {path}")
try:
size = path.stat().st_size
except OSError as e:
raise _Unavailable(f"cannot stat MarkLLM config {path}: {e}") from e
if size > MAX_CONFIG_BYTES:
raise _Unavailable(
f"MarkLLM config too large ({size} bytes > {MAX_CONFIG_BYTES}): {path}"
)
return path
def _cmd_detect(args: argparse.Namespace, upstream: Path, alg: str) -> int:
if args.path != "-" and not Path(args.path).is_file():
eprint(f"not a file: {args.path}")
return 2
text = read_text_input(args.path, allow_binary=args.force_text)
device = resolve_device(args.device)
try:
config = _resolve_config(upstream, alg, args.config)
threshold = _threshold_from_config(config)
wm = _load_algorithm(
upstream, alg, config, args.model, device, offline=args.offline
)
result = wm.detect_watermark(text, return_dict=True)
except _Unavailable as e:
eprint(str(e))
return 3
except Exception as e:
eprint(f"detection error: {e}")
return 1
is_watermarked = bool(result.get("is_watermarked", False))
score = result.get("score")
try:
score = float(score)
except (TypeError, ValueError):
score = None
payload = {
"available": True,
"upstream_dir": str(upstream),
"scheme": alg,
"config": str(config),
"model": args.model,
"device": device,
"is_watermarked": is_watermarked,
"score": score,
"threshold": threshold,
}
if args.json:
emit_json(payload)
else:
label = "watermarked" if is_watermarked else "not watermarked"
score_txt = f"{score:.4f}" if score is not None else "n/a"
thresh_txt = f"{threshold:.4f}" if threshold is not None else "n/a"
print(f"{alg}: {label} (score {score_txt}, threshold {thresh_txt})")
return 0
def _cmd_watermark(args: argparse.Namespace, upstream: Path, alg: str) -> int:
prompt = read_text_input(args.prompt, allow_binary=args.force_text)
device = resolve_device(args.device)
try:
config = _resolve_config(upstream, alg, args.config)
wm = _load_algorithm(
upstream, alg, config, args.model, device, offline=args.offline
)
if args.seed is not None:
import torch
torch.manual_seed(args.seed)
wm.config.gen_kwargs["max_new_tokens"] = args.max_new_tokens
wm.config.gen_kwargs["min_length"] = args.min_length
watermarked = wm.generate_watermarked_text(prompt)
unwatermarked = None
if args.unwatermarked_output:
unwatermarked = wm.generate_unwatermarked_text(prompt)
except _Unavailable as e:
eprint(str(e))
return 3
except Exception as e:
eprint(f"generation error: {e}")
return 1
wm_out = "-" if args.watermarked_output is None else args.watermarked_output
safe_write_text(wm_out, watermarked)
if unwatermarked is not None:
safe_write_text(args.unwatermarked_output, unwatermarked)
payload = {
"available": True,
"upstream_dir": str(upstream),
"scheme": alg,
"config": str(config),
"model": args.model,
"device": device,
"watermarked_output": wm_out,
"unwatermarked_output": args.unwatermarked_output,
"watermarked_chars": len(watermarked),
"unwatermarked_chars": len(unwatermarked) if unwatermarked is not None else None,
}
if args.json:
emit_json(payload)
else:
print(f"{alg}: watermarked sample ({payload['watermarked_chars']} chars) -> {wm_out}")
if unwatermarked is not None:
print(f" unwatermarked sample ({payload['unwatermarked_chars']} chars) -> {args.unwatermarked_output}")
return 0
def _add_common(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--upstream-dir",
type=Path,
default=None,
help="MarkLLM checkout root (default: $MARKLLM_DIR)",
)
p.add_argument(
"--scheme",
required=True,
choices=sorted(SCHEMES),
help="Watermark scheme to use (kgw, synthid)",
)
p.add_argument(
"--config",
default=None,
help="Algorithm config JSON (default: <checkout>/config/<ALG>.json)",
)
p.add_argument(
"--model",
default=os.environ.get("MARKLLM_MODEL", DEFAULT_MODEL),
help=f"HF causal LM for scoring (default: $MARKLLM_MODEL or {DEFAULT_MODEL})",
)
p.add_argument(
"--device",
default="auto",
help="auto|cpu|cuda|mps (default: auto)",
)
p.add_argument(
"--offline",
action="store_true",
help="Never contact the HF hub: load the scoring model from the local "
"cache only (fails fast if not cached)",
)
p.add_argument(
"--force-text",
action="store_true",
help="Process input even when it looks like a binary container",
)
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
sub = p.add_subparsers(dest="cmd", required=True)
detect = sub.add_parser("detect", help="Detect a scheme watermark in text")
detect.add_argument("path", help="Text file to detect on, or - for stdin")
_add_common(detect)
detect.add_argument("--json", action="store_true", help="Emit JSON on stdout")
detect.set_defaults(handler=_cmd_detect)
wm = sub.add_parser("watermark", help="Generate watermarked sample text")
wm.add_argument("prompt", help="Prompt file, or - for stdin")
wm.add_argument("-o", "--watermarked-output", default=None,
help="Output path for the watermarked sample (default: stdout)")
wm.add_argument("-o2", "--unwatermarked-output", default=None,
help="Also write an unwatermarked sample to this path")
wm.add_argument("--max-new-tokens", type=int, default=200)
wm.add_argument("--min-length", type=int, default=0)
wm.add_argument("--seed", type=int, default=None, help="Optional RNG seed")
_add_common(wm)
wm.add_argument("--json", action="store_true", help="Emit JSON on stdout")
wm.set_defaults(handler=_cmd_watermark)
args = p.parse_args()
if args.cmd == "detect" and args.path != "-" and not Path(args.path).is_file():
eprint(f"not a file: {args.path}")
return 2
raw_upstream = args.upstream_dir or os.environ.get("MARKLLM_DIR")
upstream = resolve_upstream(str(raw_upstream) if raw_upstream else None)
if upstream is None:
eprint(
"MarkLLM not configured: set MARKLLM_DIR or pass --upstream-dir",
)
return 3
if not (upstream / "watermark").is_dir():
eprint(f"MarkLLM checkout incomplete (no watermark/ dir): {upstream}")
return 3
alg = SCHEMES[args.scheme]
return args.handler(args, upstream, alg)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,27 @@
# Minimal dependencies for the optional MarkLLM text-watermark harness.
# The upstream repo's requirements.txt is unpinned and pulls evaluation-only
# deps (sentence-transformers, sacrebleu, networkx, translate, tiktoken,
# openai==0.28). This file installs only what AutoWatermark.load() needs for
# the KGW and SynthID schemes (plus the synthid detector's C4Dataset import).
#
# Vendored fork hardening: exact pins (no drift). Re-evaluate versions before
# bumping and keep the pinned upstream checkout in setup_markllm.sh /
# Dockerfile.markllm in sync with the code these versions are tested against.
#
# Pillow is pinned to 12.3.0 (same as requirements-synthid-scorer.txt) for
# the 24 known CVEs fixed after the upstream 9.4.0 pin.
torch==2.13.0
transformers==5.15.0
tokenizers==0.23.1
datasets==5.0.1
accelerate==1.14.0
SentencePiece==0.2.2
nltk==3.10.3
jieba==0.42.1
tqdm==4.70.0
matplotlib==3.11.1
Cython==3.2.9
numpy==2.5.2
scipy==1.18.0
huggingface_hub==1.27.0
Pillow==12.3.0
@@ -26,9 +26,11 @@ import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
from collections.abc import Callable
from pathlib import Path
from urllib.parse import urlparse
@@ -37,6 +39,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import cleaned_path, eprint, read_text_input, write_text_output # noqa: E402
from text_unicode import clean_text # noqa: E402
DEFAULT_MARKLLM_MODEL = "facebook/opt-1.3b"
PROMPTS = {
"paraphrase": (
"Rewrite the following text so that it uses substantially different wording at "
@@ -169,6 +173,101 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
raise urllib.error.HTTPError(req.full_url, code, msg, headers, fp)
SCRIPTS_DIR = Path(__file__).resolve().parent
def _venv_python(upstream: Path) -> Path | None:
"""Locate the MarkLLM checkout's venv interpreter, if it exists."""
if os.name == "nt":
candidate = upstream / ".venv" / "Scripts" / "python.exe"
else:
candidate = upstream / ".venv" / "bin" / "python"
return candidate if candidate.is_file() else None
def _markllm_preexec() -> Callable[[], None] | None:
"""Optional RLIMIT_AS guard for the MarkLLM child; None means "no limit".
torch/CUDA usually needs large address spaces, so unlike the
exiftool/c2patool/SynthID children (common.subprocess_rlimits) this is
opt-in via WATERMARKS_MARKLLM_RLIMIT_AS (byte count, hex/octal allowed).
POSIX only; on Windows preexec_fn must stay None.
"""
raw = os.environ.get("WATERMARKS_MARKLLM_RLIMIT_AS")
if not raw or os.name != "posix":
return None
try:
limit = int(raw, 0)
except ValueError:
return None
def _apply() -> None:
import resource
resource.setrlimit(resource.RLIMIT_AS, (limit, limit))
return _apply
def _markllm_detect(
text: str,
*,
scheme: str,
upstream_dir: str,
model: str,
timeout: float,
) -> dict:
"""Run the MarkLLM adapter on *text*; never fails the rewrite.
Returns the adapter's JSON payload, or an ``available: False`` dict with
an ``error`` string when the backend is unconfigured or broken. The Layer B
rewrite proceeds regardless; MarkLLM verification is best-effort.
"""
if not upstream_dir:
return {"available": False, "error": "no MARKLLM_DIR set"}
upstream = Path(upstream_dir).expanduser().resolve()
if not upstream.is_dir() or not (upstream / "watermark").is_dir():
return {"available": False, "error": f"MarkLLM checkout missing: {upstream}"}
venv_python = _venv_python(upstream)
if venv_python is None:
return {"available": False, "error": f"MarkLLM venv missing: {upstream}"}
cmd = [
str(venv_python),
str(SCRIPTS_DIR / "detect_text_watermark.py"),
"detect",
"-",
"--scheme",
scheme,
"--upstream-dir",
str(upstream),
"--model",
model,
"--json",
]
try:
r = subprocess.run(
cmd,
input=text,
capture_output=True,
text=True,
timeout=timeout,
preexec_fn=_markllm_preexec(),
)
except (OSError, subprocess.SubprocessError, TimeoutError) as e:
return {"available": False, "error": f"MarkLLM adapter error: {e}"}
if r.returncode != 0:
return {
"available": False,
"error": (r.stderr or "").strip() or f"adapter exited {r.returncode}",
}
try:
return json.loads(r.stdout)
except ValueError as e:
return {"available": False, "error": f"adapter JSON parse error: {e}"}
def build_prompt(strength: str, text: str, *, lang: str, original_lang: str) -> str:
if strength == "paraphrase":
return PROMPTS["paraphrase"].format(TEXT=text)
@@ -276,6 +375,10 @@ def rewrite(
temperature: float,
candidates: int,
allow_remote: bool = False,
markllm_scheme: str | None = None,
markllm_dir: str | None = None,
markllm_model: str | None = None,
markllm_timeout: float = 180.0,
) -> tuple[str, dict]:
prompt = build_prompt(strength, text, lang=lang, original_lang=original_lang)
info: dict = {
@@ -288,6 +391,22 @@ def rewrite(
"input_chars": len(text),
}
markllm: dict | None = None
if markllm_scheme:
markllm = {
"scheme": markllm_scheme,
"before": _markllm_detect(
text,
scheme=markllm_scheme,
upstream_dir=markllm_dir or "",
model=markllm_model or DEFAULT_MARKLLM_MODEL,
timeout=markllm_timeout,
),
}
if not markllm["before"]["available"]:
eprint(f"markllm verification unavailable: {markllm['before']['error']}")
info["markllm"] = markllm
if backend == "print-prompt":
info["mode"] = "print-prompt"
if candidates > 1:
@@ -330,6 +449,26 @@ def rewrite(
"Layer B is best-effort against statistical token-sampling watermarks; "
"cannot certify removal against a vendor detector."
)
if markllm:
after = _markllm_detect(
out,
scheme=markllm["scheme"],
upstream_dir=markllm_dir or "",
model=markllm_model or DEFAULT_MARKLLM_MODEL,
timeout=markllm_timeout,
)
markllm["after"] = after
before = markllm["before"]
if before.get("available") and after.get("available"):
markllm["cleared"] = bool(
before.get("is_watermarked") and not after.get("is_watermarked")
)
markllm["note"] = (
"MarkLLM detection is only valid against the SAME scheme config + "
"keys used at generation; it does not certify a vendor detector."
)
return out, info
@@ -382,6 +521,29 @@ def main() -> int:
help="Skip Layer A scrub on model output",
)
p.add_argument("--json-stats", action="store_true", help="Stats JSON on stderr")
p.add_argument(
"--markllm-scheme",
choices=("kgw", "synthid", "synthid-text"),
default=None,
help="Optional: run MarkLLM before/after detection around the rewrite "
"(scheme = kgw or synthid)",
)
p.add_argument(
"--markllm-dir",
default=_env("MARKLLM_DIR"),
help="MarkLLM checkout root (default: $MARKLLM_DIR)",
)
p.add_argument(
"--markllm-model",
default=_env("MARKLLM_MODEL", DEFAULT_MARKLLM_MODEL),
help=f"Scoring model for MarkLLM detection (default: $MARKLLM_MODEL or {DEFAULT_MARKLLM_MODEL})",
)
p.add_argument(
"--markllm-timeout",
type=float,
default=float(_env("WATERMARKS_MARKLLM_TIMEOUT", "180.0")),
help="Timeout per MarkLLM detection call (default: 180.0)",
)
p.add_argument(
"--force-text",
action="store_true",
@@ -410,6 +572,10 @@ def main() -> int:
temperature=args.temperature,
candidates=args.candidates,
allow_remote=allow_remote,
markllm_scheme=args.markllm_scheme,
markllm_dir=args.markllm_dir,
markllm_model=args.markllm_model,
markllm_timeout=args.markllm_timeout,
)
except (urllib.error.URLError, TimeoutError, RuntimeError) as e:
eprint(f"rewrite failed: {e}")
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
# Bootstrap an external THU-BPM/MarkLLM checkout for the optional text-watermark
# harness.
#
# The upstream project (https://github.com/THU-BPM/MarkLLM) is Apache-2.0 and
# is NOT bundled in this repository. This script clones it locally (pinned
# commit), creates a venv, and installs only the dependencies needed by
# detect_text_watermark.py (torch, transformers, datasets, ...).
#
# The base scoring model (default facebook/opt-1.3b) is downloaded from
# Hugging Face by detect_text_watermark.py at runtime, not here.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_DIR="${MARKLLM_DIR:-$HOME/MarkLLM}"
DIR=""
# Pinned upstream commit (2026-07-10). Do not point at a moving branch.
REF="c45ddc40f7b761beabe55a1b8dc4690e531d1c6d"
PYTHON="${PYTHON:-python3}"
usage() {
cat <<'EOF'
Usage: setup_markllm.sh [--dir PATH] [--ref REF] [--python PYTHON]
Clones (if needed) THU-BPM/MarkLLM, creates a venv, and installs the Python
dependencies required by detect_text_watermark.py (including torch).
Options:
--dir PATH checkout directory (default: $MARKLLM_DIR or ~/MarkLLM)
--ref REF git ref to checkout (default: pinned commit SHA)
--python PY Python interpreter used to create the venv (default: python3)
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dir)
DIR="${2:?--dir requires a value}"
shift 2
;;
--ref)
REF="${2:?--ref requires a value}"
shift 2
;;
--python)
PYTHON="${2:?--python requires a value}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
DIR="${DIR:-$DEFAULT_DIR}"
mkdir -p "$(dirname "$DIR")"
if command -v realpath >/dev/null 2>&1; then
DIR="$(realpath -m "$DIR")"
else
DIR="$(cd "$(dirname "$DIR")" && pwd)/$(basename "$DIR")"
fi
if [[ ! -d "$DIR/.git" ]]; then
echo "Cloning THU-BPM/MarkLLM into $DIR (pinned ref: $REF)"
git clone --depth 1 --filter=blob:none --sparse \
https://github.com/THU-BPM/MarkLLM.git "$DIR"
git -C "$DIR" fetch --depth 1 origin "$REF"
git -C "$DIR" checkout --detach "$REF"
git -C "$DIR" sparse-checkout set --no-cone \
'/watermark/' \
'/config/' \
'/utils/' \
'/exceptions/' \
'/evaluation/dataset.py' \
'/LICENSE' \
'/README.md'
HEAD_SHA="$(git -C "$DIR" rev-parse HEAD)"
if [[ "$HEAD_SHA" != "$REF" ]]; then
echo "error: expected pinned ref $REF, got $HEAD_SHA" >&2
exit 1
fi
else
echo "Using existing checkout: $DIR"
fi
if [[ ! -x "$DIR/.venv/bin/python" ]]; then
echo "Creating venv at $DIR/.venv"
"$PYTHON" -m venv "$DIR/.venv"
fi
echo "Installing Python dependencies"
# Pin pip itself (unpinned --upgrade pip was a supply-chain drift point).
"$DIR/.venv/bin/python" -m pip install --upgrade "pip==26.2.1"
# Install torch with the right platform index before the other pinned deps.
if command -v nvidia-smi >/dev/null 2>&1; then
cuda="$(nvidia-smi 2>/dev/null | sed -n 's/.*CUDA Version: \([0-9]*\.[0-9]*\).*/\1/p' | head -1)"
if [[ -n "$cuda" ]]; then
tag="cu${cuda/./}"
index="https://download.pytorch.org/whl/$tag"
echo "NVIDIA GPU detected (CUDA $cuda); installing torch from $index"
"$DIR/.venv/bin/python" -m pip install torch --index-url "$index"
else
echo "nvidia-smi present but no CUDA version found; installing default torch"
"$DIR/.venv/bin/python" -m pip install torch
fi
else
echo "No NVIDIA GPU detected; installing default torch (CPU/MPS)"
"$DIR/.venv/bin/python" -m pip install torch
fi
"$DIR/.venv/bin/python" -m pip install -r "$SCRIPT_DIR/requirements-markllm.txt"
cat <<EOF
Done. Detect a scheme watermark in text with:
export MARKLLM_DIR="$DIR"
"$DIR/.venv/bin/python" "\$REPO/skills/remove-ai-marks/scripts/detect_text_watermark.py" detect TEXT --scheme kgw
The base scoring model (default facebook/opt-1.3b) is downloaded from
Hugging Face on first run. Detection is only valid against the SAME scheme
config + keys used at generation: this is a verification harness, not a
vendor-detector oracle.
EOF
+435
View File
@@ -0,0 +1,435 @@
"""Tests for the optional MarkLLM text-watermark harness adapter."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "skills" / "remove-ai-marks" / "scripts"
sys.path.insert(0, str(SCRIPTS))
DETECT_SCRIPT = SCRIPTS / "detect_text_watermark.py"
FAKE_TRANSFORMERS = (
"import sys\n"
"class _LM:\n"
" def to(self, device):\n"
" return self\n"
"\n"
"class AutoModelForCausalLM:\n"
" @staticmethod\n"
" def from_pretrained(name, **kwargs):\n"
" print('MARKLLM_PRETRAINED_KWARGS=' + repr(kwargs), file=sys.stderr)\n"
" return _LM()\n"
"\n"
"class AutoTokenizer:\n"
" @staticmethod\n"
" def from_pretrained(name, **kwargs):\n"
" return object()\n"
)
FAKE_TRANSFORMERS_CONFIG = (
"class TransformersConfig:\n"
" def __init__(self, model, tokenizer, vocab_size=None, device='cuda', **kwargs):\n"
" self.device = device\n"
" self.model = model\n"
" self.tokenizer = tokenizer\n"
" self.vocab_size = vocab_size\n"
" self.gen_kwargs = {}\n"
" self.gen_kwargs.update(kwargs)\n"
)
KGW_CONFIG = '{"algorithm_name": "KGW", "z_threshold": 4.0}'
SYNTHID_CONFIG = '{"algorithm_name": "SynthID", "threshold": 0.52, "detector_type": "mean"}'
def _fake_auto_watermark(*, fail_detect: bool = False, fail_generate: bool = False) -> str:
detect_body = 'raise RuntimeError("boom")' if fail_detect else 'return {"is_watermarked": True, "score": 3.5}'
gen_body = 'raise RuntimeError("boom")' if fail_generate else "return 'WATERMARKED SAMPLE'"
return (
"class _WM:\n"
" def __init__(self):\n"
" self.config = SimpleNamespace(gen_kwargs={})\n"
" def detect_watermark(self, text, return_dict=True):\n"
f" {detect_body}\n"
" def generate_watermarked_text(self, prompt):\n"
f" {gen_body}\n"
" def generate_unwatermarked_text(self, prompt):\n"
" return 'PLAIN SAMPLE'\n"
"\n"
"class AutoWatermark:\n"
" @staticmethod\n"
" def load(algorithm_name, algorithm_config=None, transformers_config=None):\n"
" return _WM()\n"
)
def _make_fake_upstream(
tmp_path: Path,
*,
with_config: bool = True,
fail_detect: bool = False,
fail_generate: bool = False,
missing_watermark_dir: bool = False,
) -> Path:
upstream = tmp_path / "MarkLLM"
config_dir = upstream / "config"
config_dir.mkdir(parents=True)
if with_config:
(config_dir / "KGW.json").write_text(KGW_CONFIG)
(config_dir / "SynthID.json").write_text(SYNTHID_CONFIG)
if not missing_watermark_dir:
watermark = upstream / "watermark"
watermark.mkdir(parents=True)
(watermark / "__init__.py").write_text("")
(watermark / "auto_watermark.py").write_text(
"from types import SimpleNamespace\n" + _fake_auto_watermark(
fail_detect=fail_detect, fail_generate=fail_generate
)
)
utils_dir = upstream / "utils"
utils_dir.mkdir(parents=True)
(utils_dir / "__init__.py").write_text("")
(utils_dir / "transformers_config.py").write_text(FAKE_TRANSFORMERS_CONFIG)
transformers_dir = upstream / "transformers"
transformers_dir.mkdir(parents=True)
(transformers_dir / "__init__.py").write_text(FAKE_TRANSFORMERS)
return upstream
def _run_adapter(*args: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env.pop("MARKLLM_DIR", None)
return subprocess.run(
[sys.executable, str(DETECT_SCRIPT), *args],
capture_output=True,
text=True,
env=env,
)
def test_cli_unavailable_without_upstream(tmp_path: Path):
f = tmp_path / "t.txt"
f.write_text("hello world")
r = _run_adapter("detect", str(f), "--scheme", "kgw")
assert r.returncode == 3
assert "MARKLLM_DIR" in (r.stderr or "")
def test_cli_unavailable_incomplete_checkout(tmp_path: Path):
f = tmp_path / "t.txt"
f.write_text("hello world")
empty = tmp_path / "empty"
empty.mkdir()
r = _run_adapter("detect", str(f), "--scheme", "kgw", "--upstream-dir", str(empty))
assert r.returncode == 3
upstream = _make_fake_upstream(tmp_path, missing_watermark_dir=True)
r = _run_adapter("detect", str(f), "--scheme", "kgw", "--upstream-dir", str(upstream))
assert r.returncode == 3
def test_cli_unavailable_missing_config(tmp_path: Path):
f = tmp_path / "t.txt"
f.write_text("hello world")
upstream = _make_fake_upstream(tmp_path, with_config=False)
r = _run_adapter("detect", str(f), "--scheme", "kgw", "--upstream-dir", str(upstream))
assert r.returncode == 3
assert "config" in (r.stderr or "").lower()
def test_cli_unavailable_missing_deps(tmp_path: Path):
# The watermark module imports a nonexistent dependency -> ImportError ->
# exit 3 ("dependencies missing") before any model download.
upstream = tmp_path / "MarkLLM"
(upstream / "config").mkdir(parents=True)
(upstream / "config" / "KGW.json").write_text(KGW_CONFIG)
watermark = upstream / "watermark"
watermark.mkdir()
(watermark / "__init__.py").write_text("")
(watermark / "auto_watermark.py").write_text("import does_not_exist_123\n")
(upstream / "utils").mkdir()
(upstream / "utils" / "__init__.py").write_text("")
(upstream / "utils" / "transformers_config.py").write_text(FAKE_TRANSFORMERS_CONFIG)
(upstream / "transformers").mkdir()
(upstream / "transformers" / "__init__.py").write_text(FAKE_TRANSFORMERS)
f = tmp_path / "t.txt"
f.write_text("hello world")
r = _run_adapter("detect", str(f), "--scheme", "kgw", "--upstream-dir", str(upstream))
assert r.returncode == 3
assert "dependencies missing" in (r.stderr or "")
def test_cli_bad_input_missing_file(tmp_path: Path):
r = _run_adapter("detect", str(tmp_path / "missing.txt"), "--scheme", "kgw")
assert r.returncode == 2
def test_cli_bad_input_binary(tmp_path: Path):
upstream = _make_fake_upstream(tmp_path)
png = tmp_path / "img.png"
png.write_bytes(b"\x89PNG\r\n\x1a\nnot really")
r = _run_adapter("detect", str(png), "--scheme", "kgw", "--upstream-dir", str(upstream))
assert r.returncode == 2
assert "refusing" in (r.stderr or "")
def test_cli_bad_scheme(tmp_path: Path):
f = tmp_path / "t.txt"
f.write_text("hello world")
r = _run_adapter("detect", str(f), "--scheme", "nope")
assert r.returncode == 2
def test_cli_detect_json_success(tmp_path: Path):
upstream = _make_fake_upstream(tmp_path)
f = tmp_path / "t.txt"
f.write_text("hello world")
r = _run_adapter(
"detect", str(f), "--scheme", "kgw", "--upstream-dir", str(upstream),
"--device", "cpu", "--json",
)
assert r.returncode == 0, r.stderr
payload = json.loads(r.stdout)
assert payload["available"] is True
assert payload["scheme"] == "KGW"
assert payload["is_watermarked"] is True
assert payload["score"] == 3.5
assert payload["threshold"] == 4.0
assert payload["device"] == "cpu"
def test_cli_detect_synthid_alias(tmp_path: Path):
upstream = _make_fake_upstream(tmp_path)
f = tmp_path / "t.txt"
f.write_text("hello world")
r = _run_adapter(
"detect", str(f), "--scheme", "synthid-text", "--upstream-dir", str(upstream),
"--device", "cpu", "--json",
)
assert r.returncode == 0, r.stderr
payload = json.loads(r.stdout)
assert payload["scheme"] == "SynthID"
assert payload["threshold"] == 0.52
def test_cli_detect_runtime_error(tmp_path: Path):
upstream = _make_fake_upstream(tmp_path, fail_detect=True)
f = tmp_path / "t.txt"
f.write_text("hello world")
r = _run_adapter(
"detect", str(f), "--scheme", "kgw", "--upstream-dir", str(upstream),
"--device", "cpu", "--json",
)
assert r.returncode == 1
assert "boom" in (r.stderr or "")
def test_cli_detect_offline_flag(tmp_path: Path):
upstream = _make_fake_upstream(tmp_path)
f = tmp_path / "t.txt"
f.write_text("hello world")
r = _run_adapter(
"detect", str(f), "--scheme", "kgw", "--upstream-dir", str(upstream),
"--device", "cpu", "--json", "--offline",
)
assert r.returncode == 0, r.stderr
assert "local_files_only" in (r.stderr or "")
assert "True" in (r.stderr or "")
def test_cli_config_too_large(tmp_path: Path):
upstream = _make_fake_upstream(tmp_path)
big = tmp_path / "huge.json"
big.write_bytes(b"x" * (1024 * 1024 + 1))
f = tmp_path / "t.txt"
f.write_text("hello world")
r = _run_adapter(
"detect", str(f), "--scheme", "kgw", "--config", str(big),
"--upstream-dir", str(upstream),
)
assert r.returncode == 3
assert "too large" in (r.stderr or "")
def test_cli_watermark_json_success(tmp_path: Path):
upstream = _make_fake_upstream(tmp_path)
prompt = tmp_path / "prompt.txt"
prompt.write_text("write about capybaras")
wm_out = tmp_path / "wm.txt"
uwm_out = tmp_path / "uwm.txt"
r = _run_adapter(
"watermark", str(prompt), "--scheme", "kgw",
"-o", str(wm_out), "-o2", str(uwm_out),
"--upstream-dir", str(upstream), "--device", "cpu", "--json",
)
assert r.returncode == 0, r.stderr
payload = json.loads(r.stdout)
assert payload["available"] is True
assert wm_out.read_text() == "WATERMARKED SAMPLE"
assert uwm_out.read_text() == "PLAIN SAMPLE"
def test_cli_watermark_runtime_error(tmp_path: Path):
upstream = _make_fake_upstream(tmp_path, fail_generate=True)
prompt = tmp_path / "prompt.txt"
prompt.write_text("write about capybaras")
r = _run_adapter(
"watermark", str(prompt), "--scheme", "kgw",
"--upstream-dir", str(upstream), "--device", "cpu", "--json",
)
assert r.returncode == 1
assert "boom" in (r.stderr or "")
def test_rewrite_markllm_detect_missing_venv(tmp_path: Path):
import rewrite_text
upstream = tmp_path / "MarkLLM"
upstream.mkdir()
result = rewrite_text._markllm_detect(
"hello", scheme="kgw", upstream_dir=str(upstream), model="x", timeout=5,
)
assert result["available"] is False
def test_rewrite_markllm_detect_parses_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
import rewrite_text
upstream = tmp_path / "MarkLLM"
if os.name == "nt":
venv_python = upstream / ".venv" / "Scripts" / "python.exe"
else:
venv_python = upstream / ".venv" / "bin" / "python"
venv_python.parent.mkdir(parents=True)
venv_python.write_text("")
(upstream / "watermark").mkdir()
payload = {"available": True, "is_watermarked": True, "score": 2.0}
captured: dict = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
captured["input"] = kwargs.get("input")
return SimpleNamespace(returncode=0, stdout=json.dumps(payload), stderr="")
monkeypatch.setattr(rewrite_text.subprocess, "run", fake_run)
result = rewrite_text._markllm_detect(
"hello", scheme="kgw", upstream_dir=str(upstream), model="x", timeout=5,
)
assert result["available"] is True
assert result["score"] == 2.0
assert captured["input"] == "hello"
assert captured["cmd"][0] == str(venv_python)
def test_rewrite_markllm_detect_adapter_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
import rewrite_text
upstream = tmp_path / "MarkLLM"
if os.name == "nt":
venv_python = upstream / ".venv" / "Scripts" / "python.exe"
else:
venv_python = upstream / ".venv" / "bin" / "python"
venv_python.parent.mkdir(parents=True)
venv_python.write_text("")
(upstream / "watermark").mkdir()
def fake_run(cmd, **kwargs):
return SimpleNamespace(returncode=3, stdout="", stderr="deps missing")
monkeypatch.setattr(rewrite_text.subprocess, "run", fake_run)
result = rewrite_text._markllm_detect(
"hello", scheme="kgw", upstream_dir=str(upstream), model="x", timeout=5,
)
assert result["available"] is False
assert "deps missing" in result["error"]
def test_markllm_preexec_default_off(monkeypatch: pytest.MonkeyPatch):
import rewrite_text
monkeypatch.delenv("WATERMARKS_MARKLLM_RLIMIT_AS", raising=False)
assert rewrite_text._markllm_preexec() is None
def test_markllm_preexec_env(monkeypatch: pytest.MonkeyPatch):
import rewrite_text
if os.name != "posix":
pytest.skip("preexec_fn is POSIX-only")
monkeypatch.setenv("WATERMARKS_MARKLLM_RLIMIT_AS", "0x40000000")
fn = rewrite_text._markllm_preexec()
assert callable(fn)
def test_rewrite_markllm_detect_applies_rlimit(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
):
import rewrite_text
if os.name != "posix":
pytest.skip("preexec_fn is POSIX-only")
upstream = tmp_path / "MarkLLM"
venv_python = upstream / ".venv" / "bin" / "python"
venv_python.parent.mkdir(parents=True)
venv_python.write_text("")
(upstream / "watermark").mkdir()
monkeypatch.setenv("WATERMARKS_MARKLLM_RLIMIT_AS", "1073741824")
captured: dict = {}
def fake_run(cmd, **kwargs):
captured["preexec_fn"] = kwargs.get("preexec_fn")
return SimpleNamespace(
returncode=0, stdout='{"available": true}', stderr=""
)
monkeypatch.setattr(rewrite_text.subprocess, "run", fake_run)
result = rewrite_text._markllm_detect(
"hello", scheme="kgw", upstream_dir=str(upstream), model="x", timeout=5,
)
assert result["available"] is True
assert callable(captured["preexec_fn"])
def test_rewrite_markllm_hook_records_before_after(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
import rewrite_text
def fake_detect(text, **kwargs):
return {"available": True, "is_watermarked": text == "ORIG", "score": 3.0}
monkeypatch.setattr(rewrite_text, "_markllm_detect", fake_detect)
monkeypatch.setattr(
rewrite_text, "call_ollama", lambda *a, **k: "REWRITTEN OUTPUT"
)
out, info = rewrite_text.rewrite(
"ORIG",
backend="ollama",
model="m",
base_url="http://127.0.0.1:11434",
api_key=None,
strength="paraphrase",
lang="French",
original_lang="English",
timeout=10,
layer_a_after=False,
temperature=0.9,
candidates=1,
markllm_scheme="kgw",
markllm_dir="/tmp/x",
markllm_model="opt-1.3b",
markllm_timeout=5,
)
assert out == "REWRITTEN OUTPUT"
mk = info["markllm"]
assert mk["before"]["is_watermarked"] is True
assert mk["after"]["is_watermarked"] is False
assert mk["cleared"] is True
assert "note" in mk