design-playbook 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +28 -0
  2. package/NOTICE +37 -0
  3. package/README.md +143 -0
  4. package/commands/design-io.md +8 -0
  5. package/commands/ui-review.md +8 -0
  6. package/commands/ux-spec.md +8 -0
  7. package/mcp/__init__.py +0 -0
  8. package/mcp/_transport.py +242 -0
  9. package/mcp/evidence/README.md +40 -0
  10. package/mcp/evidence/__init__.py +0 -0
  11. package/mcp/evidence/server.py +450 -0
  12. package/mcp/evidence/test_server_stdio.py +645 -0
  13. package/mcp/preview/__init__.py +0 -0
  14. package/mcp/preview/browser.py +661 -0
  15. package/mcp/preview/confirm.py +255 -0
  16. package/mcp/preview/control.py +1293 -0
  17. package/mcp/preview/i18n.py +162 -0
  18. package/mcp/preview/server.py +126 -0
  19. package/mcp/preview/test_browser_control.py +663 -0
  20. package/mcp/preview/test_server_stdio.py +630 -0
  21. package/mcp/preview/test_transaction.py +436 -0
  22. package/mcp/preview/transaction.py +536 -0
  23. package/mcp/preview/util.py +19 -0
  24. package/mcp/test_transport.py +39 -0
  25. package/package.json +42 -0
  26. package/skills/craft-guard/SKILL.md +59 -0
  27. package/skills/craft-guard/references/craft.md +29 -0
  28. package/skills/craft-guard/references/detectors.md +124 -0
  29. package/skills/design-baseline/SKILL.md +134 -0
  30. package/skills/design-baseline/agents/openai.yaml +4 -0
  31. package/skills/design-baseline/references/design-template.md +73 -0
  32. package/skills/design-baseline/references/extraction-guidance.md +39 -0
  33. package/skills/design-baseline/scripts/design_baseline.py +780 -0
  34. package/skills/design-playbook/SKILL.md +219 -0
  35. package/skills/native-craft/SKILL.md +59 -0
  36. package/skills/native-craft/references/native-feel.md +79 -0
  37. package/skills/reference-intake/SKILL.md +86 -0
  38. package/skills/reference-intake/references/contract-template.md +82 -0
  39. package/skills/ui-evaluator/SKILL.md +110 -0
  40. package/skills/ui-evaluator/references/rubric.md +45 -0
  41. package/skills/ui-picker/SKILL.md +63 -0
  42. package/skills/ui-picker/references/components.md +31 -0
  43. package/skills/ui-picker/references/design.md +21 -0
  44. package/skills/ui-picker/references/domain.md +26 -0
  45. package/skills/ui-picker/references/template.md +24 -0
  46. package/skills/ux-spec/SKILL.md +51 -0
  47. package/skills/ux-spec/references/spec-template.md +43 -0
