fix(human-gate): sanitize reviewed HTML; fix 3 gate-integrity defects

Second PR-review round on #948. All four reproduced before fixing.

1. HIGH — reviewed HTML executed in the review page. BlockTagger re-emitted
   attributes verbatim, escaping values but never filtering attribute names or
   URL schemes. The Markdown path has had _safe_href scheme-allowlisting all
   along; the HTML path had nothing. Reproduced: a draft.html containing
   `<img src=x onerror=...>`, `<a href="javascript:alert(1)">` and an <iframe>
   passed straight into the page a reviewer opens — and reviewing a landing-page
   draft is a documented use of this skill.

   sanitize_attrs() drops on* handlers, srcdoc and srcset, and runs href/src/
   action/formaction/poster/cite/background through the scheme allowlist.
   _safe_href now strips control characters before reading the scheme (so
   `java\tscript:` cannot smuggle one) and allows data:image only for image
   attributes. DROP_TAGS removes iframe/object/embed/frame/base/applet as well
   as script/style/head/link/meta. Verified: handlers, javascript: (plain and
   tab-smuggled), and iframes all gone; https links and relative images kept.

2. MEDIUM — verify_quotes compared rendered text against raw markup. A quote
   comes from window.getSelection(), which is what the browser rendered, so
   selecting a sentence containing **bold**, `code` or a link never matched the
   raw source. G7 had just made that blocking, so this refused legitimate
   closes. Now matched against raw OR a rendered-text projection (inline markup
   stripped for Markdown, tags stripped and entities unescaped for HTML). A
   fabricated quote is still caught — verified both directions.

3. MEDIUM — state["waiver"] was never cleared, so after waived-close → reopen →
   a clean round, close still printed the old waiver reason. For a tool whose
   premise is an honest record of what was actually reviewed and waived, that is
   its own integrity bug. Cleared whenever a close passes with zero refusals.

4. LOW — status returned 4 for both "no sidecar yet" and "collected, blockers
   open". The blocked case now returns 2, matching close, so an agent can branch
   on the exit code alone: 0 clear, 2 blocked, 3 collect, 4 nothing yet.

Also corrected an over-broad claim of my own: the page makes no network request
of its own, but a reviewed HTML artifact's own https: assets do load, as they
must for the review to be faithful. README and SKILL.md now say that precisely.

Re-verified: derive_counters --check, check_plugin_json --all, checklist 6/6
PASS, description validator PASS, all three scripts --help/--sample green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01233Eggb2cjSYf96X6C3pCm
This commit is contained in:
Claude 2026-08-09 05:46:07 +00:00
parent daa4dde7d1
commit 70c908a65a
No known key found for this signature in database
7 changed files with 139 additions and 23 deletions

View file

@ -55,7 +55,24 @@ three stdlib-only Python scripts, no server, no socket, no network fetch.
waiver without a recorded reason, or a round carrying unresolved integrity problems.
Waivers store both the reason and every refusal they overrode.
**G7 came out of PR review** and closes a real hole: a mistyped severity heading
**Four more fixes came out of a second PR review round**, each reproduced before fixing:
**(a)** the HTML artifact path re-emitted attributes verbatim, so a reviewed draft containing
`<img src=x onerror=...>`, `<a href="javascript:...">` or an `<iframe>` executed inside the
review page — the Markdown path had `_safe_href` scheme-allowlisting all along and the HTML
path had nothing. `sanitize_attrs()` now drops `on*`/`srcdoc`/`srcset`, runs every URL
attribute through the same allowlist (control characters stripped first, so `java\tscript:`
cannot smuggle a scheme), and `DROP_TAGS` removes `iframe`/`object`/`embed`/`base`. Legitimate
`https:` links and relative images survive. **(b)** `verify_quotes()` compared a browser
selection (rendered text) against raw markup, so quoting a sentence containing `**bold**` or a
link failed — and since G7 made that blocking, it refused a legitimate close. It now matches
against raw *or* a rendered-text projection, while a genuinely fabricated quote is still
caught. **(c)** `state["waiver"]` was never cleared, so a clean unwaived round N+1 still
printed round N's waiver reason — in a tool whose premise is an honest record, that is its own
integrity bug. **(d)** `status` returned 4 for both "no sidecar yet" and "collected, blockers
open"; the blocked case now returns 2, matching `close`, so an agent can branch on the exit
code alone (0 clear · 2 blocked · 3 collect · 4 nothing yet).
**G7 came out of the first PR review round** and closes a real hole: a mistyped severity heading
(`## BLOKCER`) silently downgrades to `NIT`, so before this a reviewer's genuine blocker
could be lost to a typo and `close` would still exit 0. Reproduced, then fixed — the
parser's integrity problems (unknown severity, EDIT with no replacement text, a quote

