11 Commits
Author SHA1 Message Date
ce90a71c9b fix: treat a failed c2patool run as inconclusive, not as "no C2PA" (#156)
c2patool exits non-zero both when an asset carries no manifest and when the
binary itself fails, and run_optional_tools only ever substring-matched the
output. A probe that died before main() produced has_manifest: False with no
other signal, so inspect reported has_c2pa: false -- the same answer it gives
for a genuinely clean asset.

Reproduced on Apple Silicon: service/Dockerfile pins a multi-arch base digest,
so an arm64 host builds an arm64 image while still installing the
x86_64-unknown-linux-gnu c2patool release (upstream publishes no linux-aarch64
build). c2patool dies with "rosetta error: failed to open elf at
/lib64/ld-linux-x86-64.so.2" and exit -5, and /capabilities kept reporting
c2patool: true because which() only finds the file.

Exposure is largest for PDF, where inspect_pdf documents exiftool and c2patool
as the more reliable detectors. For JPEG/PNG the native APP11 and PNG-chunk
scans still catch hard-bound C2PA, so there it costs corroboration rather than
the only signal.

- run_optional_tools marks a run conclusive only when it found a manifest or
  said in so many words that there is none; anything else sets ok: False
- inspect_image and inspect_pdf surface that as an inconclusive note, worded
  to land in the informational confidence bucket
- /capabilities probes each tool's version flag instead of trusting which()

Issues #1 and #3 fixed the opposite direction of this same function ("No claim
found" matching "claim"). This closes the false-negative side.

Co-authored-by: Italo Rodrigues <italo@pvwi.com.br>
Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
2026-08-19 10:34:43 -07:00
063119d7e5 feat(server): add POST /detect/batch endpoint for batch watermark detection (#151)
* feat(server): add POST /detect/batch endpoint for batch watermark detection (#149)

* fix: satisfy ruff lint and format checks

---------

Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
2026-08-18 13:38:23 -07:00
939df90ccd fix(server): preserve image format extensions in /clean and use safe writes in av_meta (#150)
* fix(server): preserve image format extensions in /clean and use safe writes in av_meta (#148)

* style: apply ruff formatting to server.py and test_http_server.py

---------

Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
2026-08-18 12:37:26 -07:00
275a087a07 feat: audio/video AI/C2PA metadata stripping (MP4/MOV, WAV, MP3) (#139)
The file-cleaners layer covered 15 formats -- all image, document, or
text -- and zero audio/video. That gap gets more expensive every month:
Sora, Veo, ElevenLabs, and Suno all embed provenance through the same
mechanisms image generators do, just in different containers.

New av_meta.py adds inspect/clean for:
- MP4/MOV/M4A/M4V: top-level C2PA (jumb/c2pa box) and XMP (uuid box)
  detection/stripping reuse inspect_isobmff()/strip_isobmff() from
  image_meta.py unchanged -- that's exactly the mechanism the C2PA spec
  defines for ISOBMFF-family containers, already proven for AVIF/HEIC.
  moov/udta (where generator/tool tags live) is handled separately since
  it's MP4-specific.
- WAV: RIFF LIST INFO chunk + embedded id3 chunk.
- MP3: ID3v2 frames, per-frame for v2.3/v2.4, whole-tag fallback for
  v2.2 (3-byte frame IDs are detected but not decomposed, so a partial
  rewrite is never attempted there).

Every box/chunk/frame is either kept byte-identical or dropped whole --
nothing does a partial in-place rewrite of a payload, so a container can
never come out semantically mangled. Default strip_all_metadata=True
matches this project's existing default (privacy-first: drop everything,
--keep-non-ai-metadata narrows to only AI-flagged content), same as the
image cleaners.

Wired through the full dispatch stack so the feature isn't a half
integration: format_dispatch.py (new "av" Kind), inspect_file.py /
clean_file.py (--as av), audit_lib.py (so audit_dir.py's CI/SARIF path
and the pre-commit hooks from #135 both cover audio/video too), and
server.py (HTTP /inspect and /clean).

Closes #134

Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
2026-08-18 07:47:43 -07:00
7e5b4c1a14 feat: add POST /clean/batch and /inspect/batch endpoints (#137)
Directory-scale cleaning already exists in the CLI (audit_dir.py, -j
concurrency, SARIF export from #101), but the HTTP service handled one
file per request. Any web app or CI step talking to the service over
HTTP instead of the CLI paid N full round trips to clean N files.

Extract the single-file /inspect and /clean logic into _inspect_payload
and _clean_payload so both the existing single-file endpoints and the
new batch endpoints run the identical pipeline — no duplicated cleaning
logic. A malformed entry in a batch (bad base64, unknown option,
unrecognized format) surfaces as that entry's "ok": false with an
"error" string instead of aborting the rest of the batch.

Capped at WATERMARKS_MAX_BATCH_FILES per request (default 50) as
defense-in-depth against a request packing many tiny files into one
call; the existing MAX_BODY_BYTES envelope cap already bounds total
payload size the same as a single-file request.

/openapi.json picks up both routes automatically since the spec is
generated from the route table.

Closes #136

Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
2026-08-18 07:16:02 -07:00
a2e72ed019 feat: vendor text-watermark detection (Gemini SynthID, Claude seam, MarkLLM) + SynthID image scorer sidecar (#109)
* feat: add vendor text-watermark detection and SynthID image scorer sidecar

Adds Layer B watermark detection as a first-class service capability:

- text_detectors.py: a registry of text-watermark detectors behind one
  interface — Google's official SynthID-text detector via the Gemini API
  (taskType DETECT_TEXT_WATERMARK), a Claude placeholder ready for
  Anthropic's announced detection API, and the MarkLLM research harness
  (KGW / SynthID, same-config-only). Fail-soft: unconfigured or errored
  detectors never block cleaning.
- server.py: new POST /detect endpoint, detect_before / detect_after options
  on /clean (before/after scoring for text and images), an opt-in
  /inspect "detect" flag, and /capabilities gains text_detectors and
  scorers.synthid_http.
- synthid_score_server.py: a stdlib HTTP sidecar for the reverse-SynthID
  scorer, so the published core image never bundles the non-commercial
  upstream code; wired via WATERMARKS_SYNTHID_SCORER_URL.
- score_synthid.py: extract score_file() so the CLI and the sidecar share
  one implementation.
- compose.yaml / Dockerfile.synthid / .env.example: wr-synthid-score sidecar
  service and env wiring.
- README + skill docs, plus tests for the detectors, the /detect endpoint,
  and the image sidecar.

* feat: per-candidate watermark detection for Layer B rewrite candidates

When --candidates N (N > 1) is combined with --markllm-scheme or
WATERMARKS_GEMINI_API_KEY, run every configured text detector from the
text_detectors.py registry on each candidate and report per-candidate
measurements in --json-stats as candidate_scores entries carrying
lexical_divergence, selection_score, selected, and per-detector reports
(is_watermarked, score, threshold where the detector provides one).

Candidate selection stays purely lexical; the detections are observability
for correlating lexical divergence with watermark removal (issue #106).

Converges rewrite_text.py onto the shared detector registry:
- MarkLLMTextDetector gains constructor overrides (scheme, upstream_dir,
  model, timeout) plus the checkout-venv interpreter preference and the
  WATERMARKS_MARKLLM_RLIMIT_AS preexec guard ported from rewrite_text.py;
  the old _markllm_detect / _venv_python / _markllm_preexec helpers are gone.
- run_all_text_detectors() accepts an injected MarkLLM instance and an
  include_markllm switch so CLI flag gating stays intact.
- before/after/cleared semantics unchanged; detection remains fail-soft.

* docs: pin Watermarks in the Sand reference to arXiv v5

* fix: mark only one rewrite candidate as selected (#110)

---------

Co-authored-by: Zhenxin Ai <142008897+ai-kunkun@users.noreply.github.com>
2026-08-17 18:31:22 -07:00
Guillaume Meyer (The Opinionated Man)andGitHub 6df80e77a4 fix: correctness and security hardening of the cleaning scripts (#122) (#126)
* fix: rewrite ODT/EPUB manifests and measure real zip bytes (#122)

Two container correctness/security fixes from issue #122:

- clean_odt dropped marker-bearing parts while leaving their entries in
  META-INF/manifest.xml, so readers flagged the package as damaged. It is
  now two-pass: compute the dropped set, then rewrite the manifest
  attribute-order-independently, and write each part exactly once. The same
  bug class in clean_epub (dropped parts left in the OPF manifest, plus
  dangling spine itemrefs) gets the same two-pass treatment.
- The zip budget trusted ZipInfo.file_size from the archive's own central
  directory, so a crafted DOCX/ODT could declare a tiny size and still
  expand via zf.read. Budgets are now charged on actual decompressed bytes
  via _read_zip_member (streaming, cap enforced mid-read), with the declared
  size kept only as a fast-path pre-reject.

* fix: classify unrecognized bytes as "unknown", not text (#122)

Two classification defects from issue #122:

- format_dispatch.classify_bytes fell back to "text" for any unrecognized
  file, so a binary with valid UTF-8 runs could be decoded and written back
  mangled (corrupted with --in-place) in clean_file auto mode. Unrecognized
  bytes now classify as "unknown"; clean_file refuses them in auto mode
  (exit 2, no write, router advice) and --as text / --force-text are the
  explicit opt-ins. inspect_file reports kind "unknown" (exit 0), audit_lib
  records a non-actionable item, and the HTTP server answers /inspect with
  kind "unknown" but rejects /clean of unknown formats (400).
- classify(path) read the whole file to sniff a header, and only a full read
  could detect zip containers. It now routes known extensions without
  reading, sniffs a 4096-byte header once for images and prefix-based
  containers, and reads the whole file only when the header is a zip local
  header (PK), where the container signature lives in the central directory.

* feat: distinct exit code for partial audits (#122)

audit_dir and audit_website reported success (0) even when some files or
URLs could not be scanned; the exit status was computed only over the items
that succeeded. A scan that is missing items is not a clean scan.

- common.EXIT_PARTIAL = 3, with precedence: partial (3) > actionable (1)
  > clean (0) — an incomplete audit is the more important CI signal.
- audit_dir returns 3 when any file was skipped/failed; audit_website
  returns 3 when any URL failed to fetch or inspect. Both are independent
  of the output format (human/json/sarif already share one return).

* fix: verify the pinned upstream ref on existing checkouts (#122)

setup_ctrlregen.sh/setup_synthid.sh (and their .ps1 twins) only verified
the pinned commit in the fresh-clone branch; an existing checkout at an
unknown or drifted revision was silently reused, defeating the commit pin.

All four scripts now check HEAD against the pinned ref in the
existing-checkout branch too, and repair by fetch + detach checkout
(re-applying the sparse-checkout set), failing hard if the ref cannot be
reached or the re-pin does not land on it.

* docs: unknown-format behavior, audit exit codes, backend isolation (#122)

- README: clean_file no longer auto-cleans unrecognized formats (--as text
  / --force-text are the opt-ins), and the CtrlRegen bootstrap documents the
  isolation expectation for its research-era dependency pins plus the new
  re-pin check on existing checkouts.
- SKILL.md: audit exit codes (0/1/2/3, partial=3) and a note that /clean
  requires a name with a known extension.
- audit_website: document why stdlib ElementTree is used (stdlib-first) and
  that defusedxml is the fallback if that policy changes (DTD rejection stays).
- requirements-ctrlregen.txt: advisory/isolation note for the pinned research
  dependencies.

* test: ODT manifest and EPUB OPF dangling-ref regressions (#122)

- clean_odt: dropped marker-bearing parts remove their META-INF/manifest.xml
  file-entry (attribute-order-independent), exactly one manifest entry, root
  and surviving entries kept, and the manifest is byte-identical when nothing
  is dropped.
- clean_epub: dropped non-content parts lose their <item> entry in the OPF
  manifest, so the book no longer references removed members.
2026-08-17 17:15:35 -07:00
5e43d53fc3 fix: validate clean option types (#111)
Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
2026-08-17 16:08:31 -07:00
Guillaume Meyer (The Opinionated Man)andGitHub 723627716c chore: add Ruff linting and formatting with CI enforcement (#103)
Introduce Ruff (pinned at 0.16.3) as the project linter + formatter and
enforce it in CI:

- requirements-dev.txt: pin ruff==0.16.3 (exact pins, no drift)
- ruff.toml: line-length 100, target py312; rule set E/F/W/I/UP/B/SIM/RUF/PLW/S
  with deliberate ignores (E501 for content strings, S603 for safe_arg
  subprocess calls, S101 asserts in tests) and per-file test ignores
- Makefile: add lint / format / lint-fix targets
- .github/workflows/ci.yml: add lint job (ruff check + format --check)
- .gitignore: whitelist ruff.toml

Also fix every finding the new gate surfaced so CI is green:
- 109+ auto-fixes from ruff --fix (import sorting, simplifications,
  unused vars, re.I aliases, etc.)
- explicit check=False on all subprocess.run calls (PLW1510)
- harden sitemap XML parsing: reject DTD/entity declarations (S314)
- replace hardcoded /tmp paths in tests with tmp_path (S108)
- narrow/annotate intentional bare excepts (S110/S112), bind loop vars
  in closures (B023), raise ... from None (B904), strict= for zip (B905)
- ruff format applied across service/ and tests/

Verified: ruff check + ruff format --check pass; 287 tests pass, 1 skip.
2026-08-16 17:32:40 -07:00
Poorvith M PandGitHub c7200d20c2 feat: add zero-LLM statistical and stylometric AI text detector for CI and audits (#68) (#69) 2026-08-15 14:20:50 -07:00
Guillaume Meyer (The Opinionated Man)andGitHub 55d4bdc9fc feat: split skill from service, add HTTP API and Docker distribution (#60)
* feat: split skill from service, add HTTP API and Docker distribution

The agent skill (skills/remove-ai-marks/) is now a code-free remote client:
all implementation moved to service/scripts/ and runs behind a stdlib HTTP
service (server.py) with /health, /capabilities, /inspect, /clean and a
dynamically generated OpenAPI 3.0.3 spec at /openapi.json.

- Move scripts/ and the backend Dockerfiles under service/
- server.py: JSON/base64 HTTP entrypoint with size caps, binary guard,
  atomic writes, loopback default, optional bearer auth
- Core Dockerfile (exiftool/qpdf/c2patool preinstalled) and a GHCR publish
  workflow for the core/markllm/markdiffusion images
- compose.yaml (wr-* services, harness/heavy profiles) + compose-check.sh
  to validate the running stack (exit code only)
- Fix markllm image build (tokenizers 0.22.2, CPU-only torch) and ctrlregen
  build (python:3.11 base for the 2023-era research pins)
- Fix markllm/markdiffusion harness images missing common.py at runtime

* docs: add .env.example and service configuration guide

* fix: disable chain-of-thought for openai-compatible Layer B rewrites

deepseek-v4-flash is a reasoning model: a one-line paraphrase burned 9,894
reasoning tokens (~100s) and hit the default timeout. Send
reasoning_effort=none by default for the openai-compatible backend
(--reasoning-effort / WATERMARKS_REWRITE_REASONING_EFFORT; 'off' omits the
parameter), cutting the same rewrite to ~1s / 12 tokens. Tested end-to-end
against api.deepseek.com.

* fix: sanitize client-supplied filename in HTTP service

CodeQL 'uncontrolled data in path expression' (server.py): a name like
'../../x' flowed into Path(tmpdir) / name, letting an upload escape the
request temp dir on write. Sanitize name to its basename in _decode_input
(_safe_name) and refuse any joined path whose parent is not the tmpdir at
the write sites (_tmp_path). Tests cover traversal names.

* chore: gitignore .env (contains local rewrite credentials)

* chore: deny-by-default gitignore and dockerignore; document compose env config

.gitignore and service/.dockerignore now exclude everything by default and
explicitly allow only what is publishable/needed: tracked source, docs,
tests, .github, and (for images) the service/scripts/ tree that every
Dockerfile COPYs. Root .dockerignore documents that all builds use service/
as context. README Configuration section now covers .env setup for docker
compose, host-side export for CLI runs, and the full variable table.
2026-08-14 15:42:48 -07:00