From 9dd4fcf2a367a44fabbc9027dbafc18164a2b7fc Mon Sep 17 00:00:00 2001 From: audit-loop Date: Mon, 17 Aug 2026 18:49:26 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20'untell=20humanize=20--html'=20?= =?UTF-8?q?=E2=80=94=20self-contained=20HTML=20report=20with=20per-span=20?= =?UTF-8?q?lock=20explanations=20(Closes=20#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/human-queue.md | 25 +++ tests/test_humanize_html.py | 302 ++++++++++++++++++++++++++++++++++++ untell/rich_output.py | 239 ++++++++++++++++++++++++++++ untell/scripts/run.py | 71 ++++++++- 4 files changed, 635 insertions(+), 2 deletions(-) create mode 100644 tests/test_humanize_html.py diff --git a/.claude/human-queue.md b/.claude/human-queue.md index c206ad10..23bf42bf 100644 --- a/.claude/human-queue.md +++ b/.claude/human-queue.md @@ -1647,3 +1647,28 @@ RAN fast suite green (8427 passed) with all changes present; browser tests 75 SAW No queue entry in the original commit (discipline slip — repaired here). WHY New CLI surfaces (--browser auto, signals mode) are AMBER per the envelope. NEXT Issue #3 per-rule rubric tests + #2 live-probe e2e evidence: queued for the next wave. +## 2026-08-17 slice 2 (wave 6, issue #30) — AMBER — new `untell humanize --html` surface (new CLI flag + HTML artifact) + +WHAT Issue #30: `untell humanize --html` now writes a self-contained, deterministic HTML + report of the run to stdout: before/after text, pre/post scores, seed, whether it + rewrote, the unified --diff payload, and a per-span lock annotation (via the + explain/lock machinery — same single source of truth as lock()). Every dynamic value + is HTML-escaped (fuzz-tested against ", "alert(1)", + "", + '', + '">', + "bold & italic", + "quote ' single \" double", + "", +] + + +def test_all_hostile_shapes_are_inert_in_the_report() -> None: + for hostile in _HOSTILE: + html = render_humanize_html( + hostile, + hostile, # unchanged path still renders the hostile text + *_scores(0.5, 0.5), 1, "converged", + diff={"hunks": []}, seed=1, rewrote=False, + ) + # The escaped spelling appears; a live tag never does. Escaping neutralises the + # tag boundaries ("><"), so the real invariant is that no literal `tagname` opens. + assert "<" in html or "'" in html or """ in html + for marker in ("", ""): + assert marker not in html, f"{hostile!r} leaked a live tag {marker!r}" + + +def test_hostile_span_and_rationale_are_escaped() -> None: + """Locked spans and their rationale are user-carried text too — a hostile span must + not break out of its table cell or execute.""" + diff = { + "format": "untell-diff", "version": 1, "changed": False, "hunks": [], + "locked_spans": [ + {"sentinel": "⟦HZ0000⟧", "span": '', "rules": ["evil"], + "rationale": "note with markup"}, + ], + "locks_preserved": 0, + } + html = render_humanize_html("in", "in", *_scores(0.5, 0.5), 1, "converged", diff=diff, seed=1) + assert "" not in html + assert "<script>bad()</script>" in html + assert "<b>with</b>" in html + + +def test_hostile_warning_is_escaped() -> None: + html = render_humanize_html( + "a", "b", *_scores(0.8, 0.2), 1, "converged", + warning='carried payload', diff=_sample_diff("a", "b"), seed=1, rewrote=True, + ) + assert "" not in html + assert "<script>warn()</script>" in html + + +# --------------------------------------------------------------------------- +# the renderer carries the run +# --------------------------------------------------------------------------- + + +def test_the_report_carries_scores_seed_rewrote_and_stopped() -> None: + pre, post = _scores(0.82, 0.19) + html = render_humanize_html( + AI, EDITED, pre, post, 3, "converged", + diff=_sample_diff(AI, EDITED), seed=42, rewrote=True, tells_before=9, tells_after=3, + ) + assert "0.8200" in html and "0.1900" in html + assert "seed: 42" in html + assert "Rewrote" in html + assert "3 iterations" in html + assert "Converged" in html # the stopped reason, title-cased from the enum value + assert "AI tells" in html and "9" in html and "3" in html + + +def test_the_report_says_when_it_did_not_rewrite() -> None: + html = render_humanize_html(AI, AI, *_scores(0.8, 0.8), 0, "no-op", seed=3, rewrote=False, diff={"hunks": []}) + assert "Returned unchanged" in html or "no change" in html + + +def test_the_lock_annotations_are_present_with_rationale() -> None: + html = render_humanize_html(AI, EDITED, *_scores(0.80, 0.20), 1, "converged", diff=_sample_diff(AI, EDITED), seed=7) + assert "Locked spans" in html + assert "Smith (2020)" in html and "47%" in html + # Rationale text from the explain registry rides along. + assert "citation" in html.lower() or "number" in html.lower() + + +def test_the_diff_hunks_render() -> None: + html = render_humanize_html(AI, EDITED, *_scores(0.80, 0.20), 1, "converged", diff=_sample_diff(AI, EDITED), seed=7) + assert "Diff" in html + assert "@@" in html # a unified-diff hunk header + assert "deeply reshaped" in html + + +# --------------------------------------------------------------------------- +# CLI contract +# --------------------------------------------------------------------------- + + +class _EditRW: + name = "edit" + + def available(self): + return True + + def rewrite(self, text, score_result, threshold=0.30): + return text.replace("fundamentally transformed", "deeply reshaped") + + +class _IdentityRW: + name = "identity" + + def available(self): + return True + + def rewrite(self, text, score_result, threshold=0.30): + return text + + +def _fixed_score(mapping: dict, default: float = 0.8): + def _s(text, tier="full", threshold=0.3): + mx = mapping.get(text.strip(), default) + return { + "tier": tier, + "detectors": {"perplexity_burstiness": mx}, + "max": mx, + "mean": mx, + "threshold": threshold, + "flagged": mx >= threshold, + "scored": True, + } + + return _s + + +def _patch_cli(monkeypatch, rewriter, scores: dict, default: float = 0.8) -> None: + import untell.rewriter as rewriter_mod + import untell.scripts.run as run_mod + + monkeypatch.setenv("UNTELL_LITE_NO_TORCH", "1") + monkeypatch.setattr(rewriter_mod, "get_rewriter", lambda prefer=None: rewriter) + monkeypatch.setattr(run_mod, "score_text", _fixed_score(scores, default)) + + +def test_cli_escapes_injected_text(monkeypatch, capsys) -> None: + """A hostile document must come out of `--html` fully escaped — a defect if it + does not, so it is asserted at the CLI surface, not only in the renderer.""" + _patch_cli(monkeypatch, _IdentityRW(), {}, default=0.8) + hostile = " AI cost $500 per Smith (2020)." + rc = main(["--tier", "lite", "--html", "--max-iters", "1", "--best-of", "1", hostile]) + assert rc == 0 + html = capsys.readouterr().out + assert "" not in html + assert "<script>alert(1)</script>" in html + + +def test_cli_html_human_output(monkeypatch, capsys) -> None: + _patch_cli(monkeypatch, _EditRW(), {AI: 0.80, EDITED: 0.20}) + rc = main(["--tier", "lite", "--html", "--max-iters", "1", "--best-of", "1", AI]) + assert rc == 0 + out = capsys.readouterr().out + assert out.startswith(""), "stdout must BE the html document, not a wrapper" + assert "Locked spans" in out and "Diff" in out and "Before" in out and "After" in out + assert "Rewrote" in out + assert "Smith (2020)" in out and "47%" in out + # The standard terminal render must not also run. + assert "--- Original ---" not in out + assert "humanization complete (" not in out + + +def test_cli_html_json_envelope(monkeypatch, capsys) -> None: + _patch_cli(monkeypatch, _EditRW(), {AI: 0.80, EDITED: 0.20}) + rc = main(["--tier", "lite", "--html", "--json", "--max-iters", "1", "--best-of", "1", AI]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["format"] == "untell-html" + assert payload["version"] == 1 + assert payload["html"].startswith("") + assert payload["diff"]["format"] == "untell-diff" + assert payload["rewrote"] is True and payload["seed"] is not None + assert payload["pre"]["max"] == 0.80 and payload["post"]["max"] == 0.20 + spans = [row["span"] for row in payload["diff"]["locked_spans"]] + assert "Smith (2020)" in spans and "47%" in spans + assert payload["diff"]["locks_preserved"] == len(spans) == 2 + + +def test_cli_html_json_envelope_no_change(monkeypatch, capsys) -> None: + _patch_cli(monkeypatch, _IdentityRW(), {}, default=0.8) + rc = main(["--tier", "lite", "--html", "--json", "--max-iters", "1", "--best-of", "1", AI]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["rewrote"] is False + assert payload["diff"]["changed"] is False + assert "Returned unchanged" in payload["html"] or "no change" in payload["html"] + + +def test_cli_html_json_error_path_holds(monkeypatch, capsys) -> None: + """`--html --json` with no input must answer JSON and exit 2, like the other modes.""" + _patch_cli(monkeypatch, _IdentityRW(), {}) + rc = main(["--tier", "lite", "--html", "--json", " "]) + assert rc == 2 + parsed = json.loads(capsys.readouterr().out) + assert "error" in parsed + + +def test_cli_html_reports_rewriter_failure(monkeypatch, capsys) -> None: + class _BrokenRW: + name = "broken" + + def available(self): + return True + + def rewrite(self, text, score_result, threshold=0.30): + raise RuntimeError("simulated rewriter failure") + + _patch_cli(monkeypatch, _BrokenRW(), {}, default=0.9) + rc = main(["--tier", "lite", "--html", "--max-iters", "1", "--best-of", "1", AI]) + assert rc == 1 + out = capsys.readouterr().out + assert "ERROR" in out and "rewriter failed" in out diff --git a/untell/rich_output.py b/untell/rich_output.py index 551d41b3..5cdecc79 100644 --- a/untell/rich_output.py +++ b/untell/rich_output.py @@ -523,3 +523,242 @@ def progress_iteration(current: int, total: int, tier: str, score: float | None score_part = f" P(AI)={score:.2f}" if score is not None else "" _CONSOLE.print(f" [dim]→ Iteration {current}/{total}[/] tier={tier}{score_part}") return None + + +# --------------------------------------------------------------------------- +# HTML report (`untell humanize --html`, issue #30) +# --------------------------------------------------------------------------- +# +# A self-contained, deterministic HTML rendering of a humanize loop run. Unlike +# the rich terminal views this module also drives, the artifact is meant to be +# saved and shared, so it carries everything a reader needs to trust the run: +# before/after text, pre/post scores, seed, whether it rewrote, the unified +# --diff payload and — via the explain machinery the loop's own lock builds on — +# a per-span annotation of every frozen fact and why it was frozen. +# +# Determinism: the same inputs return byte-identical output. There is no +# timestamp, no random, no process-dependent value, and the CSS is inlined, so +# two runs on the same text/seed produce two files that diff to nothing. +# +# Escaping is load-bearing, not cosmetic. Every value that came from the user's +# text — original, final, each locked span, rules, rationale, warning — passes +# through `html.escape`, so text that *is* attacker-shaped markup (a "