constitution · epochs · watch · epoch 3

comparison

c_6f04dcb2e38c (tommy-mor) vs c_c0df72aee6da (tommy-mor)

download prompt · raw event · cmp_9c1f3b8e3484a9

council reasoning

~anthropic/claude-sonnet-latest · winner B · 65:35 · permalink

Side B fixes a genuine XSS vulnerability (raw untrusted Reddit HTML rendered via PreEscaped) with a real, tested sanitization module applied at both render sites, which is a lasting security fix. Side A is a moderate refactor removing legacy projection code paths and simplifying evidence pages, which is useful cleanup but lower-impact than closing a script-injection hole, and much of B's diff (Cargo.lock) is generated noise rather than authored logic.

~x-ai/grok-latest · winner A · 2:1 · permalink

A removes dual-path legacy projection helpers (_legacy_commit_row, _legacy_observation) and all GitDiscovery/Emission fallbacks from epoch/commit pages and downloads, forcing Evidence envelopes only (with required Emission fields and updated tests), a lasting schema/design cleanup; B is a correct but narrower XSS fix that adds ammonia sanitization of body_html at two PreEscaped sites plus unit tests.

openai/gpt-chat-latest · winner B · 4:1 · permalink

Side B fixes a concrete security issue by sanitizing untrusted Reddit `body_html` with the `ammonia` library before passing it to `maud::PreEscaped`, adds focused tests verifying scripts and event handlers are removed, and applies the protection consistently in both rendering paths. Side A primarily removes legacy GitDiscovery projection code and makes the application Evidence-only, simplifying behavior and updating tests/schema requirements, but it largely deletes compatibility logic rather than adding a broadly protective capability.

sides

A — c_6f04dcb2e38c (tommy-mor)

message

[bda5f8aa] Remove legacy evidence projection; Evidence envelopes only.

Epoch and commit pages no longer invent history from bare GitDiscovery rows. Production ledger will be wiped to re-emit under the current schema.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/constitution.py b/constitution.py
index 4dd5b9dfbba231d46289c490f91b1dd5b1018bcf..26ba130e885e0e69fb7874ca5c3f07f42100a150 100644
--- a/constitution.py
+++ b/constitution.py
@@ -210,10 +210,10 @@ class Emission:
     distributions: dict   # author -> amount str
     ranking: dict         # author -> score str
     models_used: list
-    discovery_snapshot_id: str = ""  # empty only for pre-discovery ledger history
-    evidence_schema_version: int = 1
-    ranking_run_id: str = ""
-    ranking_event_id: str = ""
+    discovery_snapshot_id: str
+    evidence_schema_version: int
+    ranking_run_id: str
+    ranking_event_id: str
 
 
 @event
@@ -502,26 +502,6 @@ def _epochs_in_ledger() -> list[int]:
     return sorted(epochs)
 
 