View file

@ -33,7 +33,7 @@ exactly what is still open.
```sh
python3 $S/human_gate.py open plan.md --launch # build review page, start round N
# → hand over path, END YOUR TURN
python3 $S/human_gate.py status plan.md # exit 3 = feedback waiting (non-blocking)
python3 $S/human_gate.py status plan.md # non-blocking: 0 clear · 2 blocked · 3 collect · 4 none
python3 $S/human_gate.py collect plan.md --output json # batch.v1 — apply every item
python3 $S/human_gate.py close plan.md # exit 2 = you are NOT done
```
@ -42,7 +42,7 @@ python3 $S/human_gate.py close plan.md # exit 2 = you are NOT done
| File | Purpose |
|---|---|
| `scripts/review_page_builder.py` | Markdown/HTML → single-file review page, every block anchored. **Zero network requests** — no CDN, no fonts, no server, no socket. ~11 KB, opens over `file://`. |
| `scripts/review_page_builder.py` | Markdown/HTML → single-file review page, every block anchored. The page itself makes **zero network requests** — no CDN, no fonts, no server, no socket; ~11 KB, opens over `file://`. Reviewed HTML is sanitized first: `on*` handlers, `javascript:` URLs, and `iframe`/`object`/`embed` are dropped, so a draft cannot execute inside the page. A reviewed HTML artifact's own `https:` images still load, as they must for the review to be faithful. |
| `scripts/feedback_parser.py` | Review sidecar → `batch.v1` JSON, with quote verification against the real file. |
| `scripts/human_gate.py` | State machine + the gate. `open`/`status`/`collect`/`close`/`reset`. |
| `references/human_in_the_loop_canon.md` | Bainbridge, Fagan, Wiegers, Weinberg, Google SWE ch.9, Klein pre-mortem. |
@ -90,7 +90,7 @@ Severities are **BLOCKER / MAJOR / MINOR / NIT** — the same ladder
| **G4** | the sidecar changed after the last collect |
| **G5** | round cap exhausted → **escalate**, never pass |
| **G6** | a waiver is used without a recorded reason |
| **G7** | the round carries unresolved integrity problems — a mistyped severity (`## BLOKCER`) silently downgrades to NIT, an EDIT has no replacement text, or a quote is not in the file |
| **G7** | the round carries unresolved integrity problems — a mistyped severity (`## BLOKCER`) silently downgrades to NIT, an EDIT has no replacement text, or a quote is in neither the raw source nor its rendered text |
Overrides are legitimate and must be explicit:

View file

@ -62,9 +62,12 @@ python3 $S/human_gate.py status "$ARTIFACT"
| Exit | Meaning |
|---|---|
| 3 | feedback waiting — collect it |
| 4 | nothing yet — end the turn again |
| 0 | last round collected, nothing blocking |
| 2 | collected, but blocking items are open — same code `close` uses |
| 3 | feedback waiting — collect it |
| 4 | nothing on disk yet — end the turn again |
Branch on the code alone: 0 clear · 2 blocked · 3 collect me · 4 nothing yet.
### 3. `collect` — read the batch

View file

@ -26,15 +26,15 @@ is available now. Read `human-gate-context.md` first if it exists.
S=engineering/human-gate/skills/human-gate/scripts
python3 $S/human_gate.py open plan.md --launch # build page, start round N → END YOUR TURN
python3 $S/human_gate.py status plan.md # exit 3 = feedback waiting (non-blocking)
python3 $S/human_gate.py status plan.md # non-blocking: 0 clear·2 blocked·3 collect·4 none
python3 $S/human_gate.py collect plan.md --output json # batch.v1 — apply every item
python3 $S/human_gate.py close plan.md # exit 2 = NOT done
```
`human_gate.py --sample` runs the whole loop, refusals included, in ~1s. It drives
`review_page_builder.py` (Markdown/HTML → single-file anchored page, zero network requests,
opens over `file://`) and `feedback_parser.py` (sidecar → `batch.v1`, verifying every quote
against the real file); both also run standalone with `--help`/`--sample`.
`review_page_builder.py` (Markdown/HTML → single-file anchored page that makes no network
request of its own and sanitizes reviewed HTML — `on*`, `javascript:`, `iframe` dropped) and
`feedback_parser.py` (sidecar → `batch.v1`, quotes checked against raw *and* rendered text).
## The sidecar

