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.
- package/LICENSE +28 -0
- package/NOTICE +37 -0
- package/README.md +143 -0
- package/commands/design-io.md +8 -0
- package/commands/ui-review.md +8 -0
- package/commands/ux-spec.md +8 -0
- package/mcp/__init__.py +0 -0
- package/mcp/_transport.py +242 -0
- package/mcp/evidence/README.md +40 -0
- package/mcp/evidence/__init__.py +0 -0
- package/mcp/evidence/server.py +450 -0
- package/mcp/evidence/test_server_stdio.py +645 -0
- package/mcp/preview/__init__.py +0 -0
- package/mcp/preview/browser.py +661 -0
- package/mcp/preview/confirm.py +255 -0
- package/mcp/preview/control.py +1293 -0
- package/mcp/preview/i18n.py +162 -0
- package/mcp/preview/server.py +126 -0
- package/mcp/preview/test_browser_control.py +663 -0
- package/mcp/preview/test_server_stdio.py +630 -0
- package/mcp/preview/test_transaction.py +436 -0
- package/mcp/preview/transaction.py +536 -0
- package/mcp/preview/util.py +19 -0
- package/mcp/test_transport.py +39 -0
- package/package.json +42 -0
- package/skills/craft-guard/SKILL.md +59 -0
- package/skills/craft-guard/references/craft.md +29 -0
- package/skills/craft-guard/references/detectors.md +124 -0
- package/skills/design-baseline/SKILL.md +134 -0
- package/skills/design-baseline/agents/openai.yaml +4 -0
- package/skills/design-baseline/references/design-template.md +73 -0
- package/skills/design-baseline/references/extraction-guidance.md +39 -0
- package/skills/design-baseline/scripts/design_baseline.py +780 -0
- package/skills/design-playbook/SKILL.md +219 -0
- package/skills/native-craft/SKILL.md +59 -0
- package/skills/native-craft/references/native-feel.md +79 -0
- package/skills/reference-intake/SKILL.md +86 -0
- package/skills/reference-intake/references/contract-template.md +82 -0
- package/skills/ui-evaluator/SKILL.md +110 -0
- package/skills/ui-evaluator/references/rubric.md +45 -0
- package/skills/ui-picker/SKILL.md +63 -0
- package/skills/ui-picker/references/components.md +31 -0
- package/skills/ui-picker/references/design.md +21 -0
- package/skills/ui-picker/references/domain.md +26 -0
- package/skills/ui-picker/references/template.md +24 -0
- package/skills/ux-spec/SKILL.md +51 -0
- 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 </script> 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()
|