design-playbook 0.8.0 → 0.9.1
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/mcp/evidence/server.py +26 -1
- package/mcp/evidence/test_server_stdio.py +54 -0
- package/mcp/preview/browser.py +61 -6
- package/mcp/preview/server.py +5 -2
- package/mcp/preview/test_browser_control.py +2 -2
- package/mcp/preview/transaction.py +84 -7
- package/mcp/preview/util.py +18 -2
- package/package.json +1 -1
- package/skills/design-playbook/SKILL.md +1 -1
- package/mcp/preview/confirm.py +0 -183
package/mcp/evidence/server.py
CHANGED
|
@@ -142,9 +142,34 @@ def _captured(
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
_RUN_MARKERS = ("plan.md", "point-back.md")
|
|
146
|
+
_warned_run_root = False
|
|
147
|
+
|
|
148
|
+
|
|
145
149
|
def _run_root() -> Path:
|
|
146
150
|
configured = os.environ.get(RUN_ROOT_ENV)
|
|
147
|
-
|
|
151
|
+
if not configured or configured == ".":
|
|
152
|
+
# cwd-relative default silently mis-roots multi-run workspaces (the
|
|
153
|
+
# root .mcp.json ships DESIGN_PLAYBOOK_RUN_ROOT="."). Warn only when
|
|
154
|
+
# cwd does not look like a run dir (no run marker file) — the shipped
|
|
155
|
+
# default resolving to a real run dir is correct usage, not a
|
|
156
|
+
# misconfig — and only once per process to avoid per-capture spam.
|
|
157
|
+
root = Path.cwd().resolve()
|
|
158
|
+
global _warned_run_root
|
|
159
|
+
if not _warned_run_root and not any(
|
|
160
|
+
(root / marker).is_file() for marker in _RUN_MARKERS
|
|
161
|
+
):
|
|
162
|
+
_warned_run_root = True
|
|
163
|
+
_log(
|
|
164
|
+
"WARNING: DESIGN_PLAYBOOK_RUN_ROOT is unset or '.' "
|
|
165
|
+
f"(cwd-relative) and {root} has no run marker "
|
|
166
|
+
f"({' / '.join(_RUN_MARKERS)}); artifacts resolve under "
|
|
167
|
+
f"{root}/evidence/. Set DESIGN_PLAYBOOK_RUN_ROOT to the run "
|
|
168
|
+
"root when the host workspace is not the intended run "
|
|
169
|
+
"directory."
|
|
170
|
+
)
|
|
171
|
+
return root
|
|
172
|
+
return Path(configured).resolve()
|
|
148
173
|
|
|
149
174
|
|
|
150
175
|
def _resolve_artifact_path(artifact_path: str) -> Path:
|
|
@@ -18,6 +18,7 @@ whose names did not match any keyword. See review item M3.
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import json
|
|
21
|
+
import os
|
|
21
22
|
import subprocess
|
|
22
23
|
import sys
|
|
23
24
|
import tempfile
|
|
@@ -50,6 +51,7 @@ def _run_stdio(
|
|
|
50
51
|
*,
|
|
51
52
|
cwd: Path | None = None,
|
|
52
53
|
no_site: bool = False,
|
|
54
|
+
env: dict[str, str] | None = None,
|
|
53
55
|
) -> subprocess.CompletedProcess[str]:
|
|
54
56
|
wire_input = "".join(
|
|
55
57
|
json.dumps(request, ensure_ascii=False) + "\n" for request in requests
|
|
@@ -67,6 +69,7 @@ def _run_stdio(
|
|
|
67
69
|
timeout=timeout,
|
|
68
70
|
check=False,
|
|
69
71
|
cwd=cwd,
|
|
72
|
+
env=env,
|
|
70
73
|
)
|
|
71
74
|
|
|
72
75
|
|
|
@@ -204,6 +207,57 @@ class EvidencePurePathTests(unittest.TestCase):
|
|
|
204
207
|
self.assertEqual(payload["observed_state"], "unknown")
|
|
205
208
|
self.assertFalse(outside.exists())
|
|
206
209
|
|
|
210
|
+
def test_run_root_warning_only_without_run_marker_and_once(self) -> None:
|
|
211
|
+
"""Default RUN_ROOT ('.'/unset) must not warn when cwd is a run dir.
|
|
212
|
+
|
|
213
|
+
Shipped defaults (root .mcp.json RUN_ROOT="." / external installs
|
|
214
|
+
unset) are correct usage when cwd is the run dir — warning there is a
|
|
215
|
+
100% false positive. Warn only when cwd lacks a run marker
|
|
216
|
+
(plan.md / point-back.md), and only once per process.
|
|
217
|
+
"""
|
|
218
|
+
def _call(request_id: int) -> dict:
|
|
219
|
+
return {
|
|
220
|
+
"jsonrpc": "2.0",
|
|
221
|
+
"id": request_id,
|
|
222
|
+
"method": "tools/call",
|
|
223
|
+
"params": {
|
|
224
|
+
"name": "execute_capture_plan",
|
|
225
|
+
"arguments": {
|
|
226
|
+
"url": "about:blank",
|
|
227
|
+
"type": "screenshot",
|
|
228
|
+
"state": "ok",
|
|
229
|
+
"actions": [],
|
|
230
|
+
# Rejected before Playwright import, but after
|
|
231
|
+
# _run_root() — cheap way to reach the warning path.
|
|
232
|
+
"artifact_path": "spec.md",
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
requests = [
|
|
238
|
+
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
|
|
239
|
+
_call(2),
|
|
240
|
+
_call(3),
|
|
241
|
+
]
|
|
242
|
+
env = {k: v for k, v in os.environ.items() if k != "DESIGN_PLAYBOOK_RUN_ROOT"}
|
|
243
|
+
|
|
244
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
245
|
+
bare = Path(tmp)
|
|
246
|
+
completed = _run_stdio(requests, timeout=15, cwd=bare, env=env)
|
|
247
|
+
self.assertEqual(completed.returncode, 0, completed.stderr)
|
|
248
|
+
self.assertEqual(
|
|
249
|
+
completed.stderr.count("DESIGN_PLAYBOOK_RUN_ROOT is unset or '.'"),
|
|
250
|
+
1,
|
|
251
|
+
completed.stderr,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
255
|
+
run_dir = Path(tmp)
|
|
256
|
+
(run_dir / "plan.md").write_text("# plan", encoding="utf-8")
|
|
257
|
+
completed = _run_stdio(requests, timeout=15, cwd=run_dir, env=env)
|
|
258
|
+
self.assertEqual(completed.returncode, 0, completed.stderr)
|
|
259
|
+
self.assertNotIn("DESIGN_PLAYBOOK_RUN_ROOT is unset", completed.stderr)
|
|
260
|
+
|
|
207
261
|
def test_provider_rejects_non_evidence_subtree_paths(self) -> None:
|
|
208
262
|
"""G6 containment: artifact_path must already live under evidence/."""
|
|
209
263
|
with tempfile.TemporaryDirectory() as tmp:
|
package/mcp/preview/browser.py
CHANGED
|
@@ -12,6 +12,7 @@ import html
|
|
|
12
12
|
import json
|
|
13
13
|
import os
|
|
14
14
|
import re
|
|
15
|
+
import secrets
|
|
15
16
|
import shutil
|
|
16
17
|
import subprocess
|
|
17
18
|
import sys
|
|
@@ -23,14 +24,68 @@ from pathlib import Path
|
|
|
23
24
|
from typing import Any
|
|
24
25
|
from urllib.parse import parse_qs
|
|
25
26
|
|
|
26
|
-
from confirm import (
|
|
27
|
-
_DecisionSession,
|
|
28
|
-
_generate_decision_token,
|
|
29
|
-
prototype_html_digest,
|
|
30
|
-
)
|
|
31
27
|
from control import _build_control
|
|
32
28
|
from i18n import lang, t
|
|
33
|
-
from util import _log
|
|
29
|
+
from util import _log, prototype_html_digest
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _generate_decision_token() -> str:
|
|
33
|
+
"""One-time URL-safe decision token (G5 trust boundary).
|
|
34
|
+
|
|
35
|
+
Proves a POST to /decide originated from the trusted parent control bar
|
|
36
|
+
(which renders the hidden field) rather than from prototype scripts running
|
|
37
|
+
inside the sandboxed iframe, which cannot read the parent DOM. Bound to a
|
|
38
|
+
single preview round via :class:`_DecisionSession`.
|
|
39
|
+
"""
|
|
40
|
+
return secrets.token_urlsafe(32)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _DecisionSession:
|
|
44
|
+
"""First-decision-wins token lock for a single preview round (G5).
|
|
45
|
+
|
|
46
|
+
``validate`` returns ``True`` only for the first POST whose token matches
|
|
47
|
+
(constant-time) AND whose round matches. Every other POST — missing token,
|
|
48
|
+
reused token, mismatched round, or wrong token — is rejected so the caller
|
|
49
|
+
can fail the decision closed. The session grants at most one valid decision.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, round_n: int, token: str) -> None:
|
|
53
|
+
self.round_n = round_n
|
|
54
|
+
self._token = token
|
|
55
|
+
self._locked = False
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
self.last_rejection: str = ""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def locked(self) -> bool:
|
|
61
|
+
return self._locked
|
|
62
|
+
|
|
63
|
+
def validate(self, posted_round: int, posted_token: str | None) -> bool:
|
|
64
|
+
# LOW-1 (secure-ship-0.4.4): the check-then-set on ``_locked`` must
|
|
65
|
+
# be atomic. ThreadingHTTPServer handles each POST on its own
|
|
66
|
+
# thread, so two concurrent valid-token POSTs could both pass the
|
|
67
|
+
# ``if self._locked`` check and each consume the session. Hold the
|
|
68
|
+
# lock for the whole decision so first-decision-wins holds under
|
|
69
|
+
# real concurrency; the lock is uncontended in the single-POST
|
|
70
|
+
# happy path, so the cost is a no-op acquire/release.
|
|
71
|
+
with self._lock:
|
|
72
|
+
if not posted_token:
|
|
73
|
+
self.last_rejection = "missing"
|
|
74
|
+
return False
|
|
75
|
+
if posted_round != self.round_n:
|
|
76
|
+
self.last_rejection = "round_mismatch"
|
|
77
|
+
return False
|
|
78
|
+
if self._locked:
|
|
79
|
+
# First valid decision already consumed the session; every
|
|
80
|
+
# later POST (even with the correct token) is a replay.
|
|
81
|
+
self.last_rejection = "reuse"
|
|
82
|
+
return False
|
|
83
|
+
if not secrets.compare_digest(posted_token, self._token):
|
|
84
|
+
self.last_rejection = "invalid_token"
|
|
85
|
+
return False
|
|
86
|
+
self._locked = True
|
|
87
|
+
self.last_rejection = ""
|
|
88
|
+
return True
|
|
34
89
|
|
|
35
90
|
|
|
36
91
|
def _screen_size() -> tuple[int, int]:
|
package/mcp/preview/server.py
CHANGED
|
@@ -24,9 +24,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
24
24
|
from _transport import ToolError, serve_stdio # noqa: E402
|
|
25
25
|
|
|
26
26
|
import browser
|
|
27
|
-
from confirm import _self_check_floor
|
|
28
27
|
from i18n import default_options
|
|
29
|
-
from transaction import
|
|
28
|
+
from transaction import (
|
|
29
|
+
PreviewTransactionError,
|
|
30
|
+
_self_check_floor,
|
|
31
|
+
run_preview_transaction,
|
|
32
|
+
)
|
|
30
33
|
|
|
31
34
|
TOOL_NAME = "preview_prototype"
|
|
32
35
|
|
|
@@ -33,11 +33,11 @@ from urllib.parse import urlencode
|
|
|
33
33
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
34
34
|
import browser # noqa: E402
|
|
35
35
|
import control as preview_control # noqa: E402
|
|
36
|
-
from
|
|
36
|
+
from browser import ( # noqa: E402
|
|
37
37
|
_DecisionSession,
|
|
38
38
|
_generate_decision_token,
|
|
39
|
-
prototype_html_digest,
|
|
40
39
|
)
|
|
40
|
+
from util import prototype_html_digest # noqa: E402
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
# --------------------------------------------------------------------------- #
|
|
@@ -17,20 +17,97 @@ from contextlib import contextmanager
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from typing import Any, Callable, Iterator
|
|
19
19
|
|
|
20
|
-
from confirm import (
|
|
21
|
-
_check_feedback_floor,
|
|
22
|
-
_ensure_prototype,
|
|
23
|
-
_preview_dir_for,
|
|
24
|
-
prototype_html_digest,
|
|
25
|
-
)
|
|
26
20
|
from control import _format_feedback
|
|
27
21
|
from i18n import CONFIRM_LABELS
|
|
28
|
-
from util import _now_iso
|
|
22
|
+
from util import _now_iso, prototype_html_digest
|
|
29
23
|
|
|
30
24
|
BrowserCollector = Callable[[Path, str, list[str], int], dict[str, Any]]
|
|
31
25
|
ENTRY_SCHEMA_VERSION = 1
|
|
32
26
|
|
|
33
27
|
|
|
28
|
+
def _preview_dir_for(path: Path | None) -> Path:
|
|
29
|
+
if path is not None:
|
|
30
|
+
return path.parent
|
|
31
|
+
scratch = Path.cwd() / ".scratch" / "preview-adapter" / "preview"
|
|
32
|
+
scratch.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return scratch
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ensure_prototype(path_arg: str | None, html: str | None, round_n: int,
|
|
37
|
+
preview_dir: Path) -> Path:
|
|
38
|
+
if path_arg:
|
|
39
|
+
p = Path(path_arg)
|
|
40
|
+
if not p.is_file():
|
|
41
|
+
raise ValueError(f"prototype path does not exist: {path_arg}")
|
|
42
|
+
return p
|
|
43
|
+
if not html:
|
|
44
|
+
raise ValueError("path or html is required")
|
|
45
|
+
preview_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
target = preview_dir / f"round-{round_n}.html"
|
|
47
|
+
target.write_text(html, encoding="utf-8")
|
|
48
|
+
return target
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _check_feedback_floor(feedback: str,
|
|
52
|
+
anchors: list[dict[str, Any]]) -> tuple[bool, str]:
|
|
53
|
+
"""ADR-0008 preview feedback floor (structural, machine-checkable).
|
|
54
|
+
|
|
55
|
+
Passes when:
|
|
56
|
+
- (non-empty feedback OR >=1 anchor present) as trigger, AND
|
|
57
|
+
- every present anchor (if any) has non-empty selector AND non-empty comment.
|
|
58
|
+
|
|
59
|
+
Deliberately structural, no minimum length: short CJK feedback like
|
|
60
|
+
"太挤了" is substantive; semantic junk (ADR-0008's "安师大" case) is
|
|
61
|
+
ui-evaluator's job (G6), not the floor's.
|
|
62
|
+
Returns (floor_pass, floor_failure_reason).
|
|
63
|
+
"""
|
|
64
|
+
feedback = (feedback or "").strip()
|
|
65
|
+
trigger = bool(feedback) or bool(anchors)
|
|
66
|
+
if not trigger:
|
|
67
|
+
return False, "confirm with no substantive feedback: empty feedback and no anchor"
|
|
68
|
+
if anchors:
|
|
69
|
+
for a in anchors:
|
|
70
|
+
if not isinstance(a, dict):
|
|
71
|
+
return False, "anchor is not an object"
|
|
72
|
+
sel = str(a.get("selector") or "").strip()
|
|
73
|
+
note = str(a.get("comment") or "").strip()
|
|
74
|
+
if not sel or not note:
|
|
75
|
+
return False, (
|
|
76
|
+
"anchor missing non-empty selector and comment: "
|
|
77
|
+
f"selector={sel!r} comment={note!r}")
|
|
78
|
+
return True, ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _self_check_floor() -> None:
|
|
82
|
+
"""ADR-0008 floor branch logic self-check (ponytail: one runnable check)."""
|
|
83
|
+
cases = [
|
|
84
|
+
("empty + no anchors", "", [], False),
|
|
85
|
+
("whitespace-only feedback", " \n ", [], False),
|
|
86
|
+
("short feedback passes (structural floor)", "ok", [], True),
|
|
87
|
+
("short CJK feedback passes ('太挤了' is substantive)", "太挤了", [], True),
|
|
88
|
+
("'安师大' passes floor; semantic junk is G6's job (ADR-0008)", "安师大", [], True),
|
|
89
|
+
("longer feedback passes", "fix it", [], True),
|
|
90
|
+
("anchor with comment", "", [{"selector": "h2", "comment": "x"}], True),
|
|
91
|
+
("anchor no comment (0015 garbage)", "",
|
|
92
|
+
[{"selector": "h2", "comment": ""}], False),
|
|
93
|
+
("anchor empty selector", "",
|
|
94
|
+
[{"selector": "", "comment": "x"}], False),
|
|
95
|
+
("non-dict anchor", "", ["not-a-dict"], False),
|
|
96
|
+
("feedback + incomplete anchor still fails", "ok",
|
|
97
|
+
[{"selector": "h2", "comment": ""}], False),
|
|
98
|
+
("two anchors one incomplete fails", "",
|
|
99
|
+
[{"selector": "h2", "comment": "x"}, {"selector": "p", "comment": ""}], False),
|
|
100
|
+
("two anchors both complete passes", "",
|
|
101
|
+
[{"selector": "h2", "comment": "x"}, {"selector": "p", "comment": "y"}], True),
|
|
102
|
+
("short feedback + good anchor passes", "hi",
|
|
103
|
+
[{"selector": "h2", "comment": "x"}], True),
|
|
104
|
+
]
|
|
105
|
+
for label, fb, anc, want in cases:
|
|
106
|
+
got, _ = _check_feedback_floor(fb, anc)
|
|
107
|
+
assert got == want, f"{label}: want {want}, got {got}"
|
|
108
|
+
print("FLOOR SELF-CHECK PASSED")
|
|
109
|
+
|
|
110
|
+
|
|
34
111
|
class PreviewTransactionError(ValueError):
|
|
35
112
|
"""Recoverable transaction failure with actionable artifact context."""
|
|
36
113
|
|
package/mcp/preview/util.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
"""Shared leaf helpers for the preview adapter (logging, timestamps).
|
|
1
|
+
"""Shared leaf helpers for the preview adapter (logging, timestamps, digest).
|
|
2
2
|
|
|
3
|
-
Sibling to i18n.py; imported by server.py, browser.py,
|
|
3
|
+
Sibling to i18n.py; imported by server.py, browser.py, transaction.py.
|
|
4
4
|
No third-party deps.
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import hashlib
|
|
8
9
|
import sys
|
|
9
10
|
from datetime import datetime, timezone
|
|
10
11
|
|
|
@@ -13,6 +14,21 @@ def _log(msg: str) -> None:
|
|
|
13
14
|
print(msg, file=sys.stderr, flush=True)
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
def prototype_html_digest(raw: bytes) -> str:
|
|
18
|
+
"""SHA-256 of prototype bytes with newlines normalized to LF.
|
|
19
|
+
|
|
20
|
+
Windows ``core.autocrlf`` rewrites working-tree bytes on checkout; a raw
|
|
21
|
+
digest then disagrees between the machine that wrote the confirm record
|
|
22
|
+
and a Linux CI runner validating the same git blob. Line-ending noise is
|
|
23
|
+
not a prototype content change for G5 integrity (issue 02 / T01).
|
|
24
|
+
|
|
25
|
+
Must stay in lockstep with ``scripts/_preview_integrity.prototype_html_digest``.
|
|
26
|
+
"""
|
|
27
|
+
return hashlib.sha256(
|
|
28
|
+
raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
29
|
+
).hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
16
32
|
|
|
17
33
|
def _now_iso() -> str:
|
|
18
34
|
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "design-playbook",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Design I/O for coding agents: controllable UI generation via declarations (spec/domain/craft/design/components/template) and contracts (skill/evaluator). Use for product UI—console, dashboard, agent-ops, CJK-first apps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -159,7 +159,7 @@ After craft, probe MCP `tools/list` for **`execute_capture_plan`**.
|
|
|
159
159
|
- **Absent** → skip; `ui-evaluator` ledger `observed` stays free-text (current behavior). G6 not triggered.
|
|
160
160
|
- **Present** → for each L6 criterion whose proof is a runtime state, run the evidence loop in this orchestrator (not inside any skill):
|
|
161
161
|
1. **Derive** a capture plan from L6 `Given -> When -> Then` (in memory, not on disk): `Given`/`When` → `state` + `actions`; `Then` → required proof (already in the ledger `required` field). Do not add or remove verification intent; L6 wins on conflict.
|
|
162
|
-
2. **Execute**: call `execute_capture_plan({url, type, state, actions, artifact_path})`. The provider returns `{artifact, observed_state, result, error, written_path}` and never sees the criterion. Prefer `written_path` (absolute) when locating the file; if it points outside `.scratch/<run>/`, fix `DESIGN_PLAYBOOK_RUN_ROOT` / cwd before binding. `artifact_path` must start with `evidence/` (e.g., `evidence/empty-state.png`, not `empty-state.png`) — the provider resolves it under `<run_root>/evidence/` and refuses absolute paths, `..` segments, or anything that escapes that subtree (`mcp/evidence/server.py` `_resolve_artifact_path`); a bare filename is rejected because it would land outside the evidence subtree.
|
|
162
|
+
2. **Execute**: call `execute_capture_plan({url, type, state, actions, artifact_path})`. The provider returns `{artifact, observed_state, result, error, written_path}` and never sees the criterion. Prefer `written_path` (absolute) when locating the file; if it points outside `.scratch/<run>/`, fix `DESIGN_PLAYBOOK_RUN_ROOT` / cwd before binding. `artifact_path` must start with `evidence/` (e.g., `evidence/empty-state.png`, not `empty-state.png`) — the provider resolves it under `<run_root>/evidence/` and refuses absolute paths, `..` segments, or anything that escapes that subtree (`mcp/evidence/server.py` `_resolve_artifact_path`); a bare filename is rejected because it would land outside the evidence subtree. **Async-init timing**: when the page has an async init (skeleton/loading before `body[data-state]` reaches the target state), include a `wait_for_state` action for that state before the capture action. A capture that lands mid-init records the loading state honestly (`observed_state: loading`), which proves the wrong criterion (dogfood 2026-08-01 settings run).
|
|
163
163
|
3. **Bind** (orchestrator owns the manifest; provider never writes it). After **each** successful or failed capture, **immediately append** one line to `.scratch/<run>/evidence/manifest.jsonl` — do not batch-rewrite the file at the end. Rules:
|
|
164
164
|
- **`observed_state` / `result` / `error`**: copy the provider return **verbatim**. If the provider returns `unknown`, write `unknown` — never overwrite with the requested `state` (request intent lives only under `capture.state`).
|
|
165
165
|
- **Embedded capture snapshot**: store the full call parameters used (`url` including query string, `type`, `state`, `actions`, `artifact_path`). Omit nothing that would be needed to re-run the capture.
|
package/mcp/preview/confirm.py
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
"""ADR-0008 floor logic, G5 trust token, and prototype target resolution.
|
|
2
|
-
|
|
3
|
-
Sibling module split from server.py; behavior unchanged. Holds the
|
|
4
|
-
feedback-floor check, the G5 one-time decision token + first-decision-wins
|
|
5
|
-
session, the prototype html digest primitive, and the ``--self-check``
|
|
6
|
-
floor cases.
|
|
7
|
-
"""
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import hashlib
|
|
11
|
-
import secrets
|
|
12
|
-
import sys
|
|
13
|
-
import threading
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from typing import Any
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def prototype_html_digest(raw: bytes) -> str:
|
|
19
|
-
"""SHA-256 of prototype bytes with newlines normalized to LF.
|
|
20
|
-
|
|
21
|
-
Windows ``core.autocrlf`` rewrites working-tree bytes on checkout; a raw
|
|
22
|
-
digest then disagrees between the machine that wrote the confirm record
|
|
23
|
-
and a Linux CI runner validating the same git blob. Line-ending noise is
|
|
24
|
-
not a prototype content change for G5 integrity (issue 02 / T01).
|
|
25
|
-
|
|
26
|
-
Must stay in lockstep with ``scripts/_preview_integrity.prototype_html_digest``.
|
|
27
|
-
"""
|
|
28
|
-
return hashlib.sha256(
|
|
29
|
-
raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
30
|
-
).hexdigest()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _preview_dir_for(path: Path | None) -> Path:
|
|
34
|
-
if path is not None:
|
|
35
|
-
return path.parent
|
|
36
|
-
scratch = Path.cwd() / ".scratch" / "preview-adapter" / "preview"
|
|
37
|
-
scratch.mkdir(parents=True, exist_ok=True)
|
|
38
|
-
return scratch
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _ensure_prototype(path_arg: str | None, html: str | None, round_n: int,
|
|
43
|
-
preview_dir: Path) -> Path:
|
|
44
|
-
if path_arg:
|
|
45
|
-
p = Path(path_arg)
|
|
46
|
-
if not p.is_file():
|
|
47
|
-
raise ValueError(f"prototype path does not exist: {path_arg}")
|
|
48
|
-
return p
|
|
49
|
-
if not html:
|
|
50
|
-
raise ValueError("path or html is required")
|
|
51
|
-
preview_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
-
target = preview_dir / f"round-{round_n}.html"
|
|
53
|
-
target.write_text(html, encoding="utf-8")
|
|
54
|
-
return target
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _check_feedback_floor(feedback: str,
|
|
58
|
-
anchors: list[dict[str, Any]]) -> tuple[bool, str]:
|
|
59
|
-
"""ADR-0008 preview feedback floor (structural, machine-checkable).
|
|
60
|
-
|
|
61
|
-
Passes when:
|
|
62
|
-
- (non-empty feedback OR >=1 anchor present) as trigger, AND
|
|
63
|
-
- every present anchor (if any) has non-empty selector AND non-empty comment.
|
|
64
|
-
|
|
65
|
-
Deliberately structural, no minimum length: short CJK feedback like
|
|
66
|
-
"太挤了" is substantive; semantic junk (ADR-0008's "安师大" case) is
|
|
67
|
-
ui-evaluator's job (G6), not the floor's.
|
|
68
|
-
Returns (floor_pass, floor_failure_reason).
|
|
69
|
-
"""
|
|
70
|
-
feedback = (feedback or "").strip()
|
|
71
|
-
trigger = bool(feedback) or bool(anchors)
|
|
72
|
-
if not trigger:
|
|
73
|
-
return False, "confirm with no substantive feedback: empty feedback and no anchor"
|
|
74
|
-
if anchors:
|
|
75
|
-
for a in anchors:
|
|
76
|
-
if not isinstance(a, dict):
|
|
77
|
-
return False, "anchor is not an object"
|
|
78
|
-
sel = str(a.get("selector") or "").strip()
|
|
79
|
-
note = str(a.get("comment") or "").strip()
|
|
80
|
-
if not sel or not note:
|
|
81
|
-
return False, (
|
|
82
|
-
"anchor missing non-empty selector and comment: "
|
|
83
|
-
f"selector={sel!r} comment={note!r}")
|
|
84
|
-
return True, ""
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def _generate_decision_token() -> str:
|
|
89
|
-
"""One-time URL-safe decision token (G5 trust boundary).
|
|
90
|
-
|
|
91
|
-
Proves a POST to /decide originated from the trusted parent control bar
|
|
92
|
-
(which renders the hidden field) rather than from prototype scripts running
|
|
93
|
-
inside the sandboxed iframe, which cannot read the parent DOM. Bound to a
|
|
94
|
-
single preview round via :class:`_DecisionSession`.
|
|
95
|
-
"""
|
|
96
|
-
return secrets.token_urlsafe(32)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
class _DecisionSession:
|
|
100
|
-
"""First-decision-wins token lock for a single preview round (G5).
|
|
101
|
-
|
|
102
|
-
``validate`` returns ``True`` only for the first POST whose token matches
|
|
103
|
-
(constant-time) AND whose round matches. Every other POST — missing token,
|
|
104
|
-
reused token, mismatched round, or wrong token — is rejected so the caller
|
|
105
|
-
can fail the decision closed. The session grants at most one valid decision.
|
|
106
|
-
"""
|
|
107
|
-
|
|
108
|
-
def __init__(self, round_n: int, token: str) -> None:
|
|
109
|
-
self.round_n = round_n
|
|
110
|
-
self._token = token
|
|
111
|
-
self._locked = False
|
|
112
|
-
self._lock = threading.Lock()
|
|
113
|
-
self.last_rejection: str = ""
|
|
114
|
-
|
|
115
|
-
@property
|
|
116
|
-
def locked(self) -> bool:
|
|
117
|
-
return self._locked
|
|
118
|
-
|
|
119
|
-
def validate(self, posted_round: int, posted_token: str | None) -> bool:
|
|
120
|
-
# LOW-1 (secure-ship-0.4.4): the check-then-set on ``_locked`` must
|
|
121
|
-
# be atomic. ThreadingHTTPServer handles each POST on its own
|
|
122
|
-
# thread, so two concurrent valid-token POSTs could both pass the
|
|
123
|
-
# ``if self._locked`` check and each consume the session. Hold the
|
|
124
|
-
# lock for the whole decision so first-decision-wins holds under
|
|
125
|
-
# real concurrency; the lock is uncontended in the single-POST
|
|
126
|
-
# happy path, so the cost is a no-op acquire/release.
|
|
127
|
-
with self._lock:
|
|
128
|
-
if not posted_token:
|
|
129
|
-
self.last_rejection = "missing"
|
|
130
|
-
return False
|
|
131
|
-
if posted_round != self.round_n:
|
|
132
|
-
self.last_rejection = "round_mismatch"
|
|
133
|
-
return False
|
|
134
|
-
if self._locked:
|
|
135
|
-
# First valid decision already consumed the session; every
|
|
136
|
-
# later POST (even with the correct token) is a replay.
|
|
137
|
-
self.last_rejection = "reuse"
|
|
138
|
-
return False
|
|
139
|
-
if not secrets.compare_digest(posted_token, self._token):
|
|
140
|
-
self.last_rejection = "invalid_token"
|
|
141
|
-
return False
|
|
142
|
-
self._locked = True
|
|
143
|
-
self.last_rejection = ""
|
|
144
|
-
return True
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def _self_check_floor() -> None:
|
|
148
|
-
"""ADR-0008 floor branch logic self-check (ponytail: one runnable check)."""
|
|
149
|
-
cases = [
|
|
150
|
-
("empty + no anchors", "", [], False),
|
|
151
|
-
("whitespace-only feedback", " \n ", [], False),
|
|
152
|
-
("short feedback passes (structural floor)", "ok", [], True),
|
|
153
|
-
("short CJK feedback passes ('太挤了' is substantive)", "太挤了", [], True),
|
|
154
|
-
("'安师大' passes floor; semantic junk is G6's job (ADR-0008)", "安师大", [], True),
|
|
155
|
-
("longer feedback passes", "fix it", [], True),
|
|
156
|
-
("anchor with comment", "", [{"selector": "h2", "comment": "x"}], True),
|
|
157
|
-
("anchor no comment (0015 garbage)", "",
|
|
158
|
-
[{"selector": "h2", "comment": ""}], False),
|
|
159
|
-
("anchor empty selector", "",
|
|
160
|
-
[{"selector": "", "comment": "x"}], False),
|
|
161
|
-
("non-dict anchor", "", ["not-a-dict"], False),
|
|
162
|
-
("feedback + incomplete anchor still fails", "ok",
|
|
163
|
-
[{"selector": "h2", "comment": ""}], False),
|
|
164
|
-
("two anchors one incomplete fails", "",
|
|
165
|
-
[{"selector": "h2", "comment": "x"}, {"selector": "p", "comment": ""}], False),
|
|
166
|
-
("two anchors both complete passes", "",
|
|
167
|
-
[{"selector": "h2", "comment": "x"}, {"selector": "p", "comment": "y"}], True),
|
|
168
|
-
("short feedback + good anchor passes", "hi",
|
|
169
|
-
[{"selector": "h2", "comment": "x"}], True),
|
|
170
|
-
]
|
|
171
|
-
for label, fb, anc, want in cases:
|
|
172
|
-
got, _ = _check_feedback_floor(fb, anc)
|
|
173
|
-
assert got == want, f"{label}: want {want}, got {got}"
|
|
174
|
-
print("FLOOR SELF-CHECK PASSED")
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if __name__ == "__main__":
|
|
178
|
-
if len(sys.argv) > 1 and sys.argv[1] == "--self-check":
|
|
179
|
-
_self_check_floor()
|
|
180
|
-
else:
|
|
181
|
-
raise SystemExit(
|
|
182
|
-
"usage: confirm.py --self-check (the MCP server entry is server.py)")
|
|
183
|
-
|