View file

@ -43,6 +43,7 @@ Exit codes
from __future__ import annotations
import argparse
import html
import json
import os
import re
@ -316,20 +317,53 @@ def parse(text, target_hint=None):
return batch, problems
def plain_text(source, is_html=False):
"""Approximate what the browser renders, so quotes can be matched fairly.
A reviewer selects text in the *rendered* page, so `window.getSelection()`
hands back "We expect a 40% lift" for source that reads
"We expect a **40%** lift". Comparing that against raw markup would flag a
perfectly good quote and since integrity problems now block the gate
(G7), a false positive here refuses a legitimate close.
"""
text = source
if is_html:
text = re.sub(r"(?is)<(script|style)\b.*?</\1>", " ", text)
text = re.sub(r"(?s)<!--.*?-->", " ", text)
text = re.sub(r"(?s)<[^>]+>", " ", text)
return html.unescape(text)
text = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", text) # images -> alt
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) # links -> label
text = re.sub(r"`([^`]+)`", r"\1", text) # inline code
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) # bold
text = re.sub(r"__([^_]+)__", r"\1", text)
text = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"\1", text) # italic
text = re.sub(r"(?<!_)_([^_\n]+)_(?!_)", r"\1", text)
text = re.sub(r"(?m)^\s{0,3}#{1,6}\s+", "", text) # heading markers
text = re.sub(r"(?m)^\s{0,3}>\s?", "", text) # blockquote markers
return text
def verify_quotes(batch, target_path):
"""Report quotes that do not literally appear in the reviewed file."""
"""Report quotes that appear in neither the raw source nor its rendered text."""
problems = []
try:
with open(target_path, "r", encoding="utf-8") as handle:
source = handle.read()
except OSError as err:
return ["could not read target %s to verify quotes: %s" % (target_path, err)]
normalized = re.sub(r"\s+", " ", source)
squash = lambda s: re.sub(r"\s+", " ", s)
is_html = os.path.splitext(target_path)[1].lower() in (".html", ".htm")
haystacks = (squash(source), squash(plain_text(source, is_html=is_html)))
for item in batch["items"]:
quote = item.get("quote", "").strip()
if not quote:
continue
if re.sub(r"\s+", " ", quote) not in normalized:
needle = squash(quote)
if not any(needle in hay for hay in haystacks):
problems.append(
"item %s quotes text not found in %s: %r"
% (item["id"], os.path.basename(target_path), quote[:60])

View file

@ -38,8 +38,12 @@ Exit codes
1 usage error
2 gate refuses to close
3 feedback is waiting to be collected
4 no feedback yet
4 no feedback yet (status: no sidecar on disk)
5 round cap exhausted escalate to a human
`status` uses the same 2 as `close` when the collected round still has open
blocking items, so an agent can branch on the exit code alone: 0 clear,
2 blocked, 3 collect me, 4 nothing yet.
"""
from __future__ import annotations
@ -250,7 +254,9 @@ def cmd_status(args):
elif rounds and rounds[-1].get("sidecar_sha") == current["sha"]:
payload["status"] = "collected"
payload["blocking_open"] = rounds[-1].get("blocking_open", 0)
code = 0 if rounds[-1].get("blocking_open", 0) == 0 else 4
# 2 mirrors close's "gate would refuse" so the two agree, and keeps 4
# meaning exactly one thing: nothing to collect.
code = 0 if rounds[-1].get("blocking_open", 0) == 0 else 2
else:
payload["status"] = "feedback-waiting"
code = 3
@ -364,6 +370,10 @@ def cmd_close(args):
"refusals": list(refusals),
}
refusals = []
elif not refusals:
# A pass that needed no waiver must not inherit an earlier one, or a
# genuinely clean round N+1 reports round N's waiver as if it applied.
state["waiver"] = None
if refusals:
print("GATE REFUSED — %s" % os.path.basename(artifact))

View file

@ -33,6 +33,19 @@ BLOCK_TAGS = {
"table", "hr", "section", "figure", "div",
}
# Dropped entirely from a reviewed HTML artifact. script/style/head/link/meta
# are chrome the review page supplies itself; iframe/object/embed/frame would
# execute or fetch third-party content inside a page the reviewer trusts.
DROP_TAGS = {
"script", "style", "head", "link", "meta",
"iframe", "object", "embed", "frame", "frameset", "base", "applet",
}
# Attributes carrying a URL, which must clear the same scheme allowlist the
# Markdown path uses. Everything named on* is an event handler and is dropped.
URL_ATTRS = {"href", "src", "action", "formaction", "poster", "cite", "background"}
DROP_ATTRS = {"srcdoc", "srcset"}
SAMPLE_MD = """# Quarterly plan
We expect a 40% lift in activation.
@ -56,12 +69,24 @@ ITALIC = re.compile(r"(?<!\*)\*([^*]+)\*(?!\*)")
LINK = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
def _safe_href(url):
def _safe_href(url, image=False):
"""Allow relative URLs and http/https/mailto; data: only for inline images.
Control characters are stripped before the scheme is read, so
`java\\tscript:alert(1)` cannot smuggle a scheme past the check.
"""
probe = re.sub(r"[\x00-\x20\x7f]+", "", url)
match = re.match(r"^([a-zA-Z][a-zA-Z0-9+.\-]*):", probe)
if match and match.group(1).lower() not in ("http", "https", "mailto"):
return None
return url
if not match:
return url
scheme = match.group(1).lower()
if scheme in ("http", "https", "mailto"):
return url
if image and re.match(
r"^data:image/(?:avif|gif|jpe?g|png|webp);base64,", probe, re.IGNORECASE
):
return url
return None
def inline(text):
@ -211,6 +236,29 @@ def render_block(block, block_id):
# ------------------------------------------------------------------- html
def sanitize_attrs(attrs):
"""Strip event handlers and unsafe URL schemes from a reviewed HTML tag.
The Markdown path scheme-allowlists every link through _safe_href. Raw HTML
input has to clear the same bar: without this an artifact containing
`<img src=x onerror=...>` or `<a href="javascript:...">` executes inside the
review page the moment the reviewer opens it and reviewing a landing-page
draft is a documented use of this skill.
"""
kept = []
for name, value in attrs:
lower = name.lower()
if lower.startswith("on") or lower in DROP_ATTRS:
continue
if lower in URL_ATTRS:
safe = _safe_href(value or "", image=(lower in ("src", "poster", "background")))
if safe is None:
continue
value = safe
kept.append((name, value))
return kept
class BlockTagger(HTMLParser):
"""Re-emit an HTML body, tagging top-level block elements with data-hg ids."""
@ -231,7 +279,7 @@ class BlockTagger(HTMLParser):
if tag == "body":
self._in_body = True
return
if tag in ("script", "style", "head", "link", "meta"):
if tag in DROP_TAGS:
self._skip += 1
return
if self._skip:
@ -242,7 +290,8 @@ class BlockTagger(HTMLParser):
if not self._in_body:
return
rebuilt = "".join(
' %s="%s"' % (k, html.escape(v or "", quote=True)) for k, v in attrs
' %s="%s"' % (k, html.escape(v or "", quote=True))
for k, v in sanitize_attrs(attrs)
)
if self.depth == 0 and tag in BLOCK_TAGS:
self.count += 1
@ -257,7 +306,7 @@ class BlockTagger(HTMLParser):
if tag == "body":
self._in_body = False
return
if tag in ("script", "style", "head"):
if tag in DROP_TAGS:
self._skip = max(0, self._skip - 1)
return
if self._skip or not self._in_body:
@ -276,8 +325,11 @@ class BlockTagger(HTMLParser):
def handle_startendtag(self, tag, attrs):
if self._skip or not self._in_body:
return
if tag in DROP_TAGS:
return
rebuilt = "".join(
' %s="%s"' % (k, html.escape(v or "", quote=True)) for k, v in attrs
' %s="%s"' % (k, html.escape(v or "", quote=True))
for k, v in sanitize_attrs(attrs)
)
if self.depth == 0 and tag in BLOCK_TAGS:
self.count += 1