-def _legacy_commit_row(commit_id: str) -> tuple[GitDiscovery | None, dict | None]:
-    for discovery in store.read():
-        if not isinstance(discovery, GitDiscovery):
-            continue
-        for commit in discovery.commits:
-            if commit_id_for_oid(commit["oid"]) == commit_id:
-                return discovery, commit
-    return None, None
-
-
-def _legacy_observation(commit_id: str) -> tuple[GitDiscovery | None, dict | None]:
-    for discovery in store.read():
-        if not isinstance(discovery, GitDiscovery):
-            continue
-        for obs in discovery.observations:
-            if commit_id_for_oid(obs["oid"]) == commit_id:
-                return discovery, obs
-    return None, None
-
-
 def build_pairwise_prompt(side_a: dict, side_b: dict) -> str:
     return f"""You are ranking contributions to an open source project.
 Compare these two sides (each may be one or more commits). Decide which side contributed more.
@@ -2040,7 +2020,7 @@ def _strip_heavy_fields(obj: dict) -> dict:
 
 @app.get("/api/ledger")
 async def get_ledger(offset: int = 0, limit: int = 100, full: int = 0):
-    """List of ledger dicts (backward-compatible). Heavy blobs stripped unless full=1."""
+    """List of ledger dicts. Heavy blobs stripped unless full=1."""
     limit = max(1, min(limit, 500))
     rows = []
     for e in store.read()[offset:offset + limit]:
@@ -2274,23 +2254,25 @@ async def epochs_index():
     epochs = _epochs_in_ledger()
     rows = []
     for epoch in epochs:
-        discovery = _discovery_for_epoch(epoch)
         emission = _emission_for_epoch(epoch)
-        evidence_n = sum(
-            1 for e in evidence_by_kind() if e.epoch == epoch
+        evidence_n = sum(1 for e in evidence_by_kind() if e.epoch == epoch)
+        disc = next(
+            (
+                e for e in evidence_by_kind("git.discovery_completed")
+                if e.epoch == epoch
+            ),
+            None,
         )
         detail = []
-        if discovery:
+        if disc:
             detail.append(
-                f"{len(discovery.commits)} eligible / "
-                f"{len(discovery.observations)} observed"
+                f"{disc.payload.get('eligible_count', 0)} eligible / "
+                f"{disc.payload.get('observation_count', 0)} observed"
             )
         if emission:
             detail.append(f"emitted {emission.total_emitted}")
         if evidence_n:
             detail.append(f"{evidence_n} evidence events")
-        elif discovery or emission:
-            detail.append("legacy (no Evidence envelopes)")
         rows.append(["li",
             _a(_evidence_path("epoch", str(epoch)), f"epoch {epoch}"),
             " — ",
@@ -2307,37 +2289,37 @@ async def epochs_index():
 
 @app.get("/epochs/{epoch}")
 async def epoch_detail(epoch: int):
-    discovery = _discovery_for_epoch(epoch)
     emission = _emission_for_epoch(epoch)
     evidence_rows = [e for e in evidence_by_kind() if e.epoch == epoch]
+    if not evidence_rows and emission is None:
+        return _evidence_page(f"epoch {epoch}", [
+            _evidence_nav(),
+            ["h1", f"epoch {epoch}"],
+            ["p.note", "No evidence for this epoch."],
+        ])
+
     commit_evs = [e for e in evidence_rows if e.kind == "git.commit"]
     comparison_evs = [e for e in evidence_rows if e.kind == "comparison.input"]
     judgment_evs = [e for e in evidence_rows if e.kind == "llm.judgment"]
+    discovery_ev = next(
+        (e for e in evidence_rows if e.kind == "git.discovery_completed"), None
+    )
     ranking_started = next(
         (e for e in evidence_rows if e.kind == "ranking.started"), None
     )
     ranking_completed = next(
         (e for e in evidence_rows if e.kind == "ranking.completed"), None
     )
-    legacy = not evidence_rows and (discovery is not None or emission is not None)
-
-    commit_links: list[tuple[str, str]] = []
-    if commit_evs:
-        for e in commit_evs:
-            cid = e.payload.get("commit_id") or ""
-            label = (
-                f"{e.payload.get('oid', cid)[:24]} "
-                f"({e.payload.get('contributor', '?')})"
-            )
-            commit_links.append((label, _evidence_path("commit", cid)))
-    elif discovery:
-        for c in discovery.commits:
-            cid = commit_id_for_oid(c["oid"])
-            commit_links.append((
-                f"{c['oid'][:24]} ({c.get('contributor', '?')})",
-                _evidence_path("commit", cid),
-            ))
 
+    commit_links = [
+        (
+            f"{e.payload.get('oid', e.payload.get('commit_id', ''))[:24]} "
+            f"({e.payload.get('contributor', '?')})",
+            _evidence_path("commit", e.payload["commit_id"]),
+        )
+        for e in commit_evs
+        if e.payload.get("commit_id")
+    ]
     comparison_links = [
         (
             e.payload.get("summary") or e.payload.get("comparison_id", e.event_id),
@@ -2360,16 +2342,13 @@ async def epoch_detail(epoch: int):
     ]
 
     excluded = []
-    if discovery:
-        for obs in discovery.observations:
+    if discovery_ev:
+        for obs in discovery_ev.payload.get("observations") or []:
             if obs.get("eligible"):
                 continue
             oid = obs.get("oid", "?")
             reason = obs.get("exclusion_reason") or "excluded"
-            excluded.append(["li",
-                f"{oid[:28]} — {reason} — ",
-                ["span.note", "legacy evidence unavailable"],
-            ])
+            excluded.append(["li", f"{oid[:28]} — {reason}"])
 
     ranking_nodes: list = []
     if ranking_completed:
@@ -2388,21 +2367,10 @@ async def epoch_detail(epoch: int):
                 indent=2, sort_keys=True,
             )],
         ]
-    elif emission:
-        if legacy and len(emission.ranking or {}) <= 1:
-            ranking_nodes.append(["p.note",
-                "Single-contributor epoch — no LLM judgments."
-            ])
-        ranking_nodes.extend([
-            ["p", "Projected from Emission (no ranking Evidence event)."],
-            ["pre.blob", json.dumps(emission.ranking, indent=2, sort_keys=True)],
-        ])
+    elif ranking_started:
+        ranking_nodes = [["p.note", f"Ranking started: {ranking_started.event_id}"]]
     else:
-        ranking_nodes = [["p.note", "No ranking recorded."]]
-    if ranking_started and not ranking_completed:
-        ranking_nodes.insert(0, ["p.note",
-            f"Ranking started: {ranking_started.event_id}"
-        ])
+        ranking_nodes = [["p.note", "No ranking evidence."]]
 
     if emission:
         emission_node = _dl_rows([
@@ -2411,44 +2379,41 @@ async def epoch_detail(epoch: int):
             ("pool_after", emission.pool_after),
             ("discovery_snapshot_id", emission.discovery_snapshot_id),
             ("ranking_run_id", emission.ranking_run_id or None),
+            ("ranking_event_id", emission.ranking_event_id or None),
             ("models_used", ", ".join(emission.models_used or [])),
             ("distributions", json.dumps(emission.distributions, sort_keys=True)),
         ])
     else:
         emission_node = ["p.note", "No emission for this epoch."]
 
-    single_contributor = False
-    if discovery:
-        single_contributor = len({c.get("contributor") for c in discovery.commits}) <= 1
-    elif emission:
-        single_contributor = len(emission.ranking or {}) <= 1
+    contributors = {
+        e.payload.get("contributor")
+        for e in commit_evs
+        if e.payload.get("contributor")
+    }
+    no_comparisons_note = "No comparisons."
+    if len(contributors) <= 1:
+        no_comparisons_note += " Single-contributor — no LLM judgments."
 
     body = [
         _evidence_nav(),
         ["div.eyebrow", f"epoch {epoch}"],
         ["h1", f"epoch {epoch}"],
     ]
-    if legacy:
-        body.append(["p.note",
-            "Legacy epoch: projected from GitDiscovery/Emission without Evidence "
-            "envelopes. Eligible commits use discovery patches; discarded observation "
-            "metadata is marked legacy evidence unavailable. Single-contributor "
-            "epochs have no LLM judgments."
-        ])
-    if discovery:
+    if discovery_ev:
         body.extend([
             ["h2", "discovery"],
             _dl_rows([
-                ("snapshot_id", discovery.snapshot_id),
-                ("config_digest", discovery.config_digest),
-                ("initial_snapshot", discovery.initial_snapshot),
-                ("observations", len(discovery.observations)),
-                ("eligible", len(discovery.commits)),
+                ("snapshot_id", discovery_ev.payload.get("snapshot_id")),
+                ("config_digest", discovery_ev.payload.get("config_digest")),
+                ("observations", discovery_ev.payload.get("observation_count")),
+                ("eligible", discovery_ev.payload.get("eligible_count")),
+                ("event", _a(
+                    _evidence_path("event", discovery_ev.event_id),
+                    discovery_ev.event_id,
+                )),
             ]),
         ])
-    no_comparisons_note = "No comparisons."
-    if single_contributor:
-        no_comparisons_note += " Single-contributor — no LLM judgments."
     body.extend([
         ["h2", "commits"],
         _link_list(commit_links),
@@ -2471,92 +2436,42 @@ async def epoch_detail(epoch: int):
 @app.get("/commits/{commit_id}")
 async def commit_detail(commit_id: str):
     ev = find_evidence_payload("git.commit", "commit_id", commit_id)
-    discovery, legacy_row = (None, None)
     if not ev:
-        discovery, legacy_row = _legacy_commit_row(commit_id)
-    if not ev and not legacy_row:
-        discovery, obs = _legacy_observation(commit_id)
-        if obs is not None:
-            epoch = discovery.epoch if discovery else "?"
-            return _evidence_page(f"commit {commit_id[:24]}", [
-                _evidence_nav(
-                    _a(_evidence_path("epoch", str(epoch)), f"epoch {epoch}")
-                ),
-                ["div.eyebrow", "commit"],
-                ["h1", commit_id],
-                ["p.note", "legacy evidence unavailable"],
-                _dl_rows([
-                    ("oid", obs.get("oid")),
-                    ("eligible", obs.get("eligible")),
-                    ("exclusion_reason", obs.get("exclusion_reason")),
-                    ("epoch", str(epoch)),
-                ]),
-            ])
         return _evidence_page("commit not found", [
             _evidence_nav(),
             ["h1", "commit not found"],
             ["p", commit_id],
         ])
-    if ev:
-        p = ev.payload
-        epoch = ev.epoch
-        oid = p.get("oid", "")
-        contributor = p.get("contributor", "")
-        message = _blob_text(p.get("message"))
-        patch = _blob_text(p.get("patch"))
-        meta = _dl_rows([
+    p = ev.payload
+    epoch = ev.epoch
+    return _evidence_page(f"commit {commit_id[:24]}", [
+        _evidence_nav(_a(_evidence_path("epoch", str(epoch)), f"epoch {epoch}")),
+        ["div.eyebrow", "commit"],
+        ["h1", commit_id],
+        _dl_rows([
             ("commit_id", commit

… preview truncated; 7,279 characters omitted

download full diff A

B — c_c0df72aee6da (tommy-mor)

message

[bf118bdf] Sanitize Reddit entity body HTML before rendering.

Use ammonia at render time so untrusted selftext_html cannot execute scripts in our origin.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/Cargo.lock b/Cargo.lock
index 3dec7cb72a182dc654a37dca8ba0b49d77504daa..0dd4fce5fb6400ae153cca4e3dbf5a5158e6d8b4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11,6 +11,19 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "ammonia"
+version = "4.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "maplit",
+ "tendril",
+ "url",
+]
+
 [[package]]
 name = "anyhow"
 version = "1.0.102"
@@ -355,6 +368,29 @@ version = "0.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
 
+[[package]]
+name = "cssparser"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "deranged"
 version = "0.5.8"
@@ -381,6 +417,21 @@ version = "0.15.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
 
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
 [[package]]
 name = "durable"
 version = "0.2.0"
@@ -482,6 +533,16 @@ dependencies = [
  "percent-encoding",
 ]
 
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
 [[package]]
 name = "futures-channel"
 version = "0.3.32"
@@ -614,6 +675,17 @@ version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
 
+[[package]]
+name = "html5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
+dependencies = [
+ "log",
+ "markup5ever",
+ "match_token",
+]
+
 [[package]]
 name = "http"
 version = "1.4.1"
@@ -974,6 +1046,15 @@ version = "0.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
 
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
 [[package]]
 name = "log"
 version = "0.4.30"
@@ -990,6 +1071,40 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
+[[package]]
+name = "maplit"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+
+[[package]]
+name = "markup5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "match_token"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "matchers"
 version = "0.2.0"
@@ -1092,6 +1207,12 @@ dependencies = [
  "tempfile",
 ]
 
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
 [[package]]
 name = "nom"
 version = "7.1.3"
@@ -1175,6 +1296,29 @@ dependencies = [
  "vcpkg",
 ]
 
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
 [[package]]
 name = "peeking_take_while"
 version = "0.1.2"
@@ -1187,6 +1331,58 @@ version = "2.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
 
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
 [[package]]
 name = "pin-project-lite"
 version = "0.2.17"
@@ -1223,6 +1419,12 @@ dependencies = [
  "zerocopy",
 ]
 
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
 [[package]]
 name = "prettyplease"
 version = "0.2.37"
@@ -1379,6 +1581,15 @@ dependencies = [
  "rand_core 0.9.5",
 ]
 
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
 [[package]]
 name = "regex"
 version = "1.12.3"
@@ -1563,6 +1774,12 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
 [[package]]
 name = "security-framework"
 version = "3.7.0"
@@ -1683,6 +1900,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
 [[package]]
 name = "slab"
 version = "0.4.12"
@@ -1709,6 +1932,7 @@ dependencies = [
 name = "sorter2-server"
 version = "0.0.1"
 dependencies = [
+ "ammonia",
  "async-stream",
  "axum",
  "axum-extra",
@@ -1742,6 +1966,31 @@ version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
 
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
 [[package]]
 name = "subtle"
 version = "2.6.1"
@@ -1813,6 +2062,17 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
 [[package]]
 name = "thiserror"
 version = "1.0.69"
@@ -2116,6 +2376,12 @@ version = "2.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
 
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
 [[package]]
 name = "utf8_iter"
 version = "1.0.4"
@@ -2281,6 +2547,18 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "web_atoms"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
 [[package]]
 name = "windows-link"
 version = "0.2.1"
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 47659ff82fbfb50972eb2b87575e80f66e572ba4..27f552c20b97ef28cdde4cb6b1a4980375135111 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -13,6 +13,7 @@ serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 thiserror = "1"
 maud = { version = "0.26", features = ["axum"] }
+ammonia = "4.1"
 tower = "0.5"
 tower-http = { version = "0.5", features = ["trace"] }
 tracing = "0.1"
diff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs
index dadf050515f0473943dad97df5d318032c8cb385..5b160c6b8bd216dfaf80149854aec0566cd00460 100644
--- a/server/src/fetch/html.rs
+++ b/server/src/fetch/html.rs
@@ -4,6 +4,7 @@ use maud::{html, Markup};
 
 use crate::{
     form_template::template_json_compact,
+    html::sanitize::entity_body_html,
     path_types::ItemId,
     reddit::{is_children_fetchable, is_fetchable},
     reducer::NodeState,
@@ -27,7 +28,7 @@ pub fn entity_panel(node: &NodeState) -> Markup {
                     p class="muted small" { "by " (author) }
                 }
                 @if let Some(body) = &data.body_html {
-                    div class="entity-body" { (maud::PreEscaped(body)) }
+                    div class="entity-body" { (maud::PreEscaped(entity_body_html(body))) }
                 }
             }
         }
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index e180a0ca542a33e2300c0a4809e6b9cfee07ecfe..a58cbbee3490a08a625cb06df06848c59a615d65 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -20,6 +20,7 @@ use crate::{
     ui

… preview truncated; 2,223 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.