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,630 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Process-boundary tests for the preview MCP stdio transport."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import socket
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import unittest
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from unittest import mock
|
|
17
|
+
|
|
18
|
+
# Sibling modules live next to this file. pytest's default prepend mode only
|
|
19
|
+
# puts this dir on sys.path[0] when it has no __init__.py; mcp/preview/ is now
|
|
20
|
+
# a package (see __init__.py) so the two same-named test_server_stdio.py files
|
|
21
|
+
# in preview/ and evidence/ collect under package-qualified names without an
|
|
22
|
+
# import-mismatch — so make the sibling dir importable explicitly here.
|
|
23
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
24
|
+
# Browser behavior is tested through its owning adapter, not server re-exports.
|
|
25
|
+
import browser # noqa: E402
|
|
26
|
+
import transaction # noqa: E402
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
SERVER = Path(__file__).with_name("server.py")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# G5: the parent page embeds a one-time dpb_token + dpb_round as hidden fields.
|
|
33
|
+
# Tests that POST /decide must GET / first and lift them — the same path a real
|
|
34
|
+
# human submit takes through the trusted control form (not a forged fetch).
|
|
35
|
+
_TOKEN_RE = re.compile(r'name="dpb_token"\s+value="([^"]*)"')
|
|
36
|
+
_ROUND_RE = re.compile(r'name="dpb_round"\s+value="([^"]*)"')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _fetch_decision_token(port: int, timeout: float = 3.0) -> tuple[str, int]:
|
|
40
|
+
"""GET / and extract the one-time dpb_token + dpb_round from the parent form."""
|
|
41
|
+
deadline = time.time() + 5
|
|
42
|
+
while time.time() < deadline:
|
|
43
|
+
try:
|
|
44
|
+
with socket.create_connection(("127.0.0.1", port), timeout=timeout) as sock:
|
|
45
|
+
sock.sendall(
|
|
46
|
+
f"GET / HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"
|
|
47
|
+
"Connection: close\r\n\r\n".encode("ascii")
|
|
48
|
+
)
|
|
49
|
+
chunks: list[bytes] = []
|
|
50
|
+
while True:
|
|
51
|
+
data = sock.recv(65536)
|
|
52
|
+
if not data:
|
|
53
|
+
break
|
|
54
|
+
chunks.append(data)
|
|
55
|
+
body = b"".join(chunks).decode("utf-8", errors="replace")
|
|
56
|
+
m_tok = _TOKEN_RE.search(body)
|
|
57
|
+
m_round = _ROUND_RE.search(body)
|
|
58
|
+
if m_tok and m_round:
|
|
59
|
+
return m_tok.group(1), int(m_round.group(1))
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
time.sleep(0.02)
|
|
63
|
+
raise AssertionError("could not fetch dpb_token/dpb_round from parent page")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_server_module():
|
|
67
|
+
spec = importlib.util.spec_from_file_location("dpb_preview_server", SERVER)
|
|
68
|
+
assert spec is not None and spec.loader is not None
|
|
69
|
+
mod = importlib.util.module_from_spec(spec)
|
|
70
|
+
spec.loader.exec_module(mod)
|
|
71
|
+
return mod
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PreviewMcpStdioTests(unittest.TestCase):
|
|
75
|
+
def test_claude_code_newline_json_can_initialize_and_list_tools(self) -> None:
|
|
76
|
+
requests = [
|
|
77
|
+
{
|
|
78
|
+
"jsonrpc": "2.0",
|
|
79
|
+
"id": 1,
|
|
80
|
+
"method": "initialize",
|
|
81
|
+
"params": {"protocolVersion": "2025-06-18"},
|
|
82
|
+
},
|
|
83
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
|
84
|
+
]
|
|
85
|
+
wire_input = "".join(
|
|
86
|
+
json.dumps(request, ensure_ascii=False) + "\n" for request in requests
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
completed = subprocess.run(
|
|
90
|
+
[sys.executable, str(SERVER)],
|
|
91
|
+
input=wire_input,
|
|
92
|
+
text=True,
|
|
93
|
+
encoding="utf-8",
|
|
94
|
+
capture_output=True,
|
|
95
|
+
timeout=5,
|
|
96
|
+
check=False,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
self.assertEqual(completed.returncode, 0, completed.stderr)
|
|
100
|
+
responses = [
|
|
101
|
+
json.loads(line)
|
|
102
|
+
for line in completed.stdout.splitlines()
|
|
103
|
+
if line.strip()
|
|
104
|
+
]
|
|
105
|
+
self.assertEqual([response["id"] for response in responses], [1, 2])
|
|
106
|
+
self.assertEqual(
|
|
107
|
+
responses[0]["result"]["serverInfo"]["name"],
|
|
108
|
+
"design-playbook-preview",
|
|
109
|
+
)
|
|
110
|
+
self.assertEqual(
|
|
111
|
+
[tool["name"] for tool in responses[1]["result"]["tools"]],
|
|
112
|
+
["preview_prototype"],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def test_active_lock_returns_structured_error_over_stdio(self) -> None:
|
|
116
|
+
html = "<html><body>locked</body></html>"
|
|
117
|
+
options = ["确认通过", "需要修改"]
|
|
118
|
+
binding = transaction._binding(
|
|
119
|
+
round_n=1,
|
|
120
|
+
prototype_hash=transaction.prototype_html_digest(html.encode("utf-8")),
|
|
121
|
+
report_ref="report.md", summary="review", options=options,
|
|
122
|
+
)
|
|
123
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
124
|
+
preview = Path(tmp) / ".scratch" / "preview-adapter" / "preview"
|
|
125
|
+
preview.mkdir(parents=True)
|
|
126
|
+
lock = preview / "decision-round-1.lock"
|
|
127
|
+
lock.write_text(json.dumps({
|
|
128
|
+
"owner_id": "active", "decision_id": "existing-id",
|
|
129
|
+
"binding_digest": binding["digest"], "heartbeat": time.time(),
|
|
130
|
+
}), encoding="utf-8")
|
|
131
|
+
request = {
|
|
132
|
+
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
133
|
+
"params": {
|
|
134
|
+
"name": "preview_prototype",
|
|
135
|
+
"arguments": {
|
|
136
|
+
"html": html, "summary": "review", "round": 1,
|
|
137
|
+
"report_ref": "report.md", "options": options,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
completed = subprocess.run(
|
|
142
|
+
[sys.executable, str(SERVER)],
|
|
143
|
+
input=json.dumps(request, ensure_ascii=False) + "\n",
|
|
144
|
+
text=True, encoding="utf-8", capture_output=True,
|
|
145
|
+
cwd=tmp, timeout=5, check=False,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
self.assertEqual(completed.returncode, 0, completed.stderr)
|
|
149
|
+
result = json.loads(completed.stdout)["result"]
|
|
150
|
+
self.assertTrue(result["isError"])
|
|
151
|
+
self.assertIn("already active", result["content"][0]["text"])
|
|
152
|
+
self.assertEqual(result["structuredContent"]["round"], 1)
|
|
153
|
+
self.assertEqual(result["structuredContent"]["decision_id"], "existing-id")
|
|
154
|
+
self.assertTrue(result["structuredContent"]["retryable"])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class PreviewStructuredErrorTests(unittest.TestCase):
|
|
158
|
+
def test_handler_maps_recovery_error_to_structured_tool_error(self) -> None:
|
|
159
|
+
server_mod = _load_server_module()
|
|
160
|
+
domain_error = transaction.PreviewTransactionError(
|
|
161
|
+
"repair required", retryable=True, round_n=2,
|
|
162
|
+
decision_id="abc", artifact="decision-round-2.json",
|
|
163
|
+
)
|
|
164
|
+
with mock.patch.object(
|
|
165
|
+
server_mod, "run_preview_transaction", side_effect=domain_error
|
|
166
|
+
):
|
|
167
|
+
with self.assertRaises(server_mod.ToolError) as caught:
|
|
168
|
+
server_mod.handle_preview_prototype({
|
|
169
|
+
"html": "<html></html>", "summary": "review", "round": 2,
|
|
170
|
+
"report_ref": "report.md",
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
self.assertEqual(str(caught.exception), "repair required")
|
|
174
|
+
self.assertEqual(caught.exception.structured_content, domain_error.details)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class PreviewWindowTests(unittest.TestCase):
|
|
178
|
+
def test_open_preview_window_uses_centered_app_window_not_fullscreen(self) -> None:
|
|
179
|
+
fake_proc = mock.Mock(pid=4242)
|
|
180
|
+
with mock.patch.object(browser, "_screen_size", return_value=(1920, 1080)), mock.patch.object(
|
|
181
|
+
browser, "_browser_candidates", return_value=["browser.exe"]
|
|
182
|
+
), mock.patch.object(browser.tempfile, "mkdtemp", return_value="profile-dir"), mock.patch.object(
|
|
183
|
+
browser.subprocess, "Popen", return_value=fake_proc
|
|
184
|
+
) as popen:
|
|
185
|
+
proc, profile = browser._open_preview_window(
|
|
186
|
+
"http://127.0.0.1:4321/", width=1100, height=780
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
self.assertIs(proc, fake_proc)
|
|
190
|
+
self.assertEqual(profile, "profile-dir")
|
|
191
|
+
command = popen.call_args.args[0]
|
|
192
|
+
self.assertIn("--app=http://127.0.0.1:4321/", command)
|
|
193
|
+
self.assertIn("--window-size=1100,780", command)
|
|
194
|
+
self.assertIn("--window-position=410,150", command)
|
|
195
|
+
self.assertNotIn("--start-maximized", command)
|
|
196
|
+
self.assertNotIn("--start-fullscreen", command)
|
|
197
|
+
self.assertNotIn("--kiosk", command)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class PreviewCollectShutdownTests(unittest.TestCase):
|
|
201
|
+
def test_collect_returns_when_client_keeps_connection_open(self) -> None:
|
|
202
|
+
"""POST /decide must not hang MCP on HTTP keep-alive (dogfood 006 hang)."""
|
|
203
|
+
port_box: dict[str, int] = {}
|
|
204
|
+
sticky_done = threading.Event()
|
|
205
|
+
|
|
206
|
+
def sticky_client() -> None:
|
|
207
|
+
deadline = time.time() + 5
|
|
208
|
+
port = None
|
|
209
|
+
while time.time() < deadline:
|
|
210
|
+
port = port_box.get("port")
|
|
211
|
+
if port:
|
|
212
|
+
break
|
|
213
|
+
time.sleep(0.02)
|
|
214
|
+
if not port:
|
|
215
|
+
sticky_done.set()
|
|
216
|
+
return
|
|
217
|
+
from urllib.parse import quote
|
|
218
|
+
|
|
219
|
+
token, round_n = _fetch_decision_token(port)
|
|
220
|
+
body = (
|
|
221
|
+
"choice=%E7%A1%AE%E8%AE%A4%E9%80%9A%E8%BF%87"
|
|
222
|
+
"&feedback="
|
|
223
|
+
"&anchors_json=%5B%5D"
|
|
224
|
+
+ f"&dpb_token={quote(token)}"
|
|
225
|
+
+ f"&dpb_round={round_n}"
|
|
226
|
+
)
|
|
227
|
+
payload = body.encode("ascii")
|
|
228
|
+
req = (
|
|
229
|
+
f"POST /decide HTTP/1.1\r\n"
|
|
230
|
+
f"Host: 127.0.0.1:{port}\r\n"
|
|
231
|
+
f"Content-Type: application/x-www-form-urlencoded\r\n"
|
|
232
|
+
f"Content-Length: {len(payload)}\r\n"
|
|
233
|
+
f"Connection: keep-alive\r\n"
|
|
234
|
+
f"\r\n"
|
|
235
|
+
).encode("ascii") + payload
|
|
236
|
+
sock = socket.create_connection(("127.0.0.1", port), timeout=3)
|
|
237
|
+
try:
|
|
238
|
+
sock.sendall(req)
|
|
239
|
+
sock.settimeout(3)
|
|
240
|
+
try:
|
|
241
|
+
data = sock.recv(65536)
|
|
242
|
+
# Response must request close so Chromium cannot pin serve_forever.
|
|
243
|
+
self.assertIn(b"Connection: close", data)
|
|
244
|
+
except socket.timeout:
|
|
245
|
+
pass
|
|
246
|
+
time.sleep(3)
|
|
247
|
+
finally:
|
|
248
|
+
try:
|
|
249
|
+
sock.close()
|
|
250
|
+
except OSError:
|
|
251
|
+
pass
|
|
252
|
+
sticky_done.set()
|
|
253
|
+
|
|
254
|
+
real_http = browser.HTTPServer
|
|
255
|
+
|
|
256
|
+
class StashingHTTPServer(real_http): # type: ignore[misc, valid-type]
|
|
257
|
+
def __init__(self, *args, **kwargs):
|
|
258
|
+
super().__init__(*args, **kwargs)
|
|
259
|
+
port_box["port"] = self.server_address[1]
|
|
260
|
+
|
|
261
|
+
client_thread = threading.Thread(target=sticky_client, daemon=True)
|
|
262
|
+
client_thread.start()
|
|
263
|
+
|
|
264
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
265
|
+
proto = Path(tmp) / "proto.html"
|
|
266
|
+
proto.write_text(
|
|
267
|
+
"<html><body><h1>proto</h1></body></html>",
|
|
268
|
+
encoding="utf-8",
|
|
269
|
+
)
|
|
270
|
+
with mock.patch.object(browser, "HTTPServer", StashingHTTPServer), mock.patch.object(
|
|
271
|
+
browser, "_open_preview_window", return_value=(None, None)
|
|
272
|
+
), mock.patch.object(browser, "_request_browser_window_close") as close_window, mock.patch.object(
|
|
273
|
+
browser, "_kill_browser_proc"
|
|
274
|
+
) as kill_browser:
|
|
275
|
+
started = time.monotonic()
|
|
276
|
+
decision = browser._collect_via_browser(
|
|
277
|
+
proto,
|
|
278
|
+
"summary for test",
|
|
279
|
+
["\u786e\u8ba4\u901a\u8fc7", "\u9700\u8981\u4fee\u6539"],
|
|
280
|
+
1,
|
|
281
|
+
)
|
|
282
|
+
elapsed = time.monotonic() - started
|
|
283
|
+
|
|
284
|
+
close_window.assert_called_once_with(None)
|
|
285
|
+
kill_browser.assert_called_once_with(None, None)
|
|
286
|
+
sticky_done.wait(timeout=5)
|
|
287
|
+
self.assertLess(
|
|
288
|
+
elapsed,
|
|
289
|
+
2.5,
|
|
290
|
+
f"collect hung under keep-alive client: {elapsed:.2f}s",
|
|
291
|
+
)
|
|
292
|
+
self.assertEqual(decision["choice"], "确认通过")
|
|
293
|
+
self.assertEqual(decision["feedback"], "")
|
|
294
|
+
self.assertFalse(decision["aborted"])
|
|
295
|
+
|
|
296
|
+
def test_modify_submission_returns_dom_anchor_and_closes_owned_window(self) -> None:
|
|
297
|
+
port_box: dict[str, int] = {}
|
|
298
|
+
anchor = {"selector": "#submit", "comment": "\u6309\u94ae\u5c42\u7ea7\u4e0d\u6e05\u6670", "label": "Retry", "tag": ""}
|
|
299
|
+
|
|
300
|
+
def submit_modify() -> None:
|
|
301
|
+
from urllib.parse import quote
|
|
302
|
+
|
|
303
|
+
deadline = time.time() + 5
|
|
304
|
+
while time.time() < deadline and not port_box.get("port"):
|
|
305
|
+
time.sleep(0.02)
|
|
306
|
+
port = port_box["port"]
|
|
307
|
+
token, round_n = _fetch_decision_token(port)
|
|
308
|
+
body = (
|
|
309
|
+
"choice=%E9%9C%80%E8%A6%81%E4%BF%AE%E6%94%B9"
|
|
310
|
+
"&feedback=%E8%AF%B7%E8%B0%83%E6%95%B4"
|
|
311
|
+
"&anchors_json="
|
|
312
|
+
+ quote(json.dumps([anchor], ensure_ascii=False))
|
|
313
|
+
+ f"&dpb_token={quote(token)}"
|
|
314
|
+
+ f"&dpb_round={round_n}"
|
|
315
|
+
)
|
|
316
|
+
payload = body.encode("ascii")
|
|
317
|
+
request = (
|
|
318
|
+
f"POST /decide HTTP/1.1\r\n"
|
|
319
|
+
f"Host: 127.0.0.1:{port}\r\n"
|
|
320
|
+
"Content-Type: application/x-www-form-urlencoded\r\n"
|
|
321
|
+
f"Content-Length: {len(payload)}\r\n"
|
|
322
|
+
"Connection: close\r\n\r\n"
|
|
323
|
+
).encode("ascii") + payload
|
|
324
|
+
with socket.create_connection(("127.0.0.1", port), timeout=3) as sock:
|
|
325
|
+
sock.sendall(request)
|
|
326
|
+
sock.recv(65536)
|
|
327
|
+
|
|
328
|
+
real_http = browser.HTTPServer
|
|
329
|
+
|
|
330
|
+
class StashingHTTPServer(real_http): # type: ignore[misc, valid-type]
|
|
331
|
+
def __init__(self, *args, **kwargs):
|
|
332
|
+
super().__init__(*args, **kwargs)
|
|
333
|
+
port_box["port"] = self.server_address[1]
|
|
334
|
+
|
|
335
|
+
client_thread = threading.Thread(target=submit_modify, daemon=True)
|
|
336
|
+
client_thread.start()
|
|
337
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
338
|
+
proto = Path(tmp) / "proto.html"
|
|
339
|
+
proto.write_text(
|
|
340
|
+
"<html><body><button id='submit'>Retry</button></body></html>",
|
|
341
|
+
encoding="utf-8",
|
|
342
|
+
)
|
|
343
|
+
with mock.patch.object(browser, "HTTPServer", StashingHTTPServer), mock.patch.object(
|
|
344
|
+
browser, "_open_preview_window", return_value=(mock.sentinel.proc, "profile")
|
|
345
|
+
), mock.patch.object(browser, "_request_browser_window_close") as close_window, mock.patch.object(
|
|
346
|
+
browser, "_kill_browser_proc"
|
|
347
|
+
) as kill_browser, mock.patch.object(browser, "_rm_tree"):
|
|
348
|
+
decision = browser._collect_via_browser(
|
|
349
|
+
proto, "summary", ["\u786e\u8ba4\u901a\u8fc7", "\u9700\u8981\u4fee\u6539"], 1
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
client_thread.join(timeout=3)
|
|
353
|
+
self.assertFalse(client_thread.is_alive())
|
|
354
|
+
self.assertEqual(decision["choice"], "\u9700\u8981\u4fee\u6539")
|
|
355
|
+
self.assertEqual(decision["feedback"], "\u8bf7\u8c03\u6574")
|
|
356
|
+
self.assertEqual(decision["anchors"], [anchor])
|
|
357
|
+
self.assertFalse(decision["aborted"])
|
|
358
|
+
close_window.assert_called_once_with(mock.sentinel.proc)
|
|
359
|
+
kill_browser.assert_called_once_with(mock.sentinel.proc, "profile")
|
|
360
|
+
|
|
361
|
+
def test_post_with_bogus_token_is_rejected(self) -> None:
|
|
362
|
+
"""G5 stdio e2e: a forged POST carrying an arbitrary dpb_token (which
|
|
363
|
+
an attacker controls in the POST body) must NOT abort the session -
|
|
364
|
+
only a genuinely validated decision ends it. The bogus POST is fail
|
|
365
|
+
closed (confirmed=False), then the real user's valid-token POST still
|
|
366
|
+
confirms, proving the session was not hijacked.
|
|
367
|
+
"""
|
|
368
|
+
port_box: dict[str, int] = {}
|
|
369
|
+
|
|
370
|
+
def bogus_then_valid_client() -> None:
|
|
371
|
+
deadline = time.time() + 5
|
|
372
|
+
port = None
|
|
373
|
+
while time.time() < deadline:
|
|
374
|
+
port = port_box.get("port")
|
|
375
|
+
if port:
|
|
376
|
+
break
|
|
377
|
+
time.sleep(0.02)
|
|
378
|
+
if not port:
|
|
379
|
+
return
|
|
380
|
+
from urllib.parse import quote
|
|
381
|
+
|
|
382
|
+
# Lift the real token + round from the parent page.
|
|
383
|
+
token, round_n = _fetch_decision_token(port)
|
|
384
|
+
# 1) Forged POST: attacker-controlled arbitrary dpb_token. Must be
|
|
385
|
+
# fail closed AND must not end the session.
|
|
386
|
+
forged_body = (
|
|
387
|
+
"choice=%E7%A1%AE%E8%AE%A4%E9%80%9A%E8%BF%87"
|
|
388
|
+
"&feedback=forged"
|
|
389
|
+
"&anchors_json=%5B%5D"
|
|
390
|
+
"&dpb_token=bogus-not-the-real-token"
|
|
391
|
+
+ f"&dpb_round={round_n}"
|
|
392
|
+
)
|
|
393
|
+
for body in (forged_body,):
|
|
394
|
+
payload = body.encode("ascii")
|
|
395
|
+
req = (
|
|
396
|
+
f"POST /decide HTTP/1.1\r\n"
|
|
397
|
+
f"Host: 127.0.0.1:{port}\r\n"
|
|
398
|
+
"Content-Type: application/x-www-form-urlencoded\r\n"
|
|
399
|
+
f"Content-Length: {len(payload)}\r\n"
|
|
400
|
+
"Connection: close\r\n\r\n"
|
|
401
|
+
).encode("ascii") + payload
|
|
402
|
+
with socket.create_connection(("127.0.0.1", port), timeout=3) as sock:
|
|
403
|
+
sock.sendall(req)
|
|
404
|
+
sock.settimeout(3)
|
|
405
|
+
try:
|
|
406
|
+
sock.recv(65536)
|
|
407
|
+
except socket.timeout:
|
|
408
|
+
pass
|
|
409
|
+
# 2) Real user's valid-token POST: must still confirm (session was
|
|
410
|
+
# not hijacked by the forged POST).
|
|
411
|
+
valid_body = (
|
|
412
|
+
"choice=%E7%A1%AE%E8%AE%A4%E9%80%9A%E8%BF%87"
|
|
413
|
+
"&feedback=ok"
|
|
414
|
+
"&anchors_json=%5B%5D"
|
|
415
|
+
+ f"&dpb_token={quote(token)}"
|
|
416
|
+
+ f"&dpb_round={round_n}"
|
|
417
|
+
)
|
|
418
|
+
payload = valid_body.encode("ascii")
|
|
419
|
+
req = (
|
|
420
|
+
f"POST /decide HTTP/1.1\r\n"
|
|
421
|
+
f"Host: 127.0.0.1:{port}\r\n"
|
|
422
|
+
"Content-Type: application/x-www-form-urlencoded\r\n"
|
|
423
|
+
f"Content-Length: {len(payload)}\r\n"
|
|
424
|
+
"Connection: close\r\n\r\n"
|
|
425
|
+
).encode("ascii") + payload
|
|
426
|
+
with socket.create_connection(("127.0.0.1", port), timeout=3) as sock:
|
|
427
|
+
sock.sendall(req)
|
|
428
|
+
sock.settimeout(3)
|
|
429
|
+
try:
|
|
430
|
+
sock.recv(65536)
|
|
431
|
+
except socket.timeout:
|
|
432
|
+
pass
|
|
433
|
+
|
|
434
|
+
real_http = browser.HTTPServer
|
|
435
|
+
|
|
436
|
+
class StashingHTTPServer(real_http): # type: ignore[misc, valid-type]
|
|
437
|
+
def __init__(self, *args, **kwargs):
|
|
438
|
+
super().__init__(*args, **kwargs)
|
|
439
|
+
port_box["port"] = self.server_address[1]
|
|
440
|
+
|
|
441
|
+
client_thread = threading.Thread(target=bogus_then_valid_client, daemon=True)
|
|
442
|
+
client_thread.start()
|
|
443
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
444
|
+
proto = Path(tmp) / "proto.html"
|
|
445
|
+
proto.write_text(
|
|
446
|
+
"<html><body><h1>proto</h1></body></html>",
|
|
447
|
+
encoding="utf-8",
|
|
448
|
+
)
|
|
449
|
+
with mock.patch.object(browser, "HTTPServer", StashingHTTPServer), mock.patch.object(
|
|
450
|
+
browser, "_open_preview_window", return_value=(None, None)
|
|
451
|
+
), mock.patch.object(browser, "_request_browser_window_close"), mock.patch.object(
|
|
452
|
+
browser, "_kill_browser_proc"
|
|
453
|
+
):
|
|
454
|
+
decision = browser._collect_via_browser(
|
|
455
|
+
proto,
|
|
456
|
+
"summary",
|
|
457
|
+
["确认通过", "需要修改"],
|
|
458
|
+
1,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
client_thread.join(timeout=3)
|
|
462
|
+
self.assertFalse(client_thread.is_alive())
|
|
463
|
+
# The forged POST did not hijack: the real user's valid POST wins.
|
|
464
|
+
self.assertEqual(decision["choice"], "确认通过")
|
|
465
|
+
self.assertEqual(decision["feedback"], "ok")
|
|
466
|
+
self.assertFalse(decision["aborted"])
|
|
467
|
+
def test_replay_same_token_is_rejected(self) -> None:
|
|
468
|
+
"""G5 stdio e2e: first-decision-wins. The first valid POST locks the
|
|
469
|
+
session and sets the confirmed result; a replayed second POST with
|
|
470
|
+
the same token must NOT overwrite that result - the legitimate
|
|
471
|
+
confirmed decision survives.
|
|
472
|
+
"""
|
|
473
|
+
port_box: dict[str, int] = {}
|
|
474
|
+
|
|
475
|
+
def replay_client() -> None:
|
|
476
|
+
deadline = time.time() + 5
|
|
477
|
+
port = None
|
|
478
|
+
while time.time() < deadline:
|
|
479
|
+
port = port_box.get("port")
|
|
480
|
+
if port:
|
|
481
|
+
break
|
|
482
|
+
time.sleep(0.02)
|
|
483
|
+
if not port:
|
|
484
|
+
return
|
|
485
|
+
from urllib.parse import quote
|
|
486
|
+
|
|
487
|
+
token, round_n = _fetch_decision_token(port)
|
|
488
|
+
body = (
|
|
489
|
+
"choice=%E7%A1%AE%E8%AE%A4%E9%80%9A%E8%BF%87"
|
|
490
|
+
"&feedback=ok"
|
|
491
|
+
"&anchors_json=%5B%5D"
|
|
492
|
+
+ f"&dpb_token={quote(token)}"
|
|
493
|
+
+ f"&dpb_round={round_n}"
|
|
494
|
+
)
|
|
495
|
+
payload = body.encode("ascii")
|
|
496
|
+
req = (
|
|
497
|
+
f"POST /decide HTTP/1.1\r\n"
|
|
498
|
+
f"Host: 127.0.0.1:{port}\r\n"
|
|
499
|
+
"Content-Type: application/x-www-form-urlencoded\r\n"
|
|
500
|
+
f"Content-Length: {len(payload)}\r\n"
|
|
501
|
+
"Connection: close\r\n\r\n"
|
|
502
|
+
).encode("ascii") + payload
|
|
503
|
+
# POST 1: first valid use of the token - locks the session and
|
|
504
|
+
# ends it (done.set()). result is confirmed=True.
|
|
505
|
+
with socket.create_connection(("127.0.0.1", port), timeout=3) as sock:
|
|
506
|
+
sock.sendall(req)
|
|
507
|
+
sock.settimeout(3)
|
|
508
|
+
try:
|
|
509
|
+
sock.recv(65536)
|
|
510
|
+
except socket.timeout:
|
|
511
|
+
pass
|
|
512
|
+
# POST 2: same token - rejected as reuse by the session. It must
|
|
513
|
+
# not change the confirmed result already set by POST 1. (This
|
|
514
|
+
# POST may or may not be processed before shutdown; the assertion
|
|
515
|
+
# is that result stays confirmed regardless.)
|
|
516
|
+
try:
|
|
517
|
+
with socket.create_connection(("127.0.0.1", port), timeout=1) as sock:
|
|
518
|
+
sock.sendall(req)
|
|
519
|
+
sock.settimeout(1)
|
|
520
|
+
try:
|
|
521
|
+
sock.recv(65536)
|
|
522
|
+
except socket.timeout:
|
|
523
|
+
pass
|
|
524
|
+
except OSError:
|
|
525
|
+
# Server already shut down after POST 1 - acceptable; the
|
|
526
|
+
# replay never reached the handler, result is intact.
|
|
527
|
+
pass
|
|
528
|
+
|
|
529
|
+
real_http = browser.HTTPServer
|
|
530
|
+
|
|
531
|
+
class StashingHTTPServer(real_http): # type: ignore[misc, valid-type]
|
|
532
|
+
def __init__(self, *args, **kwargs):
|
|
533
|
+
super().__init__(*args, **kwargs)
|
|
534
|
+
port_box["port"] = self.server_address[1]
|
|
535
|
+
|
|
536
|
+
client_thread = threading.Thread(target=replay_client, daemon=True)
|
|
537
|
+
client_thread.start()
|
|
538
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
539
|
+
proto = Path(tmp) / "proto.html"
|
|
540
|
+
proto.write_text(
|
|
541
|
+
"<html><body><h1>proto</h1></body></html>",
|
|
542
|
+
encoding="utf-8",
|
|
543
|
+
)
|
|
544
|
+
with mock.patch.object(browser, "HTTPServer", StashingHTTPServer), mock.patch.object(
|
|
545
|
+
browser, "_open_preview_window", return_value=(None, None)
|
|
546
|
+
), mock.patch.object(browser, "_request_browser_window_close"), mock.patch.object(
|
|
547
|
+
browser, "_kill_browser_proc"
|
|
548
|
+
):
|
|
549
|
+
decision = browser._collect_via_browser(
|
|
550
|
+
proto,
|
|
551
|
+
"summary",
|
|
552
|
+
["确认通过", "需要修改"],
|
|
553
|
+
1,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
client_thread.join(timeout=3)
|
|
557
|
+
self.assertFalse(client_thread.is_alive())
|
|
558
|
+
# First-decision-wins: legitimate submission survives replay.
|
|
559
|
+
self.assertEqual(decision["choice"], "确认通过")
|
|
560
|
+
self.assertEqual(decision["feedback"], "ok")
|
|
561
|
+
self.assertFalse(decision["aborted"])
|
|
562
|
+
|
|
563
|
+
def test_stop_http_server_joins_serve_thread(self) -> None:
|
|
564
|
+
http = browser.HTTPServer(("127.0.0.1", 0), browser.BaseHTTPRequestHandler)
|
|
565
|
+
serve_thread = threading.Thread(target=http.serve_forever, daemon=True)
|
|
566
|
+
serve_thread.start()
|
|
567
|
+
|
|
568
|
+
browser._stop_http_server(http, serve_thread, timeout_s=0.4)
|
|
569
|
+
|
|
570
|
+
self.assertFalse(serve_thread.is_alive())
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class PreviewLogRejectionTests(unittest.TestCase):
|
|
574
|
+
"""LOW-4 (secure-ship-0.4.4): a rejected decision's rejection reason
|
|
575
|
+
must persist to preview_dir/log.md so a fail-closed G5 event (forged
|
|
576
|
+
token / replay / round mismatch) is auditable on disk, not just in the
|
|
577
|
+
in-memory MCP payload that vanishes when the call returns.
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
def _run_handle(self, server_mod: object, tmp: str, decision: dict) -> dict:
|
|
581
|
+
proto = Path(tmp) / "proto.html"
|
|
582
|
+
proto.write_text("<html></html>", encoding="utf-8")
|
|
583
|
+
with mock.patch.object(browser, "_collect_via_browser",
|
|
584
|
+
return_value=decision):
|
|
585
|
+
return server_mod.handle_preview_prototype(
|
|
586
|
+
{
|
|
587
|
+
"path": str(proto),
|
|
588
|
+
"summary": "summary",
|
|
589
|
+
"round": 1,
|
|
590
|
+
"report_ref": "report-1",
|
|
591
|
+
}
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
def test_rejected_decision_writes_rejection_line_to_log(self) -> None:
|
|
595
|
+
server_mod = _load_server_module()
|
|
596
|
+
rejected_decision = {
|
|
597
|
+
"choice": "",
|
|
598
|
+
"feedback": "forged",
|
|
599
|
+
"aborted": True,
|
|
600
|
+
"anchors": [],
|
|
601
|
+
"rejected": True,
|
|
602
|
+
"rejection": "invalid_token",
|
|
603
|
+
}
|
|
604
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
605
|
+
payload = self._run_handle(server_mod, tmp, rejected_decision)
|
|
606
|
+
log_text = (Path(tmp) / "log.md").read_text(encoding="utf-8")
|
|
607
|
+
|
|
608
|
+
self.assertFalse(payload["confirmed"])
|
|
609
|
+
self.assertIn("- rejected: true", log_text)
|
|
610
|
+
self.assertIn("- rejection: invalid_token", log_text)
|
|
611
|
+
|
|
612
|
+
def test_confirmed_decision_does_not_write_rejection_line(self) -> None:
|
|
613
|
+
server_mod = _load_server_module()
|
|
614
|
+
confirmed_decision = {
|
|
615
|
+
"choice": "确认通过",
|
|
616
|
+
"feedback": "ok",
|
|
617
|
+
"aborted": False,
|
|
618
|
+
"anchors": [],
|
|
619
|
+
}
|
|
620
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
621
|
+
payload = self._run_handle(server_mod, tmp, confirmed_decision)
|
|
622
|
+
log_text = (Path(tmp) / "log.md").read_text(encoding="utf-8")
|
|
623
|
+
|
|
624
|
+
self.assertTrue(payload["confirmed"])
|
|
625
|
+
self.assertNotIn("rejected:", log_text)
|
|
626
|
+
self.assertNotIn("rejection:", log_text)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
if __name__ == "__main__":
|
|
630
|
+
unittest.main()
|