design-playbook 0.20.2 → 0.21.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 (104) hide show
  1. package/codex/AGENTS.md +4 -2
  2. package/commands/doctor.md +2 -1
  3. package/commands/ux-spec.md +1 -1
  4. package/examples/reference-intake/product-analogy/manifest.json +3 -0
  5. package/examples/reference-intake/screenshot/manifest.json +4 -0
  6. package/examples/reference-intake/url/manifest.json +3 -0
  7. package/mcp/evidence/capture_runtime.py +223 -21
  8. package/mcp/evidence/containment.py +65 -24
  9. package/mcp/evidence/disclosure.py +285 -0
  10. package/mcp/evidence/handoff.py +838 -0
  11. package/mcp/evidence/static_handoff_page.html +114 -0
  12. package/mcp/evidence/test_containment.py +217 -65
  13. package/mcp/evidence/test_delivery_matrix.py +159 -0
  14. package/mcp/evidence/test_disclosure.py +309 -0
  15. package/mcp/evidence/test_handoff.py +512 -0
  16. package/mcp/evidence/test_ledger_syntax.py +0 -1
  17. package/mcp/preview/compatibility.py +7 -3
  18. package/mcp/preview/control.css +602 -534
  19. package/mcp/preview/control.html +222 -65
  20. package/mcp/preview/control.js +639 -820
  21. package/mcp/preview/control.py +137 -47
  22. package/mcp/preview/control.review.js +515 -0
  23. package/mcp/preview/i18n.py +142 -8
  24. package/mcp/preview/integrity.py +17 -2
  25. package/mcp/preview/owned_browser.py +278 -0
  26. package/mcp/preview/pin_bridge.py +450 -0
  27. package/mcp/preview/review_session.py +120 -423
  28. package/mcp/preview/test_browser_control.py +185 -70
  29. package/mcp/preview/test_i18n_labels.py +71 -0
  30. package/mcp/preview/test_transaction.py +49 -12
  31. package/mcp/preview/transaction.py +25 -10
  32. package/mcp/preview/versions.py +3 -2
  33. package/mcp/run_console/__init__.py +17 -0
  34. package/mcp/run_console/actions.py +149 -0
  35. package/mcp/run_console/app.css +1230 -0
  36. package/mcp/run_console/app.html +154 -0
  37. package/mcp/run_console/app.js +1720 -0
  38. package/mcp/run_console/contract.py +430 -0
  39. package/mcp/run_console/fixtures/evidence-artifact.html +11 -0
  40. package/mcp/run_console/fixtures/plan-profile.md +11 -0
  41. package/mcp/run_console/fixtures/point-back-pass-closed.md +43 -0
  42. package/mcp/run_console/fixtures/point-back-recirculate.md +49 -0
  43. package/mcp/run_console/fixtures/point-back-repeated-verdict.md +88 -0
  44. package/mcp/run_console/fixtures/point-back-unaudited.md +56 -0
  45. package/mcp/run_console/fixtures/spec-script-summary.md +48 -0
  46. package/mcp/run_console/http_server.py +633 -0
  47. package/mcp/run_console/projection.py +155 -0
  48. package/mcp/run_console/request_security.py +191 -0
  49. package/mcp/run_console/session.py +247 -0
  50. package/mcp/run_console/snapshot_builder.py +1181 -0
  51. package/mcp/run_console/snapshot_v1.schema.json +655 -0
  52. package/mcp/run_console/source_registry.py +576 -0
  53. package/mcp/run_console/test_actions.py +680 -0
  54. package/mcp/run_console/test_contract.py +640 -0
  55. package/mcp/run_console/test_diagnostic_export_gate.py +1540 -0
  56. package/mcp/run_console/test_http_server.py +989 -0
  57. package/mcp/run_console/test_parity.py +968 -0
  58. package/mcp/run_console/test_read_only_trial.py +581 -0
  59. package/mcp/run_console/test_request_security.py +325 -0
  60. package/mcp/run_console/test_role_attestation_gate.py +945 -0
  61. package/mcp/run_console/test_session.py +336 -0
  62. package/mcp/run_console/test_snapshot_builder.py +730 -0
  63. package/mcp/run_console/test_source_registry.py +950 -0
  64. package/mcp/run_console/test_ui.py +444 -0
  65. package/mcp/run_console/test_ui_actions.py +295 -0
  66. package/mcp/run_console/test_ui_browser.py +942 -0
  67. package/mcp/run_console/ui.py +82 -0
  68. package/mcp/util.py +18 -0
  69. package/package.json +1 -1
  70. package/scripts/audit_preferences.py +600 -0
  71. package/scripts/contract_v1.py +110 -1
  72. package/scripts/dd_entries.py +3 -3
  73. package/scripts/doctor.py +55 -2
  74. package/scripts/escalation_signals.py +5 -5
  75. package/scripts/finding_syntax.py +105 -0
  76. package/scripts/g10_design_decisions.py +3 -3
  77. package/scripts/g11_coverage.py +1 -1
  78. package/scripts/g12_tier_boundary.py +10 -13
  79. package/scripts/g1_spec.py +154 -8
  80. package/scripts/g2_g4_pointback.py +44 -84
  81. package/scripts/g5_preview.py +5 -4
  82. package/scripts/g7_contract_drift.py +25 -13
  83. package/scripts/interaction_dimensions.py +2 -2
  84. package/scripts/learning_candidates.py +7 -14
  85. package/scripts/pointback_projection.py +276 -0
  86. package/scripts/repair_rounds.py +8 -10
  87. package/scripts/run_console.py +79 -0
  88. package/scripts/run_facts.py +151 -12
  89. package/scripts/run_metadata.py +189 -0
  90. package/scripts/run_profile.py +197 -1
  91. package/scripts/run_status.py +41 -321
  92. package/scripts/stages.py +7 -5
  93. package/scripts/status_projection.py +576 -0
  94. package/scripts/validate_run.py +103 -22
  95. package/skills/design-baseline/SKILL.md +1 -3
  96. package/skills/design-baseline/scripts/design_baseline.py +1 -0
  97. package/skills/design-playbook/SKILL.md +99 -21
  98. package/skills/design-playbook/references/first-run.md +2 -2
  99. package/skills/design-playbook/references/preview-ops.md +5 -4
  100. package/skills/reference-intake/SKILL.md +19 -2
  101. package/skills/reference-intake/references/contract-template.md +13 -2
  102. package/skills/reference-intake/scripts/reference_sources.py +289 -0
  103. package/skills/ui-picker/SKILL.md +23 -2
  104. package/mcp/preview/util.py +0 -15
