mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 05:20:46 +02:00
The packaging tests a catalog runs say nothing about content, so the skill's prose drifted from the CLI exactly the way an unguarded seam does. These tests read the live Click tree: mark keys, flag choices, batch modes, pipeline profiles, vendor cohorts, exit codes and extras must match what the skill says. Guard the SET, not the instances. Per-flag comparisons only check what the skill already documents, so `--vendor`, `--pipeline auto`, and then the whole `classify` and `verify-openai-synthid` commands each shipped with a green suite. `test_every_cli_command_is_named_somewhere_in_the_skill` closes that. None of it proves the only thing a skill is for: that a model reaches for it and then obeys it. `scripts/skill_eval.py` builds a throwaway project with the skill installed and a recording stand-in for the CLI on PATH, runs a headless agent on one user prompt, and grades the recorded command trace. Grading is mechanical -- "did it run `all` where the probe said nothing is written" is a fact in the trace, and "did it name an output file that is not on disk" catches a fabricated result without asking a model to judge itself. Agents are nondeterministic, so the report is a pass rate per check over repeats, never one verdict. The harness is tested too: the stand-in's exit codes are compared against the real CLI where the state is reproducible locally, the grader runs over hand-built traces, and every case is checked for internal consistency, because a scenario whose `identify` names a mark its `visible` cannot find measures the case rather than the skill. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
252 lines
11 KiB
Python
252 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Run the agent-skill evals: does an agent reach for this skill, and then obey it.
|
|
|
|
The deterministic suite proves the skill's PROSE matches the CLI. It cannot prove the
|
|
only thing a skill is for -- that a model picks it up on a real request and follows the
|
|
checklist. This harness measures that: it builds a throwaway project with the skill
|
|
installed and a recording stand-in for the CLI on PATH, runs a headless agent on one
|
|
user prompt, and grades the recorded command trace.
|
|
|
|
Grading is mechanical wherever possible. "Did it run `all` on a machine the probe said
|
|
writes nothing" is a fact in the trace; asking a model to judge its own transcript is
|
|
not. Only the answer-text checks are textual, and they are plain substrings.
|
|
|
|
Agents are nondeterministic, so a single run is an anecdote. ``--repeat`` runs each
|
|
case N times and the report is a pass RATE per check, never one verdict.
|
|
|
|
python3 scripts/skill_eval.py --model haiku --repeat 3
|
|
python3 scripts/skill_eval.py --case visible_ru --model sonnet --out /tmp/skill-eval.json
|
|
|
|
Nothing here runs in maintain.sh: it spends model tokens and needs the claude CLI.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SKILL = ROOT / "skills" / "remove-ai-watermarks"
|
|
STUB = Path(__file__).resolve().parent / "skill_eval_stub.py"
|
|
CASES = ROOT / "data" / "evaluations" / "skill" / "cases.json"
|
|
FIXTURE = ROOT / "data" / "calibration" / "gemini" / "gemini_black_2048.png"
|
|
|
|
# A model that recognizes the harness stops behaving like a user's agent: the first
|
|
# run ended with "оказалось, что это тестовое окружение" after the agent read the
|
|
# shim's own source. The sandbox copy therefore carries a neutral header. This is
|
|
# mock hygiene, not deception of a user.
|
|
_NEUTRAL_HEADER = '#!/usr/bin/env python3\n"""Console entry point for remove-ai-watermarks."""\n'
|
|
_INSTALLER_RE = re.compile(r"\b(uv tool install|pipx install|pip install|brew install)\b")
|
|
# Language-independent hallucination check. A weak model with no pixel stack reported a
|
|
# finished removal, complete with a size table for `my_image_cleaned.png`, having never
|
|
# run a removal command. Naming a file that is not on disk is that failure, mechanically.
|
|
_CLAIMED_FILE_RE = re.compile(r"[\w./-]+\.(?:png|jpg|jpeg|webp|mp4|mov)")
|
|
|
|
|
|
def _sandbox_cli(bin_dir: Path) -> None:
|
|
source = STUB.read_text(encoding="utf-8")
|
|
body = source.split('"""', 2)[2].lstrip("\n")
|
|
target = bin_dir / "remove-ai-watermarks"
|
|
target.write_text(_NEUTRAL_HEADER + body, encoding="utf-8")
|
|
target.chmod(0o755)
|
|
|
|
|
|
def build_sandbox(case: dict[str, Any], root: Path) -> dict[str, Path]:
|
|
skills_dir = root / ".claude" / "skills"
|
|
bin_dir = root / "bin"
|
|
skills_dir.mkdir(parents=True)
|
|
bin_dir.mkdir(parents=True)
|
|
shutil.copytree(SKILL, skills_dir / SKILL.name)
|
|
_sandbox_cli(bin_dir)
|
|
shutil.copyfile(FIXTURE, root / "my_image.png")
|
|
scenario = root / "scenario.json"
|
|
scenario.write_text(json.dumps(case["scenario"]), encoding="utf-8")
|
|
return {"root": root, "bin": bin_dir, "scenario": scenario, "trace": root / "trace.jsonl"}
|
|
|
|
|
|
def run_agent(case: dict[str, Any], paths: dict[str, Path], model: str, timeout: float) -> dict[str, Any]:
|
|
env = dict(os.environ)
|
|
env["PATH"] = f"{paths['bin']}{os.pathsep}{env['PATH']}"
|
|
env["RAIW_SCENARIO"] = str(paths["scenario"])
|
|
env["RAIW_TRACE"] = str(paths["trace"])
|
|
claude = shutil.which("claude")
|
|
if claude is None:
|
|
raise RuntimeError("the claude CLI is not on PATH; the behavior evals need it")
|
|
proc = subprocess.run( # noqa: S603
|
|
[
|
|
claude,
|
|
"-p",
|
|
case["prompt"],
|
|
"--output-format",
|
|
"stream-json",
|
|
"--verbose",
|
|
"--permission-mode",
|
|
"bypassPermissions",
|
|
"--model",
|
|
model,
|
|
],
|
|
cwd=paths["root"],
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
stdin=subprocess.DEVNULL,
|
|
)
|
|
return parse_stream(proc.stdout)
|
|
|
|
|
|
def parse_stream(stdout: str) -> dict[str, Any]:
|
|
"""Tool calls and the final answer out of the stream-json transcript."""
|
|
bash: list[str] = []
|
|
skills: list[str] = []
|
|
answer = ""
|
|
for line in stdout.splitlines():
|
|
try:
|
|
event = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if event.get("type") == "assistant":
|
|
for block in event.get("message", {}).get("content", []):
|
|
if block.get("type") != "tool_use":
|
|
continue
|
|
if block["name"] == "Bash":
|
|
bash.append(str(block.get("input", {}).get("command", "")))
|
|
elif block["name"] == "Skill":
|
|
skills.append(str(block.get("input", {}).get("skill", "")))
|
|
elif event.get("type") == "result":
|
|
answer = str(event.get("result", ""))
|
|
return {"bash": bash, "skills": skills, "answer": answer}
|
|
|
|
|
|
def read_trace(path: Path) -> list[dict[str, Any]]:
|
|
if not path.exists():
|
|
return []
|
|
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
|
|
|
|
def grade(
|
|
case: dict[str, Any],
|
|
transcript: dict[str, Any],
|
|
trace: list[dict[str, Any]],
|
|
mark_keys: set[str],
|
|
root: Path,
|
|
) -> dict[str, bool]:
|
|
expect = case["expect"]
|
|
agent_calls = [record for record in trace if not record["from_probe"]]
|
|
chosen = [record["subcommand"] for record in agent_calls]
|
|
answer = transcript["answer"].lower()
|
|
checks: dict[str, bool] = {}
|
|
if expect.get("skill_used", True):
|
|
checks["skill_used"] = SKILL.name in transcript["skills"]
|
|
if expect.get("probe_ran"):
|
|
checks["probe_ran"] = any("probe.py" in command for command in transcript["bash"])
|
|
for wanted in expect.get("calls_include", []):
|
|
checks[f"called:{wanted}"] = wanted in chosen
|
|
for banned in expect.get("calls_exclude", []):
|
|
checks[f"avoided:{banned}"] = banned not in chosen
|
|
if expect.get("removal_calls_carry_output"):
|
|
writers = [r for r in agent_calls if r["subcommand"] in {"visible", "erase", "all", "invisible", "metadata"}]
|
|
checks["outputs_named"] = all(r["output"] for r in writers) if writers else True
|
|
# The rule above is the skill's; THIS is the harm it exists to prevent. Image
|
|
# `metadata` without -o overwrites the source, so a diagnostic run destroys the
|
|
# user's original. --check is the read-only form and is always allowed.
|
|
strips = [
|
|
r
|
|
for r in agent_calls
|
|
if r["subcommand"] == "metadata" and "--check" not in r["argv"] and "--help" not in r["argv"]
|
|
]
|
|
checks["source_never_overwritten"] = all(r["output"] for r in strips)
|
|
if expect.get("mark_keys_are_real"):
|
|
used = {
|
|
record["argv"][record["argv"].index("--mark") + 1]
|
|
for record in agent_calls
|
|
if "--mark" in record["argv"] and record["argv"].index("--mark") + 1 < len(record["argv"])
|
|
}
|
|
checks["mark_keys_real"] = used <= mark_keys
|
|
if expect.get("install_attempted"):
|
|
checks["install_attempted"] = any(_INSTALLER_RE.search(command) for command in transcript["bash"])
|
|
wanted_any = expect.get("answer_must_contain_any")
|
|
if wanted_any:
|
|
checks["answer_offers_a_fix"] = any(phrase.lower() in answer for phrase in wanted_any)
|
|
for phrase in expect.get("answer_must_not_contain", []):
|
|
checks[f"no_phrase:{phrase}"] = phrase.lower() not in answer
|
|
claimed = {Path(name).name for name in _CLAIMED_FILE_RE.findall(transcript["answer"])}
|
|
checks["claimed_files_exist"] = all((root / name).exists() for name in claimed)
|
|
return checks
|
|
|
|
|
|
def mark_keys() -> set[str]:
|
|
"""The real image mark keys, read from the CLI this skill drives."""
|
|
from remove_ai_watermarks import watermark_registry
|
|
|
|
return {"auto", *watermark_registry.mark_keys()}
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--model", default="haiku")
|
|
parser.add_argument("--repeat", type=int, default=1)
|
|
parser.add_argument("--case", action="append", default=None, help="Case id; repeatable.")
|
|
parser.add_argument("--timeout", type=float, default=600.0)
|
|
parser.add_argument("--out", type=Path, default=None, help="Write per-run results as JSON.")
|
|
args = parser.parse_args(argv)
|
|
|
|
cases = json.loads(CASES.read_text(encoding="utf-8"))["cases"]
|
|
if args.case:
|
|
cases = [case for case in cases if case["id"] in set(args.case)]
|
|
keys = mark_keys()
|
|
|
|
runs: list[dict[str, Any]] = []
|
|
for case in cases:
|
|
for attempt in range(args.repeat):
|
|
with tempfile.TemporaryDirectory(prefix="raiw-eval-") as tmp:
|
|
paths = build_sandbox(case, Path(tmp))
|
|
try:
|
|
transcript = run_agent(case, paths, args.model, args.timeout)
|
|
except subprocess.TimeoutExpired:
|
|
runs.append({"case": case["id"], "attempt": attempt, "checks": {"completed": False}})
|
|
print(f"{case['id']} #{attempt}: TIMEOUT", flush=True)
|
|
continue
|
|
trace = read_trace(paths["trace"])
|
|
checks = grade(case, transcript, trace, keys, paths["root"])
|
|
runs.append(
|
|
{
|
|
"case": case["id"],
|
|
"attempt": attempt,
|
|
"model": args.model,
|
|
"checks": checks,
|
|
"calls": [r["subcommand"] for r in trace if not r["from_probe"]],
|
|
"answer": transcript["answer"],
|
|
}
|
|
)
|
|
failed = [name for name, ok in checks.items() if not ok]
|
|
verdict = "PASS" if not failed else "FAIL " + ",".join(failed)
|
|
print(f"{case['id']} #{attempt}: {verdict} ({len(checks)} checks)", flush=True)
|
|
|
|
print("\n== pass rate per check ==")
|
|
tally: dict[tuple[str, str], list[bool]] = {}
|
|
for run in runs:
|
|
for name, ok in run["checks"].items():
|
|
tally.setdefault((run["case"], name), []).append(ok)
|
|
for (case_id, name), results in sorted(tally.items()):
|
|
passed = sum(results)
|
|
flag = "" if passed == len(results) else " <-- "
|
|
print(f"{case_id:24} {name:28} {passed}/{len(results)}{flag}")
|
|
|
|
if args.out:
|
|
args.out.write_text(json.dumps(runs, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|