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,663 @@
1
+ #!/usr/bin/env python3
2
+ """G5 trust-boundary isolation tests for the preview browser control.
3
+
4
+ Covers the secure-ship 0.4.4 ticket 01 acceptance:
5
+
6
+ - prototype HTML isolated inside ``<iframe sandbox="allow-scripts" srcdoc="...">``
7
+ with ``allow-same-origin`` deliberately omitted (parent DOM unreachable by
8
+ prototype scripts, so the hidden decision token stays secret).
9
+ - one-time decision token generated via ``secrets.token_urlsafe(32)`` and bound
10
+ to the preview round + a first-decision-wins session.
11
+ - ``do_POST`` fails closed (``confirmed=False``, ``floor_pass=False``) on the
12
+ three rejection paths: token missing, token reused, round mismatch.
13
+ - a normal human confirm with the token still records ``confirmed=True`` and
14
+ ``floor_pass=True``; ``_write_confirm`` still records ``prototype_html_hash``.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import socket
21
+ import sys
22
+ import tempfile
23
+ import threading
24
+ import time
25
+ import unittest
26
+ from pathlib import Path
27
+ from typing import Any
28
+ from unittest import mock
29
+ from urllib.parse import urlencode
30
+
31
+ # Make sibling runtime modules importable under package-qualified discovery.
32
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
33
+ import browser # noqa: E402
34
+ from confirm import ( # noqa: E402
35
+ _DecisionSession,
36
+ _generate_decision_token,
37
+ _write_confirm,
38
+ prototype_html_digest,
39
+ )
40
+
41
+
42
+ # --------------------------------------------------------------------------- #
43
+ # HTTP client helpers (raw sockets, mirroring test_server_stdio.py) #
44
+ # --------------------------------------------------------------------------- #
45
+
46
+
47
+ def _stash_http(server_module: Any) -> tuple[type, dict[str, int]]:
48
+ """Wrap HTTPServer so the bound port is exposed to the client thread."""
49
+ real = server_module.HTTPServer
50
+ port_box: dict[str, int] = {}
51
+
52
+ class StashingHTTPServer(real): # type: ignore[misc, valid-type]
53
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
54
+ super().__init__(*args, **kwargs)
55
+ port_box["port"] = self.server_address[1]
56
+
57
+ return StashingHTTPServer, port_box
58
+
59
+
60
+ def _wait_for_port(port_box: dict[str, int], deadline: float = 5.0) -> int | None:
61
+ end = time.time() + deadline
62
+ while time.time() < end:
63
+ port = port_box.get("port")
64
+ if port:
65
+ return port
66
+ time.sleep(0.01)
67
+ return None
68
+
69
+
70
+ def _http_round_trip(port: int, raw_request: bytes) -> bytes:
71
+ with socket.create_connection(("127.0.0.1", port), timeout=3) as sock:
72
+ sock.sendall(raw_request)
73
+ sock.settimeout(3)
74
+ chunks: list[bytes] = []
75
+ try:
76
+ while True:
77
+ data = sock.recv(65536)
78
+ if not data:
79
+ break
80
+ chunks.append(data)
81
+ except socket.timeout:
82
+ pass
83
+ return b"".join(chunks)
84
+
85
+
86
+ def _get_page(port: int) -> str:
87
+ req = (
88
+ f"GET / HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
89
+ ).encode("ascii")
90
+ return _http_round_trip(port, req).decode("utf-8", errors="replace")
91
+
92
+
93
+ def _post_form(port: int, fields: dict[str, str]) -> bytes:
94
+ body = urlencode(fields).encode("ascii")
95
+ req = (
96
+ f"POST /decide HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"
97
+ "Content-Type: application/x-www-form-urlencoded\r\n"
98
+ f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n"
99
+ ).encode("ascii") + body
100
+ return _http_round_trip(port, req)
101
+
102
+
103
+ def _extract_token(page: str) -> str | None:
104
+ m = re.search(r'name="dpb_token"\s+value="([^"]+)"', page)
105
+ return m.group(1) if m else None
106
+
107
+
108
+ def _run_collect(
109
+ proto_html: str,
110
+ client_fn: Any,
111
+ *,
112
+ summary: str = "summary",
113
+ options: list[str] | None = None,
114
+ round_n: int = 1,
115
+ ) -> dict[str, Any]:
116
+ """Drive browser._collect_via_browser with a mocked owned window.
117
+
118
+ ``client_fn(port)`` runs in a thread and is expected to POST something to
119
+ /decide so the collect call terminates.
120
+ """
121
+ if options is None:
122
+ options = ["确认通过", "需要修改"]
123
+ StashingHTTPServer, port_box = _stash_http(browser)
124
+
125
+ def client_wrapper() -> None:
126
+ port = _wait_for_port(port_box)
127
+ if not port:
128
+ return
129
+ try:
130
+ client_fn(port)
131
+ except Exception as exc: # noqa: BLE001
132
+ # Surface client-side failures for the main thread; do not hang.
133
+ port_box["client_error"] = repr(exc)
134
+
135
+ client_thread = threading.Thread(target=client_wrapper, daemon=True)
136
+ client_thread.start()
137
+
138
+ with tempfile.TemporaryDirectory() as tmp:
139
+ proto = Path(tmp) / "proto.html"
140
+ proto.write_text(proto_html, encoding="utf-8")
141
+ with mock.patch.object(browser, "HTTPServer", StashingHTTPServer), mock.patch.object(
142
+ browser, "_open_preview_window", return_value=(None, None)
143
+ ), mock.patch.object(
144
+ browser, "_request_browser_window_close"
145
+ ), mock.patch.object(
146
+ browser, "_kill_browser_proc"
147
+ ), mock.patch.object(browser, "_rm_tree"):
148
+ decision = browser._collect_via_browser(proto, summary, options, round_n)
149
+
150
+ client_thread.join(timeout=3)
151
+ assert not client_thread.is_alive(), "client thread still alive"
152
+ client_error = port_box.get("client_error")
153
+ assert not client_error, f"client thread error: {client_error}"
154
+ return decision
155
+
156
+
157
+ # --------------------------------------------------------------------------- #
158
+ # Unit tests: token + session logic #
159
+ # --------------------------------------------------------------------------- #
160
+
161
+
162
+ class DecisionTokenUnitTests(unittest.TestCase):
163
+ def test_token_is_urlsafe_unique_and_long(self) -> None:
164
+ a = _generate_decision_token()
165
+ b = _generate_decision_token()
166
+ self.assertNotEqual(a, b)
167
+ # secrets.token_urlsafe(32) yields ~43 chars of [A-Za-z0-9_-]
168
+ self.assertGreaterEqual(len(a), 32)
169
+ self.assertRegex(a, r"^[A-Za-z0-9_-]+$")
170
+
171
+ def test_missing_token_rejected(self) -> None:
172
+ session = _DecisionSession(1, _generate_decision_token())
173
+ self.assertFalse(session.validate(1, None))
174
+ self.assertEqual(session.last_rejection, "missing")
175
+ self.assertFalse(session.validate(1, ""))
176
+ self.assertEqual(session.last_rejection, "missing")
177
+ self.assertFalse(session.locked)
178
+
179
+ def test_round_mismatch_rejected(self) -> None:
180
+ token = _generate_decision_token()
181
+ session = _DecisionSession(1, token)
182
+ self.assertFalse(session.validate(2, token))
183
+ self.assertEqual(session.last_rejection, "round_mismatch")
184
+ # A failed attempt must not consume the session.
185
+ self.assertTrue(session.validate(1, token))
186
+
187
+ def test_invalid_token_rejected(self) -> None:
188
+ session = _DecisionSession(1, _generate_decision_token())
189
+ self.assertFalse(session.validate(1, "not-the-real-token"))
190
+ self.assertEqual(session.last_rejection, "invalid_token")
191
+ self.assertFalse(session.locked)
192
+
193
+ def test_reuse_rejected_after_first_valid(self) -> None:
194
+ token = _generate_decision_token()
195
+ session = _DecisionSession(1, token)
196
+ self.assertTrue(session.validate(1, token))
197
+ self.assertTrue(session.locked)
198
+ # Second POST with the same token (replay) is rejected.
199
+ self.assertFalse(session.validate(1, token))
200
+ self.assertEqual(session.last_rejection, "reuse")
201
+ # Even a different token is rejected once locked.
202
+ self.assertFalse(session.validate(1, "other"))
203
+
204
+
205
+ # --------------------------------------------------------------------------- #
206
+ # Integration tests: parent-page rendering + HTTP decide flow #
207
+ # --------------------------------------------------------------------------- #
208
+
209
+
210
+ class TrustBoundaryIntegrationTests(unittest.TestCase):
211
+ def test_iframe_sandbox_excludes_allow_same_origin(self) -> None:
212
+ page_box: dict[str, str] = {"page": ""}
213
+
214
+ def client(port: int) -> None:
215
+ page = _get_page(port)
216
+ page_box["page"] = page
217
+ token = _extract_token(page) or ""
218
+ _post_form(
219
+ port,
220
+ {
221
+ "choice": "确认通过",
222
+ "feedback": "ok",
223
+ "anchors_json": "[]",
224
+ "dpb_token": token,
225
+ "dpb_round": "1",
226
+ },
227
+ )
228
+
229
+ _run_collect(
230
+ "<html><body><h1>proto-marker-123</h1></body></html>", client
231
+ )
232
+ page = page_box["page"]
233
+ self.assertIn("srcdoc=", page)
234
+ m = re.search(r'<iframe[^>]*\bsandbox="([^"]*)"', page)
235
+ self.assertIsNotNone(m, f"no sandboxed iframe in page head: {page[:240]!r}")
236
+ sandbox_attr = m.group(1)
237
+ self.assertIn("allow-scripts", sandbox_attr)
238
+ self.assertNotIn(
239
+ "allow-same-origin",
240
+ sandbox_attr,
241
+ "allow-same-origin would re-same-origin the iframe and defeat G5",
242
+ )
243
+ # The prototype body must NOT be rendered inline in the parent document;
244
+ # it lives escaped inside the iframe srcdoc attribute.
245
+ self.assertNotIn("<h1>proto-marker-123</h1>", page)
246
+ self.assertIn("proto-marker-123", page)
247
+
248
+ def test_control_form_carries_hidden_token_and_round(self) -> None:
249
+ page_box: dict[str, str] = {"page": ""}
250
+
251
+ def client(port: int) -> None:
252
+ page_box["page"] = _get_page(port)
253
+ token = _extract_token(page_box["page"]) or ""
254
+ _post_form(
255
+ port,
256
+ {
257
+ "choice": "确认通过",
258
+ "feedback": "ok",
259
+ "anchors_json": "[]",
260
+ "dpb_token": token,
261
+ "dpb_round": "1",
262
+ },
263
+ )
264
+
265
+ _run_collect("<html><body>x</body></html>", client)
266
+ page = page_box["page"]
267
+ token = _extract_token(page)
268
+ self.assertIsNotNone(token, "hidden dpb_token field missing from control form")
269
+ self.assertGreaterEqual(len(token), 32)
270
+ self.assertRegex(
271
+ page,
272
+ r'name="dpb_round"\s+value="1"',
273
+ "hidden dpb_round field missing or wrong round",
274
+ )
275
+
276
+ def test_malicious_post_without_token_does_not_hijack_session(self) -> None:
277
+ """MEDIUM-1 (secure-ship-0.4.4): a forged no-token POST must NOT
278
+ terminate the preview session.
279
+
280
+ A sandboxed prototype forging ``fetch('/decide', ...)`` arrives
281
+ without ``dpb_token`` (the hidden field lives in the trusted parent,
282
+ unreachable from the iframe). The server still fail-closes the
283
+ result internally (``confirmed=False``, ``rejected=True``) AND
284
+ responds 200, but it must keep the session alive so the real user
285
+ can still click confirm. Before MEDIUM-1, ``done.set()`` fired
286
+ unconditionally and one forged POST aborted every preview before
287
+ the user clicked anything (DoS on the gate).
288
+ """
289
+
290
+ def client(port: int) -> None:
291
+ # 1) Forged cross-origin POST: no dpb_token, no dpb_round.
292
+ _post_form(
293
+ port,
294
+ {
295
+ "choice": "确认通过",
296
+ "feedback": "forged",
297
+ "anchors_json": "[]",
298
+ },
299
+ )
300
+ # 2) Real user then submits via the trusted control form, which
301
+ # is the path that should terminate the session.
302
+ page = _get_page(port)
303
+ token = _extract_token(page) or ""
304
+ _post_form(
305
+ port,
306
+ {
307
+ "choice": "确认通过",
308
+ "feedback": "real user clicked confirm",
309
+ "anchors_json": "[]",
310
+ "dpb_token": token,
311
+ "dpb_round": "1",
312
+ },
313
+ )
314
+
315
+ decision = _run_collect(
316
+ "<html><body><script>fetch('/decide',{method:'POST',"
317
+ "body:new URLSearchParams({choice:'CONFIRM',feedback:'ok'})})"
318
+ "</script></body></html>",
319
+ client,
320
+ )
321
+ # Forged POST did not hijack: real user's authenticated submission wins.
322
+ self.assertEqual(decision["choice"], "确认通过")
323
+ self.assertEqual(decision["feedback"], "real user clicked confirm")
324
+ self.assertFalse(decision["aborted"])
325
+ self.assertNotIn("confirmed", decision)
326
+ self.assertNotIn("floor_pass", decision)
327
+ self.assertNotIn("rejected", decision)
328
+
329
+ def test_round_mismatch_rejected_at_http(self) -> None:
330
+ # A POST whose dpb_round does not match the session round is rejected
331
+ # (validate -> round_mismatch) and, per MEDIUM-1, must NOT end the
332
+ # session - the real user can still confirm afterward. The mismatch
333
+ # POST is fail closed internally; the subsequent valid POST wins,
334
+ # proving the mismatch neither consumed nor hijacked the session.
335
+ def client(port: int) -> None:
336
+ page = _get_page(port)
337
+ token = _extract_token(page) or ""
338
+ # 1) Mismatched-round POST: rejected, must not terminate.
339
+ _post_form(
340
+ port,
341
+ {
342
+ "choice": "确认通过",
343
+ "feedback": "ok",
344
+ "anchors_json": "[]",
345
+ "dpb_token": token,
346
+ "dpb_round": "99",
347
+ },
348
+ )
349
+ # 2) Real user's valid-round POST: must still confirm.
350
+ _post_form(
351
+ port,
352
+ {
353
+ "choice": "确认通过",
354
+ "feedback": "real user",
355
+ "anchors_json": "[]",
356
+ "dpb_token": token,
357
+ "dpb_round": "1",
358
+ },
359
+ )
360
+
361
+ decision = _run_collect("<html><body>x</body></html>", client)
362
+ # Mismatch did not hijack: the real user's valid POST wins.
363
+ self.assertEqual(decision["choice"], "确认通过")
364
+ self.assertEqual(decision["feedback"], "real user")
365
+ self.assertFalse(decision["aborted"])
366
+ self.assertNotIn("rejected", decision)
367
+
368
+ def test_normal_confirm_with_token_passes(self) -> None:
369
+ def client(port: int) -> None:
370
+ page = _get_page(port)
371
+ token = _extract_token(page)
372
+ assert token, "token not rendered in control form"
373
+ _post_form(
374
+ port,
375
+ {
376
+ "choice": "确认通过",
377
+ "feedback": "looks good, ship it",
378
+ "anchors_json": "[]",
379
+ "dpb_token": token,
380
+ "dpb_round": "1",
381
+ },
382
+ )
383
+
384
+ decision = _run_collect(
385
+ "<html><body><h1>real prototype</h1></body></html>", client
386
+ )
387
+ self.assertEqual(decision["choice"], "确认通过")
388
+ self.assertEqual(decision["feedback"], "looks good, ship it")
389
+ self.assertFalse(decision["aborted"])
390
+ self.assertEqual(
391
+ decision["prototype_html_hash"],
392
+ prototype_html_digest(
393
+ b"<html><body><h1>real prototype</h1></body></html>"
394
+ ),
395
+ )
396
+ self.assertNotIn("confirmed", decision)
397
+ self.assertNotIn("floor_pass", decision)
398
+ self.assertNotIn("rejected", decision)
399
+
400
+ def test_abort_with_token_is_recorded(self) -> None:
401
+ def client(port: int) -> None:
402
+ page = _get_page(port)
403
+ token = _extract_token(page) or ""
404
+ _post_form(
405
+ port,
406
+ {
407
+ "choice": "__abort__",
408
+ "feedback": "",
409
+ "anchors_json": "[]",
410
+ "dpb_token": token,
411
+ "dpb_round": "1",
412
+ },
413
+ )
414
+
415
+ decision = _run_collect("<html><body>x</body></html>", client)
416
+ self.assertEqual(decision["choice"], "__abort__")
417
+ self.assertEqual(decision["feedback"], "")
418
+ self.assertTrue(decision["aborted"])
419
+ self.assertNotIn("confirmed", decision)
420
+ self.assertNotIn("rejected", decision)
421
+
422
+
423
+ # --------------------------------------------------------------------------- #
424
+ # Confirm record hash (existing trusted-side behavior unchanged) #
425
+ # --------------------------------------------------------------------------- #
426
+
427
+
428
+ class ConfirmRecordHashTests(unittest.TestCase):
429
+ def test_write_confirm_records_prototype_html_hash(self) -> None:
430
+ with tempfile.TemporaryDirectory() as tmp:
431
+ preview_dir = Path(tmp)
432
+ proto = preview_dir / "round-1.html"
433
+ proto_bytes = b"<html><body><h1>hash me</h1></body></html>"
434
+ proto.write_bytes(proto_bytes)
435
+ from confirm import prototype_html_digest
436
+
437
+ expected = prototype_html_digest(proto_bytes)
438
+
439
+ out = _write_confirm(
440
+ preview_dir,
441
+ round_n=1,
442
+ report_ref="report.md",
443
+ selected=["确认通过"],
444
+ feedback="ok",
445
+ prototype_html_hash=expected,
446
+ confirmed=True,
447
+ floor_pass=True,
448
+ )
449
+ record = json.loads(out.read_text(encoding="utf-8"))
450
+
451
+ self.assertTrue(record["confirmed"])
452
+ self.assertTrue(record["floor_pass"])
453
+ self.assertEqual(record["prototype_html_hash"], expected)
454
+ self.assertEqual(record["round"], 1)
455
+
456
+
457
+ # --------------------------------------------------------------------------- #
458
+ # pin-to-annotate postMessage bridge (G5 sandbox regression fix) #
459
+ # --------------------------------------------------------------------------- #
460
+
461
+
462
+ def _bridge_inner_js() -> str:
463
+ """Return the raw JS inside the bridge <script> tag (no <script> wrappers).
464
+
465
+ Used by syntax + string assertions so they reason about the executable JS
466
+ rather than the HTML wrapper. Strips the leading ``<script ...>`` and
467
+ trailing ``</script>`` of the first script block in BRIDGE_SCRIPT.
468
+ """
469
+ raw = browser.BRIDGE_SCRIPT
470
+ m = re.search(r"<script[^>]*>(.*)</script>\s*$", raw, re.DOTALL)
471
+ assert m, f"BRIDGE_SCRIPT is not a single <script>...</script> block: {raw[:80]!r}"
472
+ return m.group(1)
473
+
474
+
475
+ class PinAnnotationBridgeTests(unittest.TestCase):
476
+ """G5 introduced ``<iframe sandbox="allow-scripts" srcdoc=...>`` (no
477
+ allow-same-origin, opaque origin) to keep the prototype away from the
478
+ parent DOM where the decision token lives. That isolation also broke
479
+ pin-to-annotate: the parent's ``document.click`` + ``cssPath(e.target)``
480
+ can no longer see iframe clicks or traverse the iframe DOM.
481
+
482
+ The fix is a postMessage bridge: a script injected into the srcdoc captures
483
+ clicks inside the iframe and postMessages ``{dpbPinAnchor:{selector,tag}}``
484
+ to the parent; the parent records the anchor only while pin mode is on.
485
+
486
+ These tests pin the bridge's presence, structure, and G5 safety (it must
487
+ never touch the parent DOM or the token) at the unit level. End-to-end
488
+ DOM correctness is covered by the playwright bridge test.
489
+ """
490
+
491
+ def test_bridge_script_constant_is_single_script_block(self) -> None:
492
+ # The bridge is a self-contained <script>...</script> appended to the
493
+ # prototype before escaping into srcdoc. It must be exactly one block
494
+ # so _build_parent_page can concatenate it as a trailer.
495
+ self.assertTrue(browser.BRIDGE_SCRIPT.startswith("<script"))
496
+ self.assertTrue(browser.BRIDGE_SCRIPT.rstrip().endswith("</script>"))
497
+ # exactly one <script...> open + one </script> close
498
+ self.assertEqual(
499
+ len(re.findall(r"<script\b", browser.BRIDGE_SCRIPT)), 1,
500
+ "bridge must be a single <script> block",
501
+ )
502
+ self.assertEqual(
503
+ len(re.findall(r"</script>", browser.BRIDGE_SCRIPT)), 1,
504
+ "bridge must close exactly one <script> block",
505
+ )
506
+
507
+ def test_bridge_script_is_valid_javascript(self) -> None:
508
+ # node --check proves the injected JS parses (catches copy/format
509
+ # errors that string assertions cannot). Skipped if node is absent.
510
+ import shutil
511
+ import subprocess
512
+ node = shutil.which("node")
513
+ if not node:
514
+ self.skipTest("node not available; JS syntax check skipped")
515
+ with tempfile.NamedTemporaryFile(
516
+ mode="w", suffix=".js", delete=False, encoding="utf-8"
517
+ ) as fh:
518
+ fh.write(_bridge_inner_js())
519
+ tmp_path = fh.name
520
+ try:
521
+ completed = subprocess.run(
522
+ [node, "--check", tmp_path],
523
+ stdout=subprocess.PIPE,
524
+ stderr=subprocess.PIPE,
525
+ timeout=10,
526
+ )
527
+ finally:
528
+ try:
529
+ Path(tmp_path).unlink()
530
+ except OSError:
531
+ pass
532
+ self.assertEqual(
533
+ completed.returncode, 0,
534
+ f"bridge script is not valid JS: "
535
+ f"{completed.stderr.decode('utf-8', 'replace')}",
536
+ )
537
+
538
+ def test_build_parent_page_injects_bridge_into_srcdoc(self) -> None:
539
+ page = browser._build_parent_page(
540
+ "<html><body>proto-marker-xyz</body></html>",
541
+ "<div>control</div>",
542
+ )
543
+ # The srcdoc attribute carries the escaped prototype + bridge. After
544
+ # unescaping the srcdoc payload we must find BOTH the prototype body
545
+ # and the bridge trailer.
546
+ m = re.search(r'<iframe[^>]*\bsrcdoc="([^"]*)"', page)
547
+ self.assertIsNotNone(m, "no srcdoc iframe in parent page")
548
+ import html as html_mod
549
+ payload = html_mod.unescape(m.group(1))
550
+ self.assertIn("proto-marker-xyz", payload)
551
+ self.assertIn("dpbPinAnchor", payload,
552
+ "bridge trailer missing from srcdoc payload")
553
+ # bridge comes AFTER the prototype body (appended, not prepended)
554
+ self.assertLess(
555
+ payload.index("proto-marker-xyz"),
556
+ payload.index("dpbPinAnchor"),
557
+ "bridge must be appended after prototype body",
558
+ )
559
+
560
+ def test_bridge_contains_postMessage_with_dpbPinAnchor(self) -> None:
561
+ js = _bridge_inner_js()
562
+ self.assertIn("postMessage", js,
563
+ "bridge must postMessage the anchor to the parent")
564
+ self.assertIn("dpbPinAnchor", js,
565
+ "bridge must tag its messages with the dpbPinAnchor key")
566
+ # postMessage target must be the parent window
567
+ self.assertRegex(
568
+ js, r"parent\.postMessage\s*\(",
569
+ "bridge must postMessage to parent (not top/opener)",
570
+ )
571
+
572
+ def test_bridge_contains_cssPath_logic(self) -> None:
573
+ # The bridge duplicates cssPath (from control.py) so the iframe can
574
+ # compute a selector for the clicked element on its own side of the
575
+ # trust boundary. Assert the key branches are present.
576
+ js = _bridge_inner_js()
577
+ self.assertIn("function cssPath", js,
578
+ "cssPath function missing from bridge")
579
+ self.assertIn("CSS.escape", js,
580
+ "cssPath must escape id/class via CSS.escape")
581
+ # id fast path
582
+ self.assertRegex(js, r'el\.id\b.*#"',
583
+ "cssPath must short-circuit on element id")
584
+ # nth-of-type branch (disambiguate siblings)
585
+ self.assertIn(":nth-of-type(", js,
586
+ "cssPath must include :nth-of-type for sibling disambig")
587
+ # tag fallback
588
+ self.assertIn("tagName.toLowerCase()", js,
589
+ "cssPath must fall back to lowercased tagName")
590
+ # the click listener that fires cssPath + postMessage
591
+ self.assertIn('"click"', js,
592
+ "bridge must register a click listener")
593
+ self.assertIn("dpb-pin-target", js,
594
+ "bridge must highlight the clicked element in-iframe")
595
+
596
+ def test_bridge_does_not_reach_parent_dom_or_token(self) -> None:
597
+ # G5 security contract: the bridge runs inside the sandboxed opaque-
598
+ # origin iframe. It must NOT touch parent.document, parent.location,
599
+ # or the decision token. It may only postMessage anchor data.
600
+ js = _bridge_inner_js()
601
+ self.assertNotIn("parent.document", js,
602
+ "bridge must not read parent.document (G5 boundary)")
603
+ self.assertNotIn("parent.location", js,
604
+ "bridge must not read parent.location (G5 boundary)")
605
+ self.assertNotIn("dpb_token", js,
606
+ "bridge must not reference the decision token")
607
+ self.assertNotIn("dpb_round", js,
608
+ "bridge must not reference the decision round")
609
+ self.assertNotIn("/decide", js,
610
+ "bridge must not POST to /decide (parent-only path)")
611
+ self.assertNotIn("localStorage", js,
612
+ "bridge must not touch storage (no exfil channel)")
613
+ # no fetch/XHR — the bridge's only outbound channel is postMessage
614
+ self.assertNotRegex(
615
+ js, r"\bfetch\s*\(",
616
+ "bridge must not use fetch (postMessage is its only channel)",
617
+ )
618
+ self.assertNotIn("XMLHttpRequest", js,
619
+ "bridge must not use XHR (postMessage is its only channel)")
620
+
621
+ def test_iframe_sandbox_still_excludes_allow_same_origin_with_bridge(self) -> None:
622
+ # Regression guard: injecting the bridge must NOT relax the sandbox.
623
+ # allow-same-origin would re-same-origin the iframe and let prototype
624
+ # scripts read the parent DOM (and the hidden token) — defeating G5.
625
+ page = browser._build_parent_page(
626
+ "<html><body>x</body></html>", "<div>control</div>"
627
+ )
628
+ m = re.search(r'<iframe[^>]*\bsandbox="([^"]*)"', page)
629
+ self.assertIsNotNone(m, "no sandboxed iframe in parent page")
630
+ sandbox_attr = m.group(1)
631
+ self.assertIn("allow-scripts", sandbox_attr)
632
+ self.assertNotIn(
633
+ "allow-same-origin", sandbox_attr,
634
+ "bridge injection must not re-add allow-same-origin (G5)",
635
+ )
636
+
637
+ def test_bridge_survives_prototype_with_closing_script_tag(self) -> None:
638
+ # If the prototype itself contains a literal </script> inside a script
639
+ # block, the bridge (also a <script>) is appended AFTER the escaped
640
+ # prototype into srcdoc. html.escape neutralizes every </script> in
641
+ # the prototype to &lt;/script&gt; so neither the prototype's nor the
642
+ # bridge's script boundaries leak across the srcdoc attribute. Assert
643
+ # the bridge marker still lands in the srcdoc payload intact.
644
+ proto = (
645
+ "<html><body><script>var x = 1; "
646
+ "console.log('</script>')</script><h1>hi</h1></body></html>"
647
+ )
648
+ page = browser._build_parent_page(proto, "<div>control</div>")
649
+ m = re.search(r'<iframe[^>]*\bsrcdoc="([^"]*)"', page)
650
+ self.assertIsNotNone(m)
651
+ import html as html_mod
652
+ payload = html_mod.unescape(m.group(1))
653
+ self.assertIn("dpbPinAnchor", payload,
654
+ "bridge dropped by prototype </script> — escaping bug")
655
+ # And the sandbox attribute must not be corrupted by the prototype's
656
+ # quotes (the whole srcdoc is attribute-escaped).
657
+ sb = re.search(r'<iframe[^>]*\bsandbox="([^"]*)"', page)
658
+ self.assertIsNotNone(sb)
659
+ self.assertIn("allow-scripts", sb.group(1))
660
+
661
+
662
+ if __name__ == "__main__":
663
+ unittest.main()