package/codex/AGENTS.md CHANGED
@@ -71,12 +71,14 @@ adapter spawns it via `--app=`); `observe*` needs Playwright + Chromium.
71
71
 
72
72
  Native desktop order: `ux-spec` → `native-craft` → `ui-picker` → `fill` → `craft-guard` → `ui-evaluator`.
73
73
 
74
- Conditional entry `reference-intake?` (screenshot/URL/design/product analogy, ADR-0011) runs **before** `ux-spec?` when reference materials are present — fixed orchestrator order, not reorderable. Run `native-craft` only for an explicit native-desktop/native-feel target. Web and mobile Web skip `native-craft`; if the platform is unclear, ask before choosing the order. The orchestrator owns the decision gate, render-surface seam handoff, and fail-closed behavior.
74
+ Conditional entry `design-baseline?` (ADR-0012) runs before `reference-intake?` when the router returns `requires_baseline`. Existing-product Fill requires a valid existing baseline, an accepted generated baseline, or an explicit user waiver.
75
75
 
76
- Conditional entry `design-baseline?` (ADR-0012) runs before `reference-intake?` for UI builds/fixes in repositories with meaningful existing first-party UI. Existing-product Fill requires a valid existing baseline, an accepted generated baseline, or an explicit user waiver.
76
+ Conditional entry `reference-intake?` (screenshot/URL/design/product analogy, ADR-0011) runs **before** `ux-spec?` when the router returns `requires_reference_contract` fixed orchestrator order, not reorderable. Run `native-craft` only for an explicit native-desktop/native-feel target. Web and mobile Web skip `native-craft`; if the platform is unclear, ask before choosing the order. The orchestrator owns the decision gate, render-surface seam handoff, and fail-closed behavior.
77
77
 
78
78
  Mirror the orchestrator's skip narration (SKILL.md Steps preamble): when a step is skipped, output one line — step name + reason + how to enable, with the gate label when one applies, e.g. `-> preview*: adapter absent, skipped (G5 not triggered; enable via packages/design-playbook/mcp/preview/ or host MCP)`.
79
79
 
80
+ Audit preferences (ADR-0033) apply identically on Codex. Follow `skills/design-playbook/SKILL.md` § *Audit preferences* as sole authority; this bridge adds no host-specific preference rules.
81
+
80
82
  ## Compose
81
83
 
82
84
  - Style DB → ui-ux-pro-max
@@ -10,6 +10,7 @@ One packaged diagnosis entry for install/runtime capability.
10
10
  python <plugin>/scripts/doctor.py
11
11
  python <plugin>/scripts/doctor.py --json
12
12
  python <plugin>/scripts/doctor.py --run-root .scratch/<run>
13
+ python <plugin>/scripts/doctor.py --repo-root <target-repo>
13
14
  ```
14
15
 
15
- Reports `ok` / `degraded` / `broken`. Failed checks include a concrete repair. Optional adapters (Playwright, run-root env) degrade rather than hard-fail the install.
16
+ Reports `ok` / `degraded` / `broken`. Failed checks include a concrete repair. Optional adapters (Playwright, run-root env) degrade rather than hard-fail the install. Audit-preference state shows effective stage values, sources, asked status, and corrupt layers for target repository.
@@ -2,7 +2,7 @@
2
2
  description: Six-layer spec.md via the S0-S6 shaping session (question/assumption/confirmation batches + session artifacts); stop before UI shell/code
3
3
  ---
4
4
 
5
- Run skill **ux-spec** only. Emit complete `spec.md`. Do not pick templates or write UI.
5
+ Run the ux-spec protocol only. A same-named command in this plugin shadows the `ux-spec` skill in the Skill tool registry, so the Skill tool injects this text instead of the protocol. Locate this plugin's install root (the `--plugin-dir` path, or under `~/.claude/plugins/` for marketplace installs), read `skills/ux-spec/SKILL.md`, and follow its S0-S6 protocol. Emit complete `spec.md`. Do not pick templates or write UI.
6
6
 
7
7
  Request:
8
8
  $ARGUMENTS
@@ -9,6 +9,9 @@
9
9
  "kind": "product_analogy",
10
10
  "locator": "Linear (product analogy; no asset)",
11
11
  "sha256": null,
12
+ "storage": "symbolic",
13
+ "acquired_via": "analogy",
14
+ "captured_at": "2026-07-22T10:10:00+08:00",
12
15
  "note": "taste anchor only"
13
16
  }
14
17
  ]
@@ -9,6 +9,10 @@
9
9
  "kind": "screenshot",
10
10
  "locator": "assets/ops-alert-list.png",
11
11
  "sha256": "0000000000000000000000000000000000000000000000000000000000000000",
12
+ "media_type": "image/png",
13
+ "storage": "copied",
14
+ "acquired_via": "local-file",
15
+ "captured_at": "2026-07-22T10:00:00+08:00",
12
16
  "note": "fixture placeholder hash; real runs must hash the file"
13
17
  }
14
18
  ]
@@ -9,6 +9,9 @@
9
9
  "kind": "url",
10
10
  "locator": "https://example.com/docs/settings/notifications",
11
11
  "sha256": null,
12
+ "storage": "remote",
13
+ "acquired_via": "url",
14
+ "captured_at": "2026-07-22T10:05:00+08:00",
12
15
  "note": "example.com placeholder for fixture shape only"
13
16
  }
14
17
  ]
@@ -5,17 +5,24 @@ Never writes manifest.jsonl; never accepts criterion refs (orchestrator binds).
5
5
  Returns relative ``artifact`` plus absolute ``written_path`` so RUN_ROOT/cwd
6
6
  misconfig is visible to the orchestrator.
7
7
  """
8
+
8
9
  from __future__ import annotations
9
10
 
10
11
  import json
11
12
  import os
12
- import sys
13
13
  from pathlib import Path
14
14
  from typing import Any, Protocol
15
15
 
16
16
  from design_playbook.mcp.evidence import containment
17
17
  from design_playbook.mcp.evidence.capture_contract import parse_capture_contract
18
- from design_playbook.mcp.preview.util import _log
18
+ from design_playbook.mcp.evidence.disclosure import (
19
+ LAYOUT_PROBE_JS,
20
+ VIEWPORTS,
21
+ ViewportMetrics,
22
+ metric_payload,
23
+ probe_layout,
24
+ )
25
+ from design_playbook.mcp.util import log as _log
19
26
 
20
27
  CAPTURE_TYPES = frozenset({"screenshot", "a11y tree", "interaction trace"})
21
28
  ALLOWED_ARGUMENTS = frozenset(
@@ -50,6 +57,22 @@ class BrowserAdapter(Protocol):
50
57
  """Write one artifact and return the observed page state."""
51
58
 
52
59
 
60
+ class ProbeBrowserAdapter(Protocol):
61
+ """Optional seam for capturing and probing one browser page."""
62
+
63
+ def capture_and_probe(
64
+ self,
65
+ *,
66
+ url: str,
67
+ capture_type: str,
68
+ actions: list[dict[str, Any]],
69
+ out_path: Path,
70
+ viewport: dict[str, Any],
71
+ freeze: dict[str, Any],
72
+ ) -> dict[str, Any]:
73
+ """Capture an artifact and evaluate the layout probe before teardown."""
74
+
75
+
53
76
  def _failed(
54
77
  artifact: str,
55
78
  error: str,
@@ -188,16 +211,11 @@ def _reason_message(reason: str) -> str:
188
211
  # payload stays compatible; resolution_failure is the one new surface (the
189
212
  # old inline resolver propagated OSError uncaught).
190
213
  _REASON_MESSAGES = {
191
- containment.REASON_ABSOLUTE_PATH:
192
- "artifact_path must be relative to the configured run root",
193
- containment.REASON_DOTDOT_SEGMENT:
194
- "artifact_path must not contain '..' segments",
195
- containment.REASON_RESOLUTION_FAILURE:
196
- "artifact_path could not be resolved under the evidence/ subtree",
197
- containment.REASON_CANONICAL_ESCAPE:
198
- "artifact_path must stay under the evidence/ subtree",
199
- containment.REASON_SYMLINK_ESCAPE:
200
- "artifact_path symlink escapes the evidence/ subtree",
214
+ containment.REASON_ABSOLUTE_PATH: "artifact_path must be relative to the configured run root",
215
+ containment.REASON_DOTDOT_SEGMENT: "artifact_path must not contain '..' segments",
216
+ containment.REASON_RESOLUTION_FAILURE: "artifact_path could not be resolved under the evidence/ subtree",
217
+ containment.REASON_CANONICAL_ESCAPE: "artifact_path must stay under the evidence/ subtree",
218
+ containment.REASON_SYMLINK_ESCAPE: "artifact_path symlink escapes the evidence/ subtree",
201
219
  }
202
220
 
203
221
 
@@ -276,8 +294,7 @@ def _action_select_option(page: Any, action: dict, index: int, do: str) -> None:
276
294
  value = action.get("value")
277
295
  label = action.get("label")
278
296
  if value is None and label is None:
279
- raise ValueError(
280
- f"actions[{index}].value or label required for select_option")
297
+ raise ValueError(f"actions[{index}].value or label required for select_option")
281
298
  if value is not None:
282
299
  page.select_option(selector, value=value, timeout=10_000)
283
300
  else:
@@ -339,6 +356,7 @@ def _read_observed_state(page: Any) -> str:
339
356
  _log(f"observed_state probe failed: {exc}")
340
357
  return "unknown"
341
358
 
359
+
342
360
  def _write_screenshot(page: Any, path: Path) -> None:
343
361
  path.parent.mkdir(parents=True, exist_ok=True)
344
362
  page.screenshot(path=str(path), full_page=True)
@@ -389,7 +407,7 @@ class PlaywrightBrowserAdapter:
389
407
 
390
408
  self._sync_playwright = sync_playwright
391
409
 
392
- def capture(
410
+ def _capture_page(
393
411
  self,
394
412
  *,
395
413
  url: str,
@@ -398,7 +416,15 @@ class PlaywrightBrowserAdapter:
398
416
  out_path: Path,
399
417
  viewport: dict[str, Any],
400
418
  freeze: dict[str, Any],
401
- ) -> str:
419
+ probe: bool,
420
+ ) -> tuple[str, ViewportMetrics | None]:
421
+ """Single Playwright capture path shared by the two adapter seams.
422
+
423
+ ``probe=False`` stops after the observed state (the plain
424
+ :class:`BrowserAdapter` contract); ``probe=True`` additionally
425
+ evaluates the layout probe on the same page before teardown so the
426
+ screenshot and its metrics share one browser pass.
427
+ """
402
428
  with self._sync_playwright() as playwright:
403
429
  browser = playwright.chromium.launch(headless=True)
404
430
  try:
@@ -411,6 +437,8 @@ class PlaywrightBrowserAdapter:
411
437
  color_scheme=viewport["colorScheme"],
412
438
  )
413
439
  page = context.new_page()
440
+ if viewport.get("media"):
441
+ page.emulate_media(media=viewport["media"])
414
442
  wait_until = (
415
443
  "networkidle" if freeze.get("networkIdle") else "domcontentloaded"
416
444
  )
@@ -428,12 +456,62 @@ class PlaywrightBrowserAdapter:
428
456
  elif capture_type == "a11y tree":
429
457
  _write_a11y_tree(page, out_path)
430
458
 
431
- return _read_observed_state(page)
459
+ observed = _read_observed_state(page)
460
+ if not probe:
461
+ return observed, None
462
+ raw = page.evaluate(LAYOUT_PROBE_JS)
463
+ metrics = probe_layout(lambda _js: raw)
464
+ return observed, metrics
432
465
  finally:
433
466
  browser.close()
434
467
 
468
+ def capture(
469
+ self,
470
+ *,
471
+ url: str,
472
+ capture_type: str,
473
+ actions: list[dict[str, Any]],
474
+ out_path: Path,
475
+ viewport: dict[str, Any],
476
+ freeze: dict[str, Any],
477
+ ) -> str:
478
+ observed, _metrics = self._capture_page(
479
+ url=url,
480
+ capture_type=capture_type,
481
+ actions=actions,
482
+ out_path=out_path,
483
+ viewport=viewport,
484
+ freeze=freeze,
485
+ probe=False,
486
+ )
487
+ return observed
435
488
 
436
- def _validate_runtime_object(args: dict[str, Any]) -> tuple[str, str, str, list[dict[str, Any]]]:
489
+ def capture_and_probe(
490
+ self,
491
+ *,
492
+ url: str,
493
+ capture_type: str,
494
+ actions: list[dict[str, Any]],
495
+ out_path: Path,
496
+ viewport: dict[str, Any],
497
+ freeze: dict[str, Any],
498
+ ) -> dict[str, Any]:
499
+ """Capture and probe the same page before closing its browser."""
500
+ observed, metrics = self._capture_page(
501
+ url=url,
502
+ capture_type=capture_type,
503
+ actions=actions,
504
+ out_path=out_path,
505
+ viewport=viewport,
506
+ freeze=freeze,
507
+ probe=True,
508
+ )
509
+ return {"observed_state": observed, "metrics": metrics}
510
+
511
+
512
+ def _validate_runtime_object(
513
+ args: dict[str, Any],
514
+ ) -> tuple[str, str, str, list[dict[str, Any]]]:
437
515
  """Validate Runtime Object fields and return (url, cap_type, state, actions).
438
516
 
439
517
  Owns the field-level validation that execute_capture_plan previously
@@ -449,7 +527,7 @@ def _validate_runtime_object(args: dict[str, Any]) -> tuple[str, str, str, list[
449
527
  raise ValueError("url is required")
450
528
  if not isinstance(cap_type, str) or cap_type not in CAPTURE_TYPES:
451
529
  raise ValueError(
452
- f'type must be one of {sorted(CAPTURE_TYPES)}; got {cap_type!r}'
530
+ f"type must be one of {sorted(CAPTURE_TYPES)}; got {cap_type!r}"
453
531
  )
454
532
  if not isinstance(state, str) or not state.strip():
455
533
  raise ValueError("state is required")
@@ -509,8 +587,7 @@ def execute_capture_plan(
509
587
  if out_path.exists() and not overwrite:
510
588
  return _failed(
511
589
  rel,
512
- f"artifact already exists: {out_path} "
513
- "(pass overwrite=true to replace)",
590
+ f"artifact already exists: {out_path} (pass overwrite=true to replace)",
514
591
  abs_written,
515
592
  request=request,
516
593
  )
@@ -549,3 +626,128 @@ def execute_capture_plan(
549
626
  )
550
627
 
551
628
  return _captured(rel, observed, abs_written, request)
629
+
630
+
631
+ # --------------------------------------------------------------------------- #
632
+ # Stage 9 delivery matrix: five-viewport capture + fold/overflow metrics #
633
+ # --------------------------------------------------------------------------- #
634
+
635
+
636
+ def matrix_viewport(name: str) -> dict[str, Any]:
637
+ """Map a standard delivery viewport name to a capture-contract viewport.
638
+
639
+ ``sw``/``innerH`` become the contract ``width``/``height``; device pixels
640
+ stay at 1.0 with the Light color scheme so the matrix is reproducible.
641
+ The ``print`` viewport additionally carries ``media: "print"`` so the
642
+ adapter emulates print media (page breaks, print stylesheets) rather than
643
+ just resizing the window. Unknown names raise ValueError so a caller cannot
644
+ silently capture a non-standard viewport into the disclosure matrix.
645
+ """
646
+ ref = VIEWPORTS.get(name)
647
+ if ref is None:
648
+ raise ValueError(
649
+ f"unknown delivery viewport {name!r}; expected one of {sorted(VIEWPORTS)}"
650
+ )
651
+ viewport = {
652
+ "width": ref["sw"],
653
+ "height": ref["innerH"],
654
+ "devicePixelRatio": 1.0,
655
+ "colorScheme": "light",
656
+ }
657
+ if ref.get("kind") == "print":
658
+ viewport["media"] = "print"
659
+ return viewport
660
+
661
+
662
+ def capture_delivery_matrix(
663
+ *,
664
+ url: str,
665
+ out_dir: Path | None = None,
666
+ freeze: dict[str, Any] | None = None,
667
+ browser_adapter: BrowserAdapter | None = None,
668
+ ) -> dict[str, dict[str, Any]]:
669
+ """Drive the five standard delivery viewports and return their metrics.
670
+
671
+ For each of ``VIEWPORTS``, captures one full-page screenshot under
672
+ ``out_dir`` (``viewport-<name>.png``) and reads the layout probe. The
673
+ returned dict maps viewport name -> ``{metrics, screenshot}`` so a caller
674
+ can assemble the ``disclosure-review.json`` matrix and the ``/export-zip``
675
+ package from one pass.
676
+
677
+ ``out_dir`` defaults to the spec's canonical delivery path
678
+ ``output/playwright/static-handoff/`` (spec §5). ``browser_adapter`` uses
679
+ the same ``BrowserAdapter`` seam as ``execute_capture_plan`` (real
680
+ Playwright by default, injected fake in tests). Production adapters should
681
+ expose ``capture_and_probe`` so the snapshot and metrics share one page;
682
+ adapters without that seam remain explicitly ``unmeasured``.
683
+ """
684
+ freeze = freeze or {"enabled": True, "waitFonts": True, "networkIdle": False}
685
+ if browser_adapter is None:
686
+ browser_adapter = PlaywrightBrowserAdapter()
687
+
688
+ if out_dir is None:
689
+ out_dir = Path("output/playwright/static-handoff")
690
+ out_dir = Path(out_dir)
691
+ out_dir.mkdir(parents=True, exist_ok=True)
692
+ results: dict[str, dict[str, Any]] = {}
693
+ for name in VIEWPORTS:
694
+ viewport = matrix_viewport(name)
695
+ out_path = out_dir / f"viewport-{name}.png"
696
+ try:
697
+ capture_and_probe = getattr(browser_adapter, "capture_and_probe", None)
698
+ if callable(capture_and_probe):
699
+ captured = capture_and_probe(
700
+ url=url,
701
+ capture_type="screenshot",
702
+ actions=[],
703
+ out_path=out_path,
704
+ viewport=viewport,
705
+ freeze=freeze,
706
+ )
707
+ measured = captured.get("metrics") if isinstance(captured, dict) else None
708
+ if not isinstance(measured, ViewportMetrics):
709
+ raise TypeError("capture_and_probe returned no ViewportMetrics")
710
+ metrics = metric_payload(measured)
711
+ else:
712
+ browser_adapter.capture(
713
+ url=url,
714
+ capture_type="screenshot",
715
+ actions=[],
716
+ out_path=out_path,
717
+ viewport=viewport,
718
+ freeze=freeze,
719
+ )
720
+ metrics = _unmeasured_metric_payload(name)
721
+ results[name] = {"metrics": metrics, "screenshot": str(out_path)}
722
+ except Exception as exc: # noqa: BLE001 — preserve blocked evidence
723
+ _log(f"matrix capture/probe failed for {name}: {exc}")
724
+ results[name] = {
725
+ "metrics": metric_payload(
726
+ ViewportMetrics(
727
+ sw=VIEWPORTS[name]["sw"],
728
+ innerH=VIEWPORTS[name]["innerH"],
729
+ hOverflow=0,
730
+ inFold=False,
731
+ measurement_status="blocked",
732
+ measurement_error=str(exc),
733
+ )
734
+ ),
735
+ "screenshot": str(out_path),
736
+ "result": "blocked",
737
+ "error": str(exc),
738
+ }
739
+ return results
740
+
741
+
742
+ def _unmeasured_metric_payload(name: str) -> dict[str, Any]:
743
+ """Return reference dimensions without claiming a layout measurement."""
744
+ ref = VIEWPORTS[name]
745
+ return metric_payload(
746
+ ViewportMetrics(
747
+ sw=ref["sw"],
748
+ innerH=ref["innerH"],
749
+ hOverflow=0,
750
+ inFold=False,
751
+ measurement_status="unmeasured",
752
+ )
753
+ )
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Single Evidence artifact containment authority (ADR-0026).
2
+ """Single Evidence artifact containment authority (ADR-0026, ADR-0039).
3
3
 
4
4
  This is the one deep module that resolves an artifact path under
5
5
  ``<run_root>/evidence/`` and rejects every escape class at resolution time.
@@ -16,6 +16,13 @@ without re-checking containment. Collapsing both callers into one mode-driven
16
16
  helper was rejected because it would hide their different existence timing
17
17
  and error contracts (ADR-0026).
18
18
 
19
+ ADR-0039 extends the same invariant to arbitrary roots: ``read_under``
20
+ exposes the canonical resolution for one existing regular file under any
21
+ directory, and the Run Console's source reads (run root, package root)
22
+ consume it instead of mirroring the escape classes. The ``evidence/``
23
+ operations remain the ADR-0026 contract surface - same reason codes, same
24
+ existence timing - now expressed as specializations of the one resolver.
25
+
19
26
  Threat-model limit (ADR-0026, explicit): this module resolves and validates
20
27
  the path; it does NOT perform the write. Path resolution alone cannot close
21
28
  the TOCTOU gap - a concurrent untrusted filesystem actor that replaces a
@@ -73,22 +80,30 @@ class ContainmentResult:
73
80
  return self.reason == ""
74
81
 
75
82
 
76
- def _resolve(
77
- artifact_path: str,
78
- run_root: Path,
83
+ def _resolve_candidate(
84
+ candidate_root: Path,
85
+ relpath: str,
86
+ boundary_root: Path,
79
87
  *,
80
88
  require_existing_file: bool) -> ContainmentResult:
81
- """Canonical containment resolution shared by write_target and read_artifact.
89
+ """Canonical containment resolution shared by every operation here.
90
+
91
+ ``relpath`` joins ``candidate_root`` to form the candidate; the resolved
92
+ candidate (and its realpath) must stay under ``boundary_root``. The
93
+ evidence operations pass the run root as the candidate root and the
94
+ evidence subtree as the boundary (their paths are run-root-relative,
95
+ including the ``evidence/`` prefix); ``read_under`` passes the same root
96
+ for both.
82
97
 
83
98
  Rejects, in order: native/POSIX/Windows absolute paths; any ``..``
84
99
  segment (defence in depth before resolution); resolution failures
85
100
  (OSError during resolve); canonical escapes (resolved candidate leaves
86
- the evidence/ subtree); observed symlink escapes (realpath leaves the
87
- evidence/ subtree - Path.resolve and os.path.realpath can disagree on
101
+ the boundary root); observed symlink escapes (realpath leaves the
102
+ boundary root - Path.resolve and os.path.realpath can disagree on
88
103
  symlink chains across platforms). When ``require_existing_file`` is set,
89
104
  a candidate that is not an existing regular file is rejected last.
90
105
  """
91
- requested = Path(artifact_path)
106
+ requested = Path(relpath)
92
107
 
93
108
  # 1. Absolute path rejection: native, POSIX, and Windows forms. Checking
94
109
  # all three means a Windows drive path is rejected on POSIX and a POSIX
@@ -96,8 +111,8 @@ def _resolve(
96
111
  # Path flavour.
97
112
  if (
98
113
  requested.is_absolute()
99
- or PureWindowsPath(artifact_path).is_absolute()
100
- or PurePosixPath(artifact_path).is_absolute()
114
+ or PureWindowsPath(relpath).is_absolute()
115
+ or PurePosixPath(relpath).is_absolute()
101
116
  ):
102
117
  return ContainmentResult(None, REASON_ABSOLUTE_PATH)
103
118
 
@@ -106,36 +121,35 @@ def _resolve(
106
121
  if any(part == ".." for part in requested.parts):
107
122
  return ContainmentResult(None, REASON_DOTDOT_SEGMENT)
108
123
 
109
- # 3. Resolution. Both the evidence root and the candidate are resolved
124
+ # 3. Resolution. Both the boundary root and the candidate are resolved
110
125
  # here; an OSError (e.g. a pathological symlink chain on a platform
111
126
  # whose resolver raises) is caught and surfaced as a resolution
112
127
  # failure rather than propagated.
113
128
  try:
114
- evidence_root = (run_root / EVIDENCE_SUBDIR).resolve(strict=False)
115
- candidate = (run_root / requested).resolve(strict=False)
129
+ boundary = boundary_root.resolve(strict=False)
130
+ candidate = (candidate_root / requested).resolve(strict=False)
116
131
  except OSError:
117
132
  return ContainmentResult(None, REASON_RESOLUTION_FAILURE)
118
133
 
119
- # 4. Canonical escape: the resolved candidate must stay under the evidence
120
- # root. Catches ``spec.md`` and ``skills/x`` (siblings of evidence/).
134
+ # 4. Canonical escape: the resolved candidate must stay under the
135
+ # boundary root. For the evidence operations this catches ``spec.md``
136
+ # and ``skills/x`` (siblings of evidence/).
121
137
  try:
122
- candidate.relative_to(evidence_root)
138
+ candidate.relative_to(boundary)
123
139
  except ValueError:
124
140
  return ContainmentResult(None, REASON_CANONICAL_ESCAPE)
125
141
 
126
142
  # 5. Symlink escape (defence in depth): realpath must also stay under the
127
- # evidence root. Path.resolve and os.path.realpath can disagree on
128
- # symlink chains across platforms, so a symlink under evidence/ that
129
- # resolves outside must be rejected even when step 4 passed.
143
+ # boundary root. Path.resolve and os.path.realpath can disagree on
144
+ # symlink chains across platforms, so a symlink under the boundary
145
+ # that resolves outside must be rejected even when step 4 passed.
130
146
  try:
131
- Path(os.path.realpath(candidate)).relative_to(
132
- os.path.realpath(evidence_root)
133
- )
147
+ Path(os.path.realpath(candidate)).relative_to(os.path.realpath(boundary))
134
148
  except ValueError:
135
149
  return ContainmentResult(None, REASON_SYMLINK_ESCAPE)
136
150
 
137
- # 6. Read side: require an existing regular file. The write side permits a
138
- # nonexistent suffix and stops here (the Provider's manifest-refusal
151
+ # 6. Read side: require an existing regular file. The write side permits
152
+ # a nonexistent suffix and stops here (the Provider's manifest-refusal
139
153
  # and overwrite checks are separate policy, not containment).
140
154
  if require_existing_file and not candidate.is_file():
141
155
  return ContainmentResult(None, REASON_NOT_REGULAR_FILE)
@@ -143,6 +157,33 @@ def _resolve(
143
157
  return ContainmentResult(candidate, "")
144
158
 
145
159
 
160
+ def _resolve(
161
+ artifact_path: str,
162
+ run_root: Path,
163
+ *,
164
+ require_existing_file: bool) -> ContainmentResult:
165
+ """Resolve an evidence artifact path (run-root-relative) under evidence/."""
166
+ return _resolve_candidate(
167
+ run_root,
168
+ artifact_path,
169
+ run_root / EVIDENCE_SUBDIR,
170
+ require_existing_file=require_existing_file,
171
+ )
172
+
173
+
174
+ def read_under(root: Path, relpath: str) -> ContainmentResult:
175
+ """Resolve one existing regular file under an arbitrary root (ADR-0039).
176
+
177
+ The same escape classes as the evidence operations, for callers whose
178
+ authority root is not the evidence subtree (Run Console source reads
179
+ under the selected run root or the package root). ``relpath`` is
180
+ root-relative. Resolution-only, same TOCTOU limit as above.
181
+ """
182
+ return _resolve_candidate(
183
+ root, relpath, root, require_existing_file=True
184
+ )
185
+
186
+
146
187
  def write_target(artifact_path: str, run_root: Path) -> ContainmentResult:
147
188
  """Resolve a write target under ``<run_root>/evidence/``.
148
189