@@ -0,0 +1,661 @@
1
+ """Owned-Chromium preview window + local HTTP decide form.
2
+
3
+ Opens a centered app window, serves the prototype and trusted control bar,
4
+ authenticates one raw submission, and tears down browser and server without
5
+ hanging on keep-alive sockets. Decision authority and floor logic belong to
6
+ transaction.py.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import ctypes
11
+ import html
12
+ import json
13
+ import os
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import threading
20
+ import webbrowser
21
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer as HTTPServer
22
+ from pathlib import Path
23
+ from typing import Any
24
+ from urllib.parse import parse_qs
25
+
26
+ from confirm import (
27
+ _DecisionSession,
28
+ _generate_decision_token,
29
+ prototype_html_digest,
30
+ )
31
+ from control import _build_control
32
+ from i18n import lang, t
33
+ from util import _log
34
+
35
+
36
+ def _screen_size() -> tuple[int, int]:
37
+ try:
38
+ import ctypes
39
+ user32 = ctypes.windll.user32 # type: ignore[attr-defined]
40
+ return int(user32.GetSystemMetrics(0)), int(user32.GetSystemMetrics(1))
41
+ except Exception: # noqa: BLE001
42
+ return 1440, 900
43
+
44
+
45
+
46
+ def _browser_candidates() -> list[str]:
47
+ found: list[str] = []
48
+ for env in ("DPB_PREVIEW_BROWSER", "CHROME_PATH", "EDGE_PATH"):
49
+ v = os.environ.get(env)
50
+ if v:
51
+ found.append(v)
52
+ for name in ("msedge", "chrome", "google-chrome", "chromium", "chromium-browser"):
53
+ w = shutil.which(name)
54
+ if w:
55
+ found.append(w)
56
+ roots = [
57
+ os.environ.get("ProgramFiles", r"C:\Program Files"),
58
+ os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
59
+ os.environ.get("LOCALAPPDATA", ""),
60
+ ]
61
+ rels = [
62
+ ("Microsoft", "Edge", "Application", "msedge.exe"),
63
+ ("Google", "Chrome", "Application", "chrome.exe"),
64
+ ("Microsoft", "Edge Beta", "Application", "msedge.exe"),
65
+ ]
66
+ for root in roots:
67
+ if not root:
68
+ continue
69
+ for rel in rels:
70
+ found.append(str(Path(root).joinpath(*rel)))
71
+ out: list[str] = []
72
+ seen: set[str] = set()
73
+ for c in found:
74
+ key = c.lower()
75
+ if key in seen:
76
+ continue
77
+ seen.add(key)
78
+ if Path(c).is_file():
79
+ out.append(c)
80
+ return out
81
+
82
+
83
+
84
+ def _open_preview_window(url: str, *, width: int = 1100, height: int = 780):
85
+ """Open a centered Chromium app window; fallback to default browser.
86
+
87
+ Returns (proc, profile_dir). profile_dir is a private user-data-dir so the
88
+ Chromium process stays owned by us and can be killed on submit (shared
89
+ profiles hand off to an existing browser and ignore terminate/window.close).
90
+ """
91
+ sw, sh = _screen_size()
92
+ x = max(0, (sw - width) // 2)
93
+ y = max(0, (sh - height) // 2)
94
+ profile_dir = tempfile.mkdtemp(prefix="dpb-preview-")
95
+ args_tail = [
96
+ f"--app={url}",
97
+ f"--user-data-dir={profile_dir}",
98
+ f"--window-size={width},{height}",
99
+ f"--window-position={x},{y}",
100
+ "--new-window",
101
+ "--no-first-run",
102
+ "--no-default-browser-check",
103
+ "--disable-features=TranslateUI",
104
+ ]
105
+ for exe in _browser_candidates():
106
+ try:
107
+ proc = subprocess.Popen(
108
+ [exe, *args_tail],
109
+ stdout=subprocess.DEVNULL,
110
+ stderr=subprocess.DEVNULL,
111
+ )
112
+ _log(
113
+ f"preview app window: {exe} pid={proc.pid} pos={x},{y} "
114
+ f"size={width}x{height} profile={profile_dir}"
115
+ )
116
+ return proc, profile_dir
117
+ except Exception as exc: # noqa: BLE001
118
+ _log(f"app window open failed ({exe}): {exc}")
119
+ try:
120
+ webbrowser.open(url)
121
+ _log("preview fallback: webbrowser.open")
122
+ except Exception as exc: # noqa: BLE001
123
+ _log(f"webbrowser.open failed: {exc}")
124
+ _rm_tree(profile_dir)
125
+ return None, None
126
+
127
+
128
+
129
+ def _kill_browser_proc(
130
+ proc: subprocess.Popen | None,
131
+ profile_dir: str | None = None,
132
+ ) -> None:
133
+ """Force-close the owned preview Chromium.
134
+
135
+ Chromium may exit the launcher PID and keep the app window under another
136
+ process that still holds --user-data-dir. Kill by PID tree first, then by
137
+ profile path in the command line.
138
+ """
139
+ launcher_killed = False
140
+ if proc is not None and proc.poll() is None:
141
+ try:
142
+ if sys.platform == "win32":
143
+ completed = subprocess.run(
144
+ ["taskkill", "/PID", str(proc.pid), "/T", "/F"],
145
+ stdout=subprocess.DEVNULL,
146
+ stderr=subprocess.DEVNULL,
147
+ check=False,
148
+ timeout=5,
149
+ )
150
+ launcher_killed = completed.returncode == 0
151
+ else:
152
+ proc.terminate()
153
+ try:
154
+ proc.wait(timeout=2)
155
+ except subprocess.TimeoutExpired:
156
+ proc.kill()
157
+ launcher_killed = True
158
+ except subprocess.TimeoutExpired:
159
+ _log("browser kill by pid timed out; trying profile fallback")
160
+ except Exception as exc: # noqa: BLE001
161
+ _log(f"browser kill by pid failed: {exc}")
162
+
163
+ if launcher_killed:
164
+ _log(f"browser kill by pid tree: {proc.pid}")
165
+ return
166
+ if not profile_dir:
167
+ return
168
+ try:
169
+ if sys.platform == "win32":
170
+ # Keep the marker out of PowerShell's own command line, otherwise
171
+ # the matcher can terminate itself before reaching Chromium.
172
+ ps = (
173
+ "$m=$env:DPB_PREVIEW_PROFILE;"
174
+ "Get-CimInstance Win32_Process | Where-Object {"
175
+ " $_.ProcessId -ne $PID -and $_.CommandLine -and "
176
+ " $_.CommandLine.IndexOf($m,[StringComparison]::OrdinalIgnoreCase) -ge 0"
177
+ "} | ForEach-Object {"
178
+ " Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue"
179
+ "}"
180
+ )
181
+ env = os.environ.copy()
182
+ # Match the unique leaf name because Chromium may expand an 8.3
183
+ # temp path (AMSTER~1) to its long form in the child command line.
184
+ env["DPB_PREVIEW_PROFILE"] = Path(profile_dir).name
185
+ subprocess.run(
186
+ [
187
+ "powershell",
188
+ "-NoProfile",
189
+ "-ExecutionPolicy",
190
+ "Bypass",
191
+ "-Command",
192
+ ps,
193
+ ],
194
+ stdout=subprocess.DEVNULL,
195
+ stderr=subprocess.DEVNULL,
196
+ check=False,
197
+ env=env,
198
+ timeout=8,
199
+ )
200
+ else:
201
+ # pkill -f is common on mac/linux for matching cmdline
202
+ for pat in (str(Path(profile_dir).resolve()), profile_dir):
203
+ if not pat:
204
+ continue
205
+ subprocess.run(
206
+ ["pkill", "-f", pat],
207
+ stdout=subprocess.DEVNULL,
208
+ stderr=subprocess.DEVNULL,
209
+ check=False,
210
+ )
211
+ _log(f"browser kill by profile: {profile_dir}")
212
+ except subprocess.TimeoutExpired:
213
+ _log(f"browser kill by profile timed out: {profile_dir}")
214
+ except Exception as exc: # noqa: BLE001
215
+ _log(f"browser kill by profile failed: {exc}")
216
+
217
+
218
+
219
+ def _request_browser_window_close(proc: subprocess.Popen | None) -> None:
220
+ """Hide the owned app window synchronously before process cleanup."""
221
+ if proc is None or sys.platform != "win32":
222
+ return
223
+ try:
224
+ user32 = ctypes.windll.user32
225
+ target_pid = proc.pid
226
+ closed = 0
227
+
228
+ @ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
229
+ def close_if_owned(hwnd, _lparam):
230
+ nonlocal closed
231
+ pid = ctypes.c_ulong()
232
+ user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
233
+ if pid.value == target_pid and user32.IsWindowVisible(hwnd):
234
+ user32.ShowWindow(hwnd, 0) # SW_HIDE
235
+ closed += 1
236
+ return True
237
+
238
+ user32.EnumWindows(close_if_owned, 0)
239
+ if closed:
240
+ _log(f"browser window hidden: pid={target_pid} windows={closed}")
241
+ except Exception as exc: # noqa: BLE001
242
+ _log(f"browser window close failed: {exc}")
243
+
244
+
245
+
246
+ def _rm_tree(path: str | None) -> None:
247
+ if not path:
248
+ return
249
+ try:
250
+ shutil.rmtree(path, ignore_errors=True)
251
+ except Exception: # noqa: BLE001
252
+ pass
253
+
254
+
255
+
256
+ def _stop_http_server(
257
+ server: HTTPServer,
258
+ serve_thread: threading.Thread,
259
+ *,
260
+ timeout_s: float = 1.5,
261
+ ) -> None:
262
+ """Stop the threaded preview server and prove its serve loop exited."""
263
+ errors: list[str] = []
264
+ try:
265
+ server.shutdown()
266
+ except Exception as exc: # noqa: BLE001
267
+ errors.append(f"http shutdown failed: {exc}")
268
+ try:
269
+ server.server_close()
270
+ except Exception as exc: # noqa: BLE001
271
+ errors.append(f"http server_close failed: {exc}")
272
+
273
+ serve_thread.join(timeout=timeout_s)
274
+ if serve_thread.is_alive():
275
+ errors.append(f"http serve thread still alive after {timeout_s:.1f}s")
276
+ if errors:
277
+ message = "; ".join(errors)
278
+ _log(message)
279
+ raise RuntimeError(message)
280
+
281
+
282
+
283
+ def _parse_anchors(raw: str) -> list[dict[str, Any]]:
284
+ if not raw or not raw.strip():
285
+ return []
286
+ try:
287
+ data = json.loads(raw)
288
+ except json.JSONDecodeError:
289
+ return []
290
+ if not isinstance(data, list):
291
+ return []
292
+ out: list[dict[str, Any]] = []
293
+ for item in data:
294
+ if not isinstance(item, dict):
295
+ continue
296
+ selector = str(item.get("selector") or "").strip()
297
+ if not selector:
298
+ continue
299
+ out.append({
300
+ "selector": selector,
301
+ "label": str(item.get("label") or "").strip()[:120],
302
+ "comment": str(item.get("comment") or "").strip()[:500],
303
+ "tag": str(item.get("tag") or "").strip()[:40],
304
+ })
305
+ return out[:40]
306
+
307
+
308
+
309
+ def _done_page_html() -> bytes:
310
+ # Owned Chromium is killed by the server after submit; JS is best-effort only.
311
+ # Use unique %markers + str.replace (not .format) so the CSS/JS braces don't
312
+ # need escaping.
313
+ html = """<!DOCTYPE html><html lang="%html_lang%"><head><meta charset="utf-8"/>
314
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
315
+ <title>%done_title%</title>
316
+ <style>
317
+ body{margin:0;min-height:100vh;display:grid;place-items:center;
318
+ font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;
319
+ background:#0f1218;color:#e5e7eb}
320
+ .card{width:min(420px,92vw);padding:28px 24px;border-radius:14px;
321
+ background:#171b24;border:1px solid #2c3444;text-align:center}
322
+ h1{margin:0 0 8px;font-size:18px;font-weight:650;letter-spacing:-.02em}
323
+ p{margin:0;color:#9aa3b2;font-size:13px}
324
+ .ok{display:inline-flex;align-items:center;justify-content:center;
325
+ width:40px;height:40px;border-radius:999px;margin-bottom:14px;
326
+ background:rgba(20,184,166,.14);color:#5eead4;font-weight:700}
327
+ </style>
328
+ <script>
329
+ setTimeout(function () {
330
+ try { window.open("", "_self"); window.close(); } catch (e) {}
331
+ try { window.close(); } catch (e) {}
332
+ }, 200);
333
+ </script>
334
+ </head><body><div class="card">
335
+ <div class="ok" aria-hidden="true">OK</div>
336
+ <h1>%done_title%</h1><p>%done_body%</p>
337
+ </div></body></html>"""
338
+ return (html
339
+ .replace("%html_lang%", lang())
340
+ .replace("%done_title%", t("done_title"))
341
+ .replace("%done_body%", t("done_body"))).encode("utf-8")
342
+
343
+
344
+
345
+ # G5: the parent control form's opening tag — a stable hook for token
346
+ # injection. control.py owns the template; we only splice hidden fields in.
347
+ _FORM_MARKER = '<form method="POST" action="/decide" id="dpb-decide-form">'
348
+
349
+
350
+ def _inject_token_fields(control_html: str, token: str, round_n: int) -> str:
351
+ """Insert hidden dpb_token + dpb_round fields into the control form (G5).
352
+
353
+ The token is the parent page's proof-of-origin; the round binds it to this
354
+ preview session. Spliced in post-template so control.py stays untouched
355
+ (sibling agents own its contents).
356
+ """
357
+ safe_token = html.escape(token, quote=True)
358
+ fields = (
359
+ f'<input type="hidden" name="dpb_token" value="{safe_token}"/>'
360
+ f'<input type="hidden" name="dpb_round" value="{round_n}"/>'
361
+ )
362
+ if _FORM_MARKER in control_html:
363
+ return control_html.replace(_FORM_MARKER, _FORM_MARKER + fields, 1)
364
+ # Defensive fallback: anchor to any <form ...> open tag if the template
365
+ # marker ever moves. Lambda keeps the replacement literal (no backslash
366
+ # expansion of the HTML/JS payload).
367
+ return re.sub(
368
+ r"(<form\b[^>]*>)",
369
+ lambda m: m.group(1) + fields,
370
+ control_html,
371
+ count=1,
372
+ )
373
+
374
+
375
+ # pin-to-annotate postMessage bridge (G5 sandbox regression fix).
376
+ #
377
+ # G5 isolated the prototype inside <iframe sandbox="allow-scripts" srcdoc=...>
378
+ # with allow-same-origin DELIBERATELY omitted, so the iframe is an opaque
379
+ # origin and prototype scripts cannot reach the parent DOM (where the decision
380
+ # token lives). That broke pin-to-annotate: the parent's document.click +
381
+ # cssPath(e.target) can no longer see clicks inside the iframe or traverse the
382
+ # iframe DOM (cross-origin). This bridge runs INSIDE the iframe document and
383
+ # restores anchor collection by postMessaging {selector, tag} to the parent.
384
+ #
385
+ # G5 safety contract (verified by test_browser_control.PinAnnotationBridgeTests):
386
+ # - the bridge only postMessages anchor DATA ({selector, tag}) — it never
387
+ # reads parent.document, parent.location, the token, or storage, and it
388
+ # never fetches/XHRs. postMessage is its only outbound channel.
389
+ # - the parent records the anchor only while pin mode is on (control.py
390
+ # message listener filters on pinOn), so no pin-state sync is needed.
391
+ # - the iframe highlights the clicked element itself (dpb-pin-target) since
392
+ # the parent cannot reach into the iframe DOM to do it.
393
+ #
394
+ # Raw string + single braces: this is plain string concatenation (not .format),
395
+ # so JS braces stay literal (no {{ doubling). cssPath is a faithful copy of
396
+ # control.py's cssPath so selectors match the same-origin path.
397
+ BRIDGE_SCRIPT = r"""<script>
398
+ (function () {
399
+ // Inject the pin highlight CSS into the iframe document. The parent's
400
+ // control-bar stylesheet does not cross the iframe boundary, so the bridge
401
+ // brings its own copy of .dpb-pin-target / .dpb-pin-hover (the same rules
402
+ // control.py renders in the parent) to actually show the highlight in-frame.
403
+ var style = document.createElement("style");
404
+ style.textContent =
405
+ ".dpb-pin-target{outline:1.5px solid rgba(20,184,166,.9)!important;" +
406
+ "outline-offset:1px!important;background-color:rgba(20,184,166,.06)!important;" +
407
+ "cursor:crosshair!important}" +
408
+ ".dpb-pin-hover{outline:1px dashed rgba(20,184,166,.45)!important;" +
409
+ "outline-offset:1px!important}";
410
+ (document.head || document.documentElement).appendChild(style);
411
+
412
+ function cssPath(el) {
413
+ if (!el || el.nodeType !== 1) return "";
414
+ if (el.id) return "#" + CSS.escape(el.id);
415
+ var parts = [];
416
+ var cur = el;
417
+ var depth = 0;
418
+ while (cur && cur.nodeType === 1 && cur !== document.documentElement && depth < 8) {
419
+ if (cur.id === "dpb-preview-bar" || cur.id === "dpb-preview-spacer" || cur.id === "dpb-float-root") break;
420
+ var part = cur.tagName.toLowerCase();
421
+ if (cur.classList && cur.classList.length) {
422
+ var cls = Array.prototype.slice.call(cur.classList, 0, 2)
423
+ .filter(function (c) { return c && c.indexOf("dpb-") !== 0; })
424
+ .map(function (c) { return "." + CSS.escape(c); })
425
+ .join("");
426
+ part += cls;
427
+ }
428
+ var parent = cur.parentElement;
429
+ if (parent) {
430
+ var kids = parent.children;
431
+ var n = 0, idx = 0, i;
432
+ for (i = 0; i < kids.length; i++) {
433
+ if (kids[i].tagName === cur.tagName) {
434
+ n++;
435
+ if (kids[i] === cur) idx = n;
436
+ }
437
+ }
438
+ if (n > 1) part += ":nth-of-type(" + idx + ")";
439
+ }
440
+ parts.unshift(part);
441
+ if (cur.tagName === "BODY") break;
442
+ cur = parent;
443
+ depth++;
444
+ }
445
+ return parts.join(" > ");
446
+ }
447
+ var hoverEl = null;
448
+ function clearHover() {
449
+ if (hoverEl) {
450
+ hoverEl.classList.remove("dpb-pin-hover");
451
+ hoverEl = null;
452
+ }
453
+ }
454
+ document.addEventListener("mousemove", function (e) {
455
+ var el = e.target;
456
+ if (!el || el === document.body || el === document.documentElement) {
457
+ clearHover();
458
+ return;
459
+ }
460
+ if (hoverEl !== el) {
461
+ clearHover();
462
+ hoverEl = el;
463
+ hoverEl.classList.add("dpb-pin-hover");
464
+ }
465
+ }, true);
466
+ document.addEventListener("click", function (e) {
467
+ var el = e.target;
468
+ if (!el || el === document.body || el === document.documentElement) return;
469
+ e.preventDefault();
470
+ e.stopPropagation();
471
+ var prev = document.querySelector(".dpb-pin-target");
472
+ if (prev && prev !== el) prev.classList.remove("dpb-pin-target");
473
+ el.classList.add("dpb-pin-target");
474
+ var selector = cssPath(el);
475
+ if (!selector) return;
476
+ parent.postMessage({ dpbPinAnchor: { selector: selector, tag: el.tagName.toLowerCase() } }, "*");
477
+ }, true);
478
+ })();
479
+ </script>"""
480
+
481
+
482
+ def _build_parent_page(prototype_html: str, control_html: str) -> str:
483
+ """Build the trusted parent document (G5 trust boundary).
484
+
485
+ The parent renders only the control bar; the prototype is isolated inside
486
+ ``<iframe sandbox="allow-scripts" srcdoc="...">``. ``allow-same-origin`` is
487
+ deliberately omitted so the iframe is treated as a unique opaque origin and
488
+ prototype scripts cannot reach the parent DOM — where the one-time decision
489
+ token lives as a hidden form field.
490
+
491
+ The pin-to-annotate bridge (``BRIDGE_SCRIPT``) is appended to the prototype
492
+ BEFORE escaping so it executes inside the iframe document, where it can see
493
+ the prototype DOM. It captures clicks/hover, computes a cssPath selector
494
+ on its own side of the trust boundary, and postMessages ``{selector, tag}``
495
+ to the parent — restoring anchor collection that G5's cross-origin boundary
496
+ took away (the parent can no longer see iframe clicks or traverse iframe
497
+ DOM). The bridge never touches ``parent.document`` or the token; postMessage
498
+ is its only outbound channel (verified by test_browser_control).
499
+ """
500
+ # html.escape neutralizes every </script> (and quote) in both the prototype
501
+ # and the bridge trailer to entity form inside the srcdoc ATTRIBUTE, so the
502
+ # prototype's own script boundaries cannot leak across and truncate the
503
+ # bridge. The browser decodes the entities when rendering the iframe
504
+ # document, restoring the original <script>...</script> blocks. This is the
505
+ # attribute-escaping context (safe); it is NOT the inline-<script> context
506
+ # where </script> would need splitting.
507
+ srcdoc = html.escape(prototype_html + BRIDGE_SCRIPT, quote=True)
508
+ # String concatenation (not .format): the CSS braces are literal here, and
509
+ # concatenation sidesteps the format()-on-HTML brace-escaping trap.
510
+ return (
511
+ '<!DOCTYPE html><html lang="' + lang() + '"><head>'
512
+ '<meta charset="utf-8"/>'
513
+ '<meta name="viewport" content="width=device-width, initial-scale=1"/>'
514
+ '<title>preview</title>'
515
+ "<style>"
516
+ "html,body{margin:0;padding:0;height:100%;background:#0f1218;}"
517
+ ".dpb-proto-frame{position:fixed;inset:0;width:100%;height:100%;"
518
+ "border:0;background:#ffffff;}"
519
+ "</style></head><body>"
520
+ + control_html
521
+ + '<iframe class="dpb-proto-frame" sandbox="allow-scripts" srcdoc="'
522
+ + srcdoc
523
+ + '" title="prototype"></iframe>'
524
+ + "</body></html>"
525
+ )
526
+
527
+
528
+ def _collect_via_browser(
529
+ prototype: Path, summary: str, options: list[str],
530
+ round_n: int) -> dict[str, Any]:
531
+ """Serve prototype + control form; block until user submits or aborts."""
532
+ result: dict[str, Any] = {
533
+ "choice": "",
534
+ "feedback": "",
535
+ "aborted": True,
536
+ "anchors": [],
537
+ }
538
+ done = threading.Event()
539
+
540
+ # TOCTOU fix: read bytes once, hash (LF-normalized), then decode for display
541
+ raw_bytes = prototype.read_bytes()
542
+ prototype_html_hash = prototype_html_digest(raw_bytes)
543
+ prototype_html = raw_bytes.decode("utf-8")
544
+ result["prototype_html_hash"] = prototype_html_hash
545
+
546
+ def with_prototype_hash(submission: dict[str, Any]) -> dict[str, Any]:
547
+ submission["prototype_html_hash"] = prototype_html_hash
548
+ return submission
549
+
550
+ control = _build_control(round_n, summary.strip(), options)
551
+ # G5 trust boundary: one-time token + first-decision-wins session. The
552
+ # token renders as a hidden field in the PARENT control form (trusted);
553
+ # the sandboxed prototype iframe cannot read it, so a forged
554
+ # fetch('/decide', ...) arrives without proof and fails closed.
555
+ token = _generate_decision_token()
556
+ control = _inject_token_fields(control, token, round_n)
557
+ page = _build_parent_page(prototype_html, control)
558
+ session = _DecisionSession(round_n, token)
559
+
560
+ class Handler(BaseHTTPRequestHandler):
561
+ def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
562
+ _log("http: " + (fmt % args))
563
+
564
+ def do_GET(self) -> None: # noqa: N802
565
+ if self.path not in ("/", "/index.html"):
566
+ self.send_error(404)
567
+ return
568
+ data = page.encode("utf-8")
569
+ self.close_connection = True
570
+ self.send_response(200)
571
+ self.send_header("Content-Type", "text/html; charset=utf-8")
572
+ self.send_header("Content-Length", str(len(data)))
573
+ self.send_header("Connection", "close")
574
+ self.end_headers()
575
+ self.wfile.write(data)
576
+
577
+ def do_POST(self) -> None: # noqa: N802
578
+ nonlocal result
579
+ length = int(self.headers.get("Content-Length", "0"))
580
+ body = self.rfile.read(length).decode("utf-8")
581
+ form = parse_qs(body)
582
+ choice = (form.get("choice") or ["__abort__"])[0]
583
+ feedback = (form.get("feedback") or [""])[0]
584
+ anchors = _parse_anchors((form.get("anchors_json") or ["[]"])[0])
585
+ # G5: validate the one-time decision token before trusting choice.
586
+ # A sandboxed prototype cannot read the hidden token, so a forged
587
+ # fetch('/decide', ...) arrives without it and fails closed.
588
+ try:
589
+ posted_round = int((form.get("dpb_round") or [""])[0])
590
+ except (ValueError, TypeError):
591
+ posted_round = -1
592
+ posted_token = (form.get("dpb_token") or [None])[0]
593
+ validated = session.validate(posted_round, posted_token)
594
+ if not validated:
595
+ # Fail closed: missing / reused / mismatched token -> NOT confirmed.
596
+ result = with_prototype_hash({
597
+ "choice": "",
598
+ "feedback": feedback,
599
+ "aborted": True,
600
+ "anchors": anchors,
601
+ "rejected": True,
602
+ "rejection": session.last_rejection,
603
+ })
604
+ else:
605
+ result = with_prototype_hash({
606
+ "choice": choice,
607
+ "feedback": feedback,
608
+ "aborted": choice == "__abort__",
609
+ "anchors": anchors,
610
+ })
611
+ reply = _done_page_html()
612
+ self.close_connection = True
613
+ self.send_response(200)
614
+ self.send_header("Content-Type", "text/html; charset=utf-8")
615
+ self.send_header("Content-Length", str(len(reply)))
616
+ self.send_header("Connection", "close")
617
+ self.end_headers()
618
+ self.wfile.write(reply)
619
+ self.wfile.flush()
620
+ # MEDIUM-1 (secure-ship-0.4.4) anti-DoS: only end the session when
621
+ # the POST proves trusted-form origin — validated (first valid
622
+ # decision) OR carried a dpb_token at all (real control-form
623
+ # submit, even on replay/mismatch). A forged cross-origin fetch
624
+ # arrives with no token (sandboxed iframe cannot read the hidden
625
+ # field); responding 200 keeps it quiet, but the server stays
626
+ # alive so the real user can still confirm. Unconditional
627
+ # done.set() here let one forged POST abort every preview before
628
+ # the user clicked anything. Fail-closed semantics above are
629
+ # unchanged — only session termination is now gated.
630
+ if validated:
631
+ done.set()
632
+
633
+ server = HTTPServer(("127.0.0.1", 0), Handler)
634
+ port = server.server_address[1]
635
+ thread = threading.Thread(
636
+ target=server.serve_forever, name="dpb-preview-http", daemon=True
637
+ )
638
+ thread.start()
639
+ url = f"http://127.0.0.1:{port}/"
640
+ _log(f"preview UI at {url}")
641
+ browser_proc, browser_profile = _open_preview_window(url)
642
+ try:
643
+ if not done.wait(timeout=1800):
644
+ result = with_prototype_hash({
645
+ "choice": "",
646
+ "feedback": "timeout waiting for user",
647
+ "aborted": True,
648
+ "anchors": [],
649
+ })
650
+ finally:
651
+ # Hide for immediate visual feedback. Kill the owned Chromium next so
652
+ # keep-alive sockets cannot block HTTPServer.shutdown; response is
653
+ # already flushed before done.set(). Bound HTTP stop so MCP returns.
654
+ _request_browser_window_close(browser_proc)
655
+ _kill_browser_proc(browser_proc, browser_profile)
656
+ try:
657
+ _stop_http_server(server, thread, timeout_s=1.5)
658
+ finally:
659
+ _rm_tree(browser_profile)
660
+ return result
661
+