Files
watermarks-remover/tests/test_rewrite_text.py
T
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

264 lines
8.5 KiB
Python

"""Tests for Layer B rewrite_text hook (offline / print-prompt + client hardening)."""
from __future__ import annotations
import http.server
import json
import sys
import threading
import time
import urllib.error
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "service" / "scripts"
sys.path.insert(0, str(SCRIPTS))
import rewrite_text # noqa: E402
from rewrite_text import ( # noqa: E402
_check_remote,
_flag_env,
_lexical_divergence,
_select_candidate,
build_prompt,
rewrite,
)
def _rewrite_kwargs(**overrides):
kwargs = dict(
backend="print-prompt",
model=None,
base_url=None,
api_key=None,
strength="paraphrase",
lang="French",
original_lang="English",
timeout=5.0,
layer_a_after=True,
temperature=0.9,
candidates=1,
)
kwargs.update(overrides)
return kwargs
def test_build_prompt_paraphrase_is_word_choice_plus_syntax():
p = build_prompt("paraphrase", "Hello world facts 42.", lang="French", original_lang="English")
assert "Hello world facts 42." in p
assert "clause order" in p
assert "function words" in p
def test_build_prompt_humanize_and_code_contain_text():
for strength, keyword in (("humanize", "human wrote it"), ("code", "comments")):
p = build_prompt(strength, "ABC 123", lang="French", original_lang="English")
assert "ABC 123" in p
assert keyword in p
def test_build_prompt_unknown_strength_raises():
with pytest.raises(ValueError):
build_prompt("nope", "ABC", lang="French", original_lang="English")
def test_print_prompt_backend():
out, info = rewrite("Sample prose about water marks.", **_rewrite_kwargs())
assert info["mode"] == "print-prompt"
assert "Sample prose" in out
assert info["backend"] == "print-prompt"
assert info["temperature"] == 0.9
def test_print_prompt_ignores_candidates():
out, info = rewrite("Sample prose about water marks.", **_rewrite_kwargs(candidates=2))
assert info["mode"] == "print-prompt"
assert isinstance(out, str)
assert "Sample prose" in out
def test_structural_and_backtranslate_prompts():
for strength in ("structural", "backtranslate"):
p = build_prompt(strength, "ABC 123", lang="German", original_lang="English")
assert "ABC 123" in p
def test_lexical_divergence_identical_is_zero():
assert _lexical_divergence("the cat sat", "the cat sat") == 0.0
def test_lexical_divergence_fully_different_higher_than_similar():
similar = _lexical_divergence("the cat sat on the mat", "the dog sat on the mat")
different = _lexical_divergence("the cat sat on the mat", "alpha beta gamma delta")
assert different > similar
def test_lexical_divergence_empty_inputs():
assert _lexical_divergence("", "") == 0.0
assert _lexical_divergence("", "text") == 1.0
assert _lexical_divergence("text", "") == 1.0
def test_select_candidate_prefers_more_divergent():
original = "the cat sat on the mat"
best, scores = _select_candidate(
original,
["the cat sat on the mat", "the dog sat on the mat", "alpha beta gamma delta"],
)
assert best == "alpha beta gamma delta"
assert len(scores) == 3
# ---------------------------------------------------------------------------
# HTTP client hardening: default-deny allowlist, scheme guard, no redirects
# ---------------------------------------------------------------------------
def _rewrite_http_kwargs(base_url: str, **overrides):
kwargs = dict(
backend="openai-compatible",
model="m",
base_url=base_url,
api_key="sk-test-key-123",
strength="paraphrase",
lang="French",
original_lang="English",
timeout=5.0,
layer_a_after=False,
temperature=0.9,
candidates=1,
)
kwargs.update(overrides)
return kwargs
def test_check_remote_loopback_allowed_without_opt_in():
# Must not raise.
_check_remote("http://127.0.0.1:11434", allow_remote=False)
_check_remote("http://localhost:11434", allow_remote=False)
_check_remote("http://[::1]:11434", allow_remote=False)
def test_check_remote_denies_non_loopback_without_opt_in():
with pytest.raises(SystemExit):
_check_remote("http://example.com:11434", allow_remote=False)
def test_check_remote_allows_non_loopback_with_opt_in(capsys):
_check_remote("http://example.com:11434", allow_remote=True)
err = capsys.readouterr().err
assert "content will leave this machine" in err
def test_check_remote_denies_non_http_scheme():
with pytest.raises(SystemExit):
_check_remote("file:///etc/passwd", allow_remote=True)
def test_flag_env(monkeypatch):
assert not _flag_env("WATERMARKS_REWRITE_ALLOW_REMOTE")
monkeypatch.setenv("WATERMARKS_REWRITE_ALLOW_REMOTE", "1")
assert _flag_env("WATERMARKS_REWRITE_ALLOW_REMOTE")
monkeypatch.setenv("WATERMARKS_REWRITE_ALLOW_REMOTE", "true")
assert _flag_env("WATERMARKS_REWRITE_ALLOW_REMOTE")
monkeypatch.setenv("WATERMARKS_REWRITE_ALLOW_REMOTE", "0")
assert not _flag_env("WATERMARKS_REWRITE_ALLOW_REMOTE")
def test_openai_compatible_sends_reasoning_effort_when_set():
captured = {}
class Collector(http.server.BaseHTTPRequestHandler):
def do_POST(self):
captured["body"] = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"choices": [{"message": {"content": "rewritten"}}]}')
def log_message(self, format, *args): # noqa: A002
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Collector)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
result, _ = rewrite(
"hello",
**_rewrite_http_kwargs(
f"http://127.0.0.1:{server.server_address[1]}",
reasoning_effort="none",
),
)
assert result == "rewritten"
assert captured["body"]["reasoning_effort"] == "none"
captured.clear()
rewrite(
"hello",
**_rewrite_http_kwargs(
f"http://127.0.0.1:{server.server_address[1]}",
reasoning_effort=None,
),
)
assert "reasoning_effort" not in captured["body"]
finally:
server.shutdown()
def test_rewrite_denies_remote_host_without_opt_in():
with pytest.raises(SystemExit):
rewrite("secret text", **_rewrite_http_kwargs("http://example.com:11434"))
def test_rewrite_blocks_redirect_and_never_sends_key():
"""A 302 from the (loopback) endpoint must not re-send the API key to the
redirect target — the request must fail instead."""
state: dict = {"collector_port": None}
captured: dict = {}
class Redirector(http.server.BaseHTTPRequestHandler):
def do_POST(self):
self.send_response(302)
self.send_header(
"Location",
f"http://127.0.0.1:{state['collector_port']}/collect",
)
self.end_headers()
def log_message(self, format, *args): # noqa: A002
pass
class Collector(http.server.BaseHTTPRequestHandler):
def do_GET(self):
captured["auth"] = self.headers.get("Authorization")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(
b'{"choices": [{"message": {"content": "rewritten"}}]}'
)
def log_message(self, format, *args): # noqa: A002
pass
collector = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Collector)
redirector = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Redirector)
state["collector_port"] = collector.server_address[1]
threading.Thread(target=collector.serve_forever, daemon=True).start()
threading.Thread(target=redirector.serve_forever, daemon=True).start()
try:
with pytest.raises(urllib.error.HTTPError):
rewrite(
"secret text",
**_rewrite_http_kwargs(
f"http://127.0.0.1:{redirector.server_address[1]}"
),
)
time.sleep(0.2)
assert captured == {}, "redirect target received a request (key leak?)"
finally:
collector.shutdown()
redirector.shutdown()