capsule-engine 0.1.0__py3-none-any.whl

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 (288) hide show
  1. capsule_engine/__init__.py +17 -0
  2. capsule_engine/bundle_viewer/__init__.py +21 -0
  3. capsule_engine/bundle_viewer/static/offline_shell.html +1509 -0
  4. capsule_engine/bundle_viewer/viewer.py +57 -0
  5. capsule_engine/cli/__init__.py +5 -0
  6. capsule_engine/cli/bundle_cmd.py +269 -0
  7. capsule_engine/cli/console_cmd.py +70 -0
  8. capsule_engine/cli/constraints_cmd.py +74 -0
  9. capsule_engine/cli/format.py +173 -0
  10. capsule_engine/cli/guard_cmds.py +361 -0
  11. capsule_engine/cli/init_cmds.py +169 -0
  12. capsule_engine/cli/main.py +120 -0
  13. capsule_engine/cli/packs_cmds.py +96 -0
  14. capsule_engine/cli/telemetry_cmd.py +80 -0
  15. capsule_engine/cli/tenant_cmds.py +199 -0
  16. capsule_engine/cli/verify_cmd.py +110 -0
  17. capsule_engine/compiler/__init__.py +13 -0
  18. capsule_engine/compiler/effect_model.py +79 -0
  19. capsule_engine/compiler/vocabulary.py +253 -0
  20. capsule_engine/console/__init__.py +10 -0
  21. capsule_engine/console/api.py +251 -0
  22. capsule_engine/console/components.css +406 -0
  23. capsule_engine/console/console.css +130 -0
  24. capsule_engine/console/console.html +51 -0
  25. capsule_engine/console/console.js +330 -0
  26. capsule_engine/console/gallery.html +274 -0
  27. capsule_engine/console/server.py +137 -0
  28. capsule_engine/console/tokens.css +68 -0
  29. capsule_engine/conversation/__init__.py +88 -0
  30. capsule_engine/conversation/capsules.py +268 -0
  31. capsule_engine/conversation/exchange.py +279 -0
  32. capsule_engine/conversation/merkle.py +80 -0
  33. capsule_engine/conversation/session.py +101 -0
  34. capsule_engine/envcompat.py +15 -0
  35. capsule_engine/events/__init__.py +14 -0
  36. capsule_engine/events/capsule.py +133 -0
  37. capsule_engine/examples/__init__.py +9 -0
  38. capsule_engine/examples/data/tau2_airline/LICENSE-tau2-bench +21 -0
  39. capsule_engine/examples/data/tau2_airline/PROVENANCE.json +70 -0
  40. capsule_engine/examples/data/tau2_airline/hand_labels.json +893 -0
  41. capsule_engine/examples/data/tau2_airline/pilot1_live_gemini-2-5-flash_airline_shift1.jsonl +190 -0
  42. capsule_engine/examples/data/tau2_airline/tau2_committed_claude-3-7-sonnet_airline_trial0.jsonl +556 -0
  43. capsule_engine/examples/data/tau2_airline/tau2_committed_gpt-4-1-mini_airline_trial0.jsonl +480 -0
  44. capsule_engine/examples/data/tau2_airline/tau2_committed_gpt-4-1_airline_trial0.jsonl +496 -0
  45. capsule_engine/examples/data/tau2_airline/tau2_committed_o4-mini_airline_trial0.jsonl +392 -0
  46. capsule_engine/examples/data/tau2_airline/tau2_conversations_claude-3-7-sonnet_airline_4trials.jsonl +200 -0
  47. capsule_engine/examples/tau2_airline_reference.py +460 -0
  48. capsule_engine/examples/tau2_conversation_exchange.py +92 -0
  49. capsule_engine/folds/__init__.py +104 -0
  50. capsule_engine/folds/account_core.py +89 -0
  51. capsule_engine/folds/approval_latency.py +86 -0
  52. capsule_engine/folds/catalog.py +79 -0
  53. capsule_engine/folds/catalog_defs/actions.count_by_developer.yaml +10 -0
  54. capsule_engine/folds/catalog_defs/actions.executed_count.yaml +16 -0
  55. capsule_engine/folds/catalog_defs/actions.last_decision.yaml +12 -0
  56. capsule_engine/folds/catalog_defs/counterparty.seen_before.v2.yaml +34 -0
  57. capsule_engine/folds/catalog_defs/counterparty.seen_before.v3.yaml +38 -0
  58. capsule_engine/folds/catalog_defs/counterparty.seen_before.yaml +28 -0
  59. capsule_engine/folds/catalog_defs/disclosure.class_seen.yaml +29 -0
  60. capsule_engine/folds/catalog_defs/disclosure.statement_seen.yaml +32 -0
  61. capsule_engine/folds/catalog_defs/hold.active_exposure.yaml +44 -0
  62. capsule_engine/folds/catalog_defs/spend.weekly.v2.yaml +38 -0
  63. capsule_engine/folds/catalog_defs/spend.weekly.v3.1.yaml +71 -0
  64. capsule_engine/folds/catalog_defs/spend.weekly.v3.yaml +76 -0
  65. capsule_engine/folds/catalog_defs/spend.weekly.yaml +33 -0
  66. capsule_engine/folds/counterparty_signals.py +179 -0
  67. capsule_engine/folds/definition.py +421 -0
  68. capsule_engine/folds/duration.py +16 -0
  69. capsule_engine/folds/engine.py +436 -0
  70. capsule_engine/folds/errors.py +44 -0
  71. capsule_engine/folds/loader.py +24 -0
  72. capsule_engine/folds/ordering.py +143 -0
  73. capsule_engine/folds/paths.py +25 -0
  74. capsule_engine/folds/record_type_coverage.py +58 -0
  75. capsule_engine/folds/reducers.py +75 -0
  76. capsule_engine/folds/retention_continuity.py +117 -0
  77. capsule_engine/folds/set_membership.py +59 -0
  78. capsule_engine/folds/taxonomy.py +231 -0
  79. capsule_engine/guards/__init__.py +59 -0
  80. capsule_engine/guards/action.py +446 -0
  81. capsule_engine/guards/action_taxonomy.json +27 -0
  82. capsule_engine/guards/action_taxonomy_v2.json +27 -0
  83. capsule_engine/guards/action_taxonomy_v3.json +27 -0
  84. capsule_engine/guards/action_taxonomy_v4.json +27 -0
  85. capsule_engine/guards/action_taxonomy_v5.json +27 -0
  86. capsule_engine/guards/capsule.py +377 -0
  87. capsule_engine/guards/checks/__init__.py +268 -0
  88. capsule_engine/guards/checks/action_class_gate.py +199 -0
  89. capsule_engine/guards/checks/base.py +33 -0
  90. capsule_engine/guards/checks/caps.py +517 -0
  91. capsule_engine/guards/checks/channel_change.py +64 -0
  92. capsule_engine/guards/checks/commitment.py +41 -0
  93. capsule_engine/guards/checks/counterparty_identity_change.py +95 -0
  94. capsule_engine/guards/checks/counterparty_list.py +176 -0
  95. capsule_engine/guards/checks/counterparty_seen_before.py +160 -0
  96. capsule_engine/guards/checks/credential_pattern.py +73 -0
  97. capsule_engine/guards/checks/dedupe.py +259 -0
  98. capsule_engine/guards/checks/destination_rail.py +120 -0
  99. capsule_engine/guards/checks/field_change_count.py +100 -0
  100. capsule_engine/guards/checks/offer_expiry.py +134 -0
  101. capsule_engine/guards/checks/plan_containment.py +180 -0
  102. capsule_engine/guards/checks/price_floor.py +211 -0
  103. capsule_engine/guards/checks/promise_never.py +236 -0
  104. capsule_engine/guards/checks/promise_requires_approval.py +214 -0
  105. capsule_engine/guards/checks/recipient_role.py +117 -0
  106. capsule_engine/guards/checks/recurring_charge.py +49 -0
  107. capsule_engine/guards/checks/refundability.py +46 -0
  108. capsule_engine/guards/checks/release_on_acceptance.py +286 -0
  109. capsule_engine/guards/checks/required_disclosure.py +233 -0
  110. capsule_engine/guards/checks/single_commitment.py +221 -0
  111. capsule_engine/guards/checks/task_authority.py +182 -0
  112. capsule_engine/guards/checks/upfront_amount.py +71 -0
  113. capsule_engine/guards/checks/verify_before_dispatch.py +75 -0
  114. capsule_engine/guards/classes.py +393 -0
  115. capsule_engine/guards/engine.py +926 -0
  116. capsule_engine/guards/history_state.py +127 -0
  117. capsule_engine/guards/plan.py +237 -0
  118. capsule_engine/guards/revocation.py +120 -0
  119. capsule_engine/guards/signing.py +60 -0
  120. capsule_engine/guards/statements.py +62 -0
  121. capsule_engine/guards/tool_call.py +83 -0
  122. capsule_engine/guards/wickets/__init__.py +23 -0
  123. capsule_engine/guards/wickets/catalog.py +75 -0
  124. capsule_engine/guards/wickets/catalog_defs/action_class_gate.v1.1.yaml +38 -0
  125. capsule_engine/guards/wickets/catalog_defs/action_class_gate.v1.2.yaml +52 -0
  126. capsule_engine/guards/wickets/catalog_defs/action_class_gate.yaml +34 -0
  127. capsule_engine/guards/wickets/catalog_defs/caps.v2.yaml +30 -0
  128. capsule_engine/guards/wickets/catalog_defs/caps.v3.yaml +49 -0
  129. capsule_engine/guards/wickets/catalog_defs/caps.v4.yaml +45 -0
  130. capsule_engine/guards/wickets/catalog_defs/caps.v5.1.yaml +53 -0
  131. capsule_engine/guards/wickets/catalog_defs/caps.v5.yaml +43 -0
  132. capsule_engine/guards/wickets/catalog_defs/caps.yaml +17 -0
  133. capsule_engine/guards/wickets/catalog_defs/caps_holds.yaml +18 -0
  134. capsule_engine/guards/wickets/catalog_defs/channel_change.v1.1.yaml +18 -0
  135. capsule_engine/guards/wickets/catalog_defs/channel_change.yaml +10 -0
  136. capsule_engine/guards/wickets/catalog_defs/counterparty_identity_change.yaml +9 -0
  137. capsule_engine/guards/wickets/catalog_defs/counterparty_list.yaml +20 -0
  138. capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v2.yaml +15 -0
  139. capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v3.yaml +23 -0
  140. capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v4.yaml +26 -0
  141. capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.yaml +14 -0
  142. capsule_engine/guards/wickets/catalog_defs/credential_pattern.yaml +14 -0
  143. capsule_engine/guards/wickets/catalog_defs/dedupe.yaml +19 -0
  144. capsule_engine/guards/wickets/catalog_defs/destination_rail.seller.v1.1.yaml +32 -0
  145. capsule_engine/guards/wickets/catalog_defs/destination_rail.seller.yaml +10 -0
  146. capsule_engine/guards/wickets/catalog_defs/destination_rail.yaml +9 -0
  147. capsule_engine/guards/wickets/catalog_defs/hold_reconcile.yaml +14 -0
  148. capsule_engine/guards/wickets/catalog_defs/material_fields_changed.v1.1.yaml +22 -0
  149. capsule_engine/guards/wickets/catalog_defs/material_fields_changed.yaml +14 -0
  150. capsule_engine/guards/wickets/catalog_defs/offer_expiry.v1.1.yaml +31 -0
  151. capsule_engine/guards/wickets/catalog_defs/offer_expiry.yaml +24 -0
  152. capsule_engine/guards/wickets/catalog_defs/offer_fields_changed.v1.1.yaml +22 -0
  153. capsule_engine/guards/wickets/catalog_defs/offer_fields_changed.yaml +10 -0
  154. capsule_engine/guards/wickets/catalog_defs/price_floor.yaml +32 -0
  155. capsule_engine/guards/wickets/catalog_defs/promise_never.v1.1.yaml +64 -0
  156. capsule_engine/guards/wickets/catalog_defs/promise_never.yaml +16 -0
  157. capsule_engine/guards/wickets/catalog_defs/promise_requires_approval.v1.1.yaml +26 -0
  158. capsule_engine/guards/wickets/catalog_defs/promise_requires_approval.yaml +21 -0
  159. capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.v1.1.yaml +23 -0
  160. capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.v1.2.yaml +24 -0
  161. capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.yaml +10 -0
  162. capsule_engine/guards/wickets/catalog_defs/recipient_role.v1.1.yaml +26 -0
  163. capsule_engine/guards/wickets/catalog_defs/recipient_role.yaml +11 -0
  164. capsule_engine/guards/wickets/catalog_defs/recipient_seen_before.yaml +16 -0
  165. capsule_engine/guards/wickets/catalog_defs/recurring_charge.yaml +9 -0
  166. capsule_engine/guards/wickets/catalog_defs/refundability.v1.1.yaml +15 -0
  167. capsule_engine/guards/wickets/catalog_defs/refundability.yaml +7 -0
  168. capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.v1.1.yaml +44 -0
  169. capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.v1.2.yaml +63 -0
  170. capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.yaml +36 -0
  171. capsule_engine/guards/wickets/catalog_defs/required_disclosure.v1.1.yaml +36 -0
  172. capsule_engine/guards/wickets/catalog_defs/required_disclosure.v1.2.yaml +42 -0
  173. capsule_engine/guards/wickets/catalog_defs/required_disclosure.yaml +25 -0
  174. capsule_engine/guards/wickets/catalog_defs/single_commitment.seller.yaml +14 -0
  175. capsule_engine/guards/wickets/catalog_defs/task_authority.v1.1.yaml +20 -0
  176. capsule_engine/guards/wickets/catalog_defs/task_authority.yaml +8 -0
  177. capsule_engine/guards/wickets/catalog_defs/upfront_amount.v1.1.yaml +19 -0
  178. capsule_engine/guards/wickets/catalog_defs/upfront_amount.yaml +11 -0
  179. capsule_engine/guards/wickets/catalog_defs/verify_before_dispatch.yaml +9 -0
  180. capsule_engine/guards/wickets/definition.py +164 -0
  181. capsule_engine/guards/wickets/errors.py +18 -0
  182. capsule_engine/guards/wickets/loader.py +26 -0
  183. capsule_engine/guards/wickets/retired.py +62 -0
  184. capsule_engine/holds/__init__.py +35 -0
  185. capsule_engine/holds/capsules.py +266 -0
  186. capsule_engine/holds/engine.py +476 -0
  187. capsule_engine/holds/errors.py +29 -0
  188. capsule_engine/holds/policy.py +62 -0
  189. capsule_engine/holds/scope.py +47 -0
  190. capsule_engine/mcp/__init__.py +16 -0
  191. capsule_engine/mcp/config.py +117 -0
  192. capsule_engine/mcp/server.py +338 -0
  193. capsule_engine/mcp/tools.py +424 -0
  194. capsule_engine/packaging.py +60 -0
  195. capsule_engine/packs/__init__.py +45 -0
  196. capsule_engine/packs/backfill_coverage.py +238 -0
  197. capsule_engine/packs/catalog/airline-engagement/AI-BOOTSTRAP.md +53 -0
  198. capsule_engine/packs/catalog/airline-engagement/folds/session_close.yaml +8 -0
  199. capsule_engine/packs/catalog/airline-engagement/pack.yaml +146 -0
  200. capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/good_execution.json +36 -0
  201. capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/oversight_declared.json +36 -0
  202. capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/tampered_execution.json +36 -0
  203. capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/trusted_issuer.jwk.json +5 -0
  204. capsule_engine/packs/catalog/eu-ai-act/obligations-pack-note.md +68 -0
  205. capsule_engine/packs/catalog/eu-ai-act/obligations-register.yaml +761 -0
  206. capsule_engine/packs/catalog/eu-ai-act/pack.yaml +88 -0
  207. capsule_engine/packs/catalog/eu-ai-act/register.yaml +96 -0
  208. capsule_engine/packs/catalog/eu-ai-act-deterministic/fixtures/synthetic_internal_assist_sample.jsonl +6 -0
  209. capsule_engine/packs/catalog/eu-ai-act-deterministic/pack.yaml +580 -0
  210. capsule_engine/packs/catalog/everyday/AI-BOOTSTRAP.md +194 -0
  211. capsule_engine/packs/catalog/everyday/fixtures/mini_ledger.jsonl +45 -0
  212. capsule_engine/packs/catalog/everyday/pack.yaml +480 -0
  213. capsule_engine/packs/catalog/payments-safety/AI-BOOTSTRAP.md +83 -0
  214. capsule_engine/packs/catalog/payments-safety/fixtures/mini_ledger.jsonl +8 -0
  215. capsule_engine/packs/catalog/payments-safety/folds/spend_weekly.yaml +29 -0
  216. capsule_engine/packs/catalog/payments-safety/pack.yaml +112 -0
  217. capsule_engine/packs/catalog/seller/fixtures/mini_ledger.jsonl +83 -0
  218. capsule_engine/packs/catalog/seller/pack.yaml +503 -0
  219. capsule_engine/packs/catalog/standard-vendor/AI-BOOTSTRAP.md +70 -0
  220. capsule_engine/packs/catalog/standard-vendor/folds/session_close.yaml +8 -0
  221. capsule_engine/packs/catalog/standard-vendor/pack.yaml +466 -0
  222. capsule_engine/packs/contract_diff.py +809 -0
  223. capsule_engine/packs/contract_validate.py +185 -0
  224. capsule_engine/packs/corpus_verify.py +126 -0
  225. capsule_engine/packs/enforce.py +81 -0
  226. capsule_engine/packs/errors.py +159 -0
  227. capsule_engine/packs/eu_ai_act_trace_field_map.py +46 -0
  228. capsule_engine/packs/install.py +266 -0
  229. capsule_engine/packs/loader.py +1462 -0
  230. capsule_engine/packs/measurability_report.py +280 -0
  231. capsule_engine/packs/obligation_results.py +72 -0
  232. capsule_engine/packs/pins.py +92 -0
  233. capsule_engine/packs/schema.py +1053 -0
  234. capsule_engine/packs/thresholds.py +146 -0
  235. capsule_engine/packs/trace_attainment.py +188 -0
  236. capsule_engine/packs/trace_reader.py +104 -0
  237. capsule_engine/policy/__init__.py +37 -0
  238. capsule_engine/policy/activation.py +87 -0
  239. capsule_engine/policy/catalog_defs/default.yaml +23 -0
  240. capsule_engine/policy/catalog_defs/holds.yaml +27 -0
  241. capsule_engine/policy/errors.py +46 -0
  242. capsule_engine/policy/limits.py +287 -0
  243. capsule_engine/policy/loader.py +26 -0
  244. capsule_engine/policy/manifest.py +289 -0
  245. capsule_engine/policy/profile.py +281 -0
  246. capsule_engine/policy/resolve.py +224 -0
  247. capsule_engine/register/__init__.py +39 -0
  248. capsule_engine/register/compiler.py +219 -0
  249. capsule_engine/register/errors.py +40 -0
  250. capsule_engine/register/loader.py +247 -0
  251. capsule_engine/register/schema.py +129 -0
  252. capsule_engine/registry/__init__.py +19 -0
  253. capsule_engine/registry/conventions.json +162 -0
  254. capsule_engine/registry/conventions.py +188 -0
  255. capsule_engine/registry/cpb_registry.json +78 -0
  256. capsule_engine/report/__init__.py +62 -0
  257. capsule_engine/report/build.py +267 -0
  258. capsule_engine/report/coverage.py +777 -0
  259. capsule_engine/report/errors.py +35 -0
  260. capsule_engine/report/live_check.py +126 -0
  261. capsule_engine/report/live_history.py +420 -0
  262. capsule_engine/report/model.py +95 -0
  263. capsule_engine/report/render.py +467 -0
  264. capsule_engine/report/replay.py +1677 -0
  265. capsule_engine/report/result.py +504 -0
  266. capsule_engine/report/result_from_folds.py +222 -0
  267. capsule_engine/report/result_from_layers.py +670 -0
  268. capsule_engine/report/sale_bundle.py +864 -0
  269. capsule_engine/report/static/verify.js +451 -0
  270. capsule_engine/schemas/evidence-contract-v0.json +383 -0
  271. capsule_engine/schemas/vendor/epistemic-types.json +21 -0
  272. capsule_engine/schemas/vendor/evidence-result-v0.json +463 -0
  273. capsule_engine/telemetry/__init__.py +9 -0
  274. capsule_engine/telemetry/consent.py +39 -0
  275. capsule_engine/telemetry/events.py +93 -0
  276. capsule_engine/telemetry/fixtures/synthetic_events.jsonl +45 -0
  277. capsule_engine/telemetry/funnel.py +147 -0
  278. capsule_engine/telemetry/record.py +106 -0
  279. capsule_engine/telemetry/sink.py +61 -0
  280. capsule_engine/telemetry/state.py +60 -0
  281. capsule_engine/tenants.py +327 -0
  282. capsule_engine-0.1.0.dist-info/METADATA +47 -0
  283. capsule_engine-0.1.0.dist-info/RECORD +288 -0
  284. capsule_engine-0.1.0.dist-info/WHEEL +5 -0
  285. capsule_engine-0.1.0.dist-info/entry_points.txt +2 -0
  286. capsule_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
  287. capsule_engine-0.1.0.dist-info/licenses/capsule_engine/examples/data/tau2_airline/LICENSE-tau2-bench +21 -0
  288. capsule_engine-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,57 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Render ``capsule bundle --with-viewer``'s self-contained HTML artifact.
3
+
4
+ The template (``static/offline_shell.html``) is a vendored, byte-for-byte
5
+ copy of scitt-cose's ``hosted_profiles.hosted.render_bundle_page(offline=True)``
6
+ output -- see ``scripts/vendor_bundle_viewer.py`` for how it's re-synced.
7
+ scitt-cose's own ``hosted_profiles/`` package is deliberately excluded from
8
+ its published wheel, so there is no pip dependency that could pull this in
9
+ at install time; vendoring one generated artifact (both repos are
10
+ Apache-2.0) is the mechanism, not a live fetch from a running verify
11
+ service -- ``capsule bundle`` is otherwise a fully local, network-free
12
+ command reading only the local ledger file, and ``--with-viewer`` keeps
13
+ that property rather than making bundle *production* depend on a service
14
+ being reachable. The template embeds MMR_JS and BUNDLE_JS inline (no
15
+ ``<script src>``) so the artifact this writes needs no network to open or
16
+ verify, on either end.
17
+
18
+ The template carries one real embed slot,
19
+ ``window.__BUNDLE_FRAGMENT_B64U__="@@BUNDLE_FRAGMENT@@"`` -- substituted
20
+ here with the same base64url fragment ``capsule bundle`` already computes
21
+ for its permalink. Other appearances of that placeholder text elsewhere in
22
+ the template are BUNDLE_JS's own placeholder-detection / re-splice logic
23
+ (its in-browser "download self-contained copy" button) and must not be
24
+ touched, so this replaces only the one assignment, not every occurrence of
25
+ the bare token.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from importlib import resources
31
+
32
+ __all__ = ["render_offline_viewer_html"]
33
+
34
+ _EMBED_SLOT_PLACEHOLDER = '<script>window.__BUNDLE_FRAGMENT_B64U__="@@BUNDLE_FRAGMENT@@";</script>'
35
+
36
+
37
+ def _load_template() -> str:
38
+ return (
39
+ resources.files("capsule_engine.bundle_viewer")
40
+ .joinpath("static", "offline_shell.html")
41
+ .read_text(encoding="utf-8")
42
+ )
43
+
44
+
45
+ def render_offline_viewer_html(fragment: str) -> str:
46
+ """Return the self-contained HTML for *fragment* (the same base64url
47
+ bundle fragment used in the bundle's permalink). The result carries no
48
+ external references at all -- open it directly, no server required."""
49
+ template = _load_template()
50
+ if template.count(_EMBED_SLOT_PLACEHOLDER) != 1:
51
+ raise RuntimeError(
52
+ "vendored offline_shell.html's embed slot is missing or no longer "
53
+ "unique -- re-check it against a fresh vendor pass "
54
+ "(scripts/vendor_bundle_viewer.py) before shipping --with-viewer"
55
+ )
56
+ embed_slot = f"<script>window.__BUNDLE_FRAGMENT_B64U__={json.dumps(fragment)};</script>"
57
+ return template.replace(_EMBED_SLOT_PLACEHOLDER, embed_slot, 1)
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``capsule-engine`` CLI package: thin verb dispatcher, see ``main.py``."""
3
+ from .main import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,269 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`capsule bundle`: a self-contained, independently verifiable slice of the
3
+ ledger, plus a verify-surface permalink.
4
+
5
+ "Self-contained" is load-bearing: the bundle transitively pulls in any
6
+ ``chain.parent_capsule_id`` a selected record cites (walking the real
7
+ ledger) so the slice verifies on its own, without needing the rest of the
8
+ ledger present -- verification below runs with ``store=`` restricted to
9
+ exactly the bundle's own capsule ids, never the full ledger, to prove that
10
+ property rather than assume it.
11
+
12
+ The permalink is fragment-carried (``#...``, after the URL's ``#``): per
13
+ the workspace's other verify surfaces (`agentactioncapsule-site` /
14
+ `scitt-cose`'s hosted verifier), capsule data that goes after ``#`` is never
15
+ sent to a server -- only the browser-side JS reads it. ``verify.
16
+ agentactioncapsule.org`` is this workspace's existing public verify domain
17
+ (see CLAUDE.md / STATUS.md T7 notes); the base URL is still overridable
18
+ (``--verify-base-url`` / ``$CAPSULE_VERIFY_BASE_URL``) since no bundle-specific
19
+ route exists there yet -- this is this package's own convention until one
20
+ does.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import base64
26
+ import dataclasses
27
+ import json
28
+ import os
29
+ import sys
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+
33
+ from agent_action_capsule import verify as verify_capsule
34
+ from capsule_emit.checkpoint import MmrLedger
35
+ from capsule_emit.checkpoint import core as mmr_core
36
+ from capsule_emit.ledger_io import (
37
+ add_scan_query_args,
38
+ build_scan_query,
39
+ echo_parts,
40
+ open_ledger,
41
+ require_ledger_path,
42
+ )
43
+ from capsule_emit.period import add_period_arg, apply_period
44
+ from cll.ledger.api import ScanQuery
45
+
46
+ from ..envcompat import env_get
47
+ from ..guards.capsule import local_only_refusal
48
+ from .format import build_echo, format_staleness
49
+
50
+ __all__ = ["add_parser", "run"]
51
+
52
+ DEFAULT_VERIFY_BASE_URL = "https://verify.agentactioncapsule.org/bundle"
53
+
54
+
55
+ def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
56
+ p = sub.add_parser("bundle", help="produce a self-contained verifiable slice of the ledger")
57
+ add_scan_query_args(p)
58
+ add_period_arg(p)
59
+ p.add_argument("--out", default="bundle.json", help="output path for the bundle file (default: %(default)s)")
60
+ p.add_argument(
61
+ "--verify-base-url",
62
+ dest="verify_base_url",
63
+ default=env_get("CAPSULE_VERIFY_BASE_URL", DEFAULT_VERIFY_BASE_URL),
64
+ help="base URL the permalink's fragment is appended to (default: %(default)s)",
65
+ )
66
+ p.add_argument(
67
+ "--with-viewer",
68
+ dest="with_viewer",
69
+ action="store_true",
70
+ help=(
71
+ "also write a self-contained offline HTML viewer next to --out, so the "
72
+ "bundle verifies on a machine with no network (default viewer path: "
73
+ "--out with a .html extension)"
74
+ ),
75
+ )
76
+ p.add_argument(
77
+ "--viewer-out",
78
+ dest="viewer_out",
79
+ default=None,
80
+ help="output path for the offline viewer HTML (implies --with-viewer; default: derived from --out)",
81
+ )
82
+ p.set_defaults(func=run)
83
+ return p
84
+
85
+
86
+ def _echo_safe_out(out: str) -> str:
87
+ """The ``--out`` value ``cli_echo`` renders on the public bundle
88
+ permalink and offline viewer -- it must show where the file went
89
+ relative to the invocation, never the operator's absolute filesystem
90
+ layout. An absolute path outside the cwd tree (no relative route to it)
91
+ falls back to just the filename rather than leaking the full path."""
92
+ p = Path(out)
93
+ if not p.is_absolute():
94
+ return out
95
+ rel = os.path.relpath(p, Path.cwd())
96
+ return rel if not rel.startswith("..") else p.name
97
+
98
+
99
+ def _default_viewer_out(out: str) -> str:
100
+ """``bundle.json`` -> ``bundle.html`` alongside it; falls back to a
101
+ ``-viewer.html`` suffix on the rare path where ``--out`` already ends in
102
+ ``.html`` (so the two outputs never collide)."""
103
+ p = Path(out)
104
+ candidate = p.with_suffix(".html")
105
+ if str(candidate) == out:
106
+ candidate = p.with_name(p.stem + "-viewer.html")
107
+ return str(candidate)
108
+
109
+
110
+ def _collect_with_parents(store, matched):
111
+ """Pull in any cited chain parent not already selected, so the bundle
112
+ verifies standalone without needing the rest of the ledger. A parent
113
+ that genuinely isn't in the ledger (a real gap) is left out -- it
114
+ surfaces honestly as a finding on the citing record, never hidden."""
115
+ by_id = {r.capsule_id: r for r in matched}
116
+ frontier = list(matched)
117
+ missing: set[str] = set()
118
+ while frontier:
119
+ rec = frontier.pop()
120
+ parent_id = (rec.capsule.get("chain") or {}).get("parent_capsule_id")
121
+ if not parent_id or parent_id in by_id or parent_id in missing:
122
+ continue
123
+ parent = store.fetch(parent_id)
124
+ if parent is None:
125
+ missing.add(parent_id)
126
+ continue
127
+ by_id[parent.capsule_id] = parent
128
+ frontier.append(parent)
129
+ return sorted(by_id.values(), key=lambda r: r.seq)
130
+
131
+
132
+ def _build_completeness_certificate(store, records, tree_size: int) -> dict | None:
133
+ """MMR range proof (plus a consistency proof bridging to the full
134
+ ledger's checkpoint, when the bundle's range doesn't already reach it)
135
+ over the bundle's own record range.
136
+
137
+ Shape matches scitt-cose's viewer (``MMR_JS`` / ``checkCompleteness`` in
138
+ ``hosted_profiles/hosted.py``) field for field -- that module ports this
139
+ package's MMR core to JS and already checks a certificate in this shape;
140
+ this is the CLI side of that contract, not a new one.
141
+ """
142
+ if not records:
143
+ return None
144
+
145
+ mmr = MmrLedger(store)
146
+ mmr.sync()
147
+
148
+ from_seq, to_seq = records[0].seq, records[-1].seq
149
+ proof = mmr.range_proof(from_seq, to_seq)
150
+ range_root = mmr.root_at(proof.size).hex()
151
+
152
+ checkpoint_size = mmr_core.node_count(tree_size)
153
+ if checkpoint_size == proof.size:
154
+ checkpoint_root = range_root
155
+ consistency = None
156
+ else:
157
+ checkpoint_root = mmr.root_at(checkpoint_size).hex()
158
+ consistency = mmr.consistency_proof(proof.size, checkpoint_size)
159
+
160
+ return {
161
+ "v": 1,
162
+ "range_proof": {
163
+ "from_seq": proof.from_seq,
164
+ "to_seq": proof.to_seq,
165
+ "size": proof.size,
166
+ "from_index": proof.from_index,
167
+ "to_index": proof.to_index,
168
+ "witness": list(proof.witness),
169
+ },
170
+ "range_root": range_root,
171
+ "checkpoint_size": checkpoint_size,
172
+ "checkpoint_root": checkpoint_root,
173
+ "consistency_proof": dataclasses.asdict(consistency) if consistency is not None else None,
174
+ }
175
+
176
+
177
+ def run(args: argparse.Namespace) -> int:
178
+ # ``bundle`` only exists at all in the "full" packaging arm -- see
179
+ # ``cli/main.py`` -- so any use of it is M5's "bundle/share created" fact.
180
+ from ..telemetry.record import record_evidence_touch
181
+
182
+ record_evidence_touch("full")
183
+
184
+ ledger_path = require_ledger_path("bundle", args)
185
+ if ledger_path is None:
186
+ return 2
187
+
188
+ apply_period(args) # --period fills in args.since/args.until in place when unset
189
+ query = build_scan_query(args)
190
+ with open_ledger(ledger_path) as store:
191
+ matched = list(store.scan(query))
192
+ records = _collect_with_parents(store, matched)
193
+ capsules = [r.capsule for r in records]
194
+ refusal = local_only_refusal(capsules)
195
+ if refusal is not None:
196
+ print(f"capsule bundle: refused: {refusal}; narrow the selection to records without them", file=sys.stderr)
197
+ return 2
198
+ ids = [c["capsule_id"] for c in capsules]
199
+
200
+ verification: dict[str, dict] = {}
201
+ all_ok = True
202
+ for capsule in capsules:
203
+ result = verify_capsule(capsule, store=ids)
204
+ verification[capsule["capsule_id"]] = {
205
+ "ok": result.ok,
206
+ "findings": [{"code": f.code, "detail": f.detail, "severity": f.severity} for f in result.findings],
207
+ }
208
+ all_ok = all_ok and result.ok
209
+
210
+ tree_size = sum(1 for _ in store.scan(ScanQuery()))
211
+ completeness_certificate = _build_completeness_certificate(store, records, tree_size)
212
+
213
+ echo = build_echo(
214
+ "bundle", flags=[*echo_parts(args), ("--period", args.period), ("--out", _echo_safe_out(args.out))]
215
+ )
216
+ bundle = {
217
+ "bundle_version": "1",
218
+ "created_at": datetime.now(timezone.utc).isoformat(),
219
+ "query": {
220
+ k: v
221
+ for k, v in {
222
+ "agent": args.agent,
223
+ # args.since/args.until already reflect --period's resolved
224
+ # bounds (apply_period mutates them in place above) -- a
225
+ # bundle's recorded query must show what was actually
226
+ # scanned, not the pre-resolution flag.
227
+ "since": args.since,
228
+ "until": args.until,
229
+ "period": args.period,
230
+ "counterparty": args.counterparty,
231
+ "verdict": args.verdict,
232
+ "action_type": args.action_type,
233
+ "limit": args.limit,
234
+ }.items()
235
+ if v is not None
236
+ },
237
+ "cli_echo": echo,
238
+ "records": capsules,
239
+ "range": [records[0].seq, records[-1].seq] if records else [0, -1],
240
+ "checkpoint": {"tree_size": tree_size},
241
+ "verification": verification,
242
+ "completeness_certificate": completeness_certificate,
243
+ }
244
+
245
+ with open(args.out, "w", encoding="utf-8") as fh:
246
+ json.dump(bundle, fh, indent=2, sort_keys=True)
247
+
248
+ payload = json.dumps(bundle, separators=(",", ":"), sort_keys=True).encode("utf-8")
249
+ fragment = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
250
+ permalink = f"{args.verify_base_url}#{fragment}"
251
+
252
+ r0, r1 = bundle["range"]
253
+ status = "all verify" if all_ok else "VERIFICATION FAILURE in this slice"
254
+ print(f"wrote {args.out} ({len(records)} record(s), records {r0}–{r1}, {status})")
255
+ print(f"checkpoint #{tree_size} · as of {format_staleness(0)}")
256
+ print(f"verify: {permalink}")
257
+
258
+ if args.with_viewer or args.viewer_out is not None:
259
+ from ..bundle_viewer import render_offline_viewer_html
260
+
261
+ viewer_out = args.viewer_out or _default_viewer_out(args.out)
262
+ viewer_html = render_offline_viewer_html(fragment)
263
+ with open(viewer_out, "w", encoding="utf-8") as fh:
264
+ fh.write(viewer_html)
265
+ print(f"wrote {viewer_out} (self-contained, opens with no network)")
266
+
267
+ print()
268
+ print(echo)
269
+ return 0 if all_ok else 1
@@ -0,0 +1,70 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`capsule console`: serve the local console UI (record stream, filters,
3
+ per-record inspector, fold strip) over the real ledger.
4
+
5
+ LOCAL ONLY -- binds to localhost by default, no hosted anything, no account
6
+ system. See `capsule_ledger/console/server.py` for the HTTP wiring and
7
+ `capsule_ledger/console/{tokens,components}.css` for the shared component
8
+ library this UI is built from (never re-invented here).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+
14
+ from capsule_emit.ledger_io import require_ledger_path
15
+
16
+ from .format import build_echo
17
+
18
+ __all__ = ["add_parser", "run"]
19
+
20
+ DEFAULT_HOST = "127.0.0.1"
21
+ DEFAULT_PORT = 8420
22
+
23
+
24
+ def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
25
+ p = sub.add_parser(
26
+ "console", help="serve the local console UI (record stream + inspector) over the real ledger"
27
+ )
28
+ p.add_argument("--ledger", help="ledger store directory or a JSONL fixture file (default: $CAPSULE_LEDGER)")
29
+ p.add_argument(
30
+ "--host", default=DEFAULT_HOST, help="bind host -- local only by default (default: %(default)s)"
31
+ )
32
+ p.add_argument("--port", type=int, default=DEFAULT_PORT, help="bind port (default: %(default)s)")
33
+ p.set_defaults(func=run)
34
+ return p
35
+
36
+
37
+ def run(args: argparse.Namespace) -> int:
38
+ # The console only exists at all in the "full" packaging arm (see
39
+ # ``cli/main.py``) -- it renders capsule vocabulary and verification
40
+ # state throughout, matching log/show/verify/bundle's own M5 marker.
41
+ from ..telemetry.record import record_evidence_touch
42
+
43
+ record_evidence_touch("full")
44
+
45
+ ledger_path = require_ledger_path("console", args)
46
+ if ledger_path is None:
47
+ return 2
48
+
49
+ from ..console.server import build_server
50
+
51
+ server = build_server(ledger_path, host=args.host, port=args.port)
52
+ url = f"http://{args.host}:{args.port}/"
53
+ print(f"capsule console: serving {ledger_path} at {url} (local only — no hosted anything, no account system)")
54
+ print(
55
+ build_echo(
56
+ "console",
57
+ flags=[
58
+ ("--ledger", args.ledger),
59
+ ("--host", args.host if args.host != DEFAULT_HOST else None),
60
+ ("--port", args.port if args.port != DEFAULT_PORT else None),
61
+ ],
62
+ )
63
+ )
64
+ try:
65
+ server.serve_forever()
66
+ except KeyboardInterrupt:
67
+ pass
68
+ finally:
69
+ server.server_close()
70
+ return 0
@@ -0,0 +1,74 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`capsule constraints list`: the registered guard checks + the starter
3
+ action-class taxonomy (T3's ``guards/classes.py``) -- a static catalog, not
4
+ a ledger query, so it needs no ``--ledger``."""
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ from ..folds.catalog import Catalog
11
+ from ..guards.classes import TAXONOMY, UNCLASSIFIED_DEFAULT
12
+
13
+ __all__ = ["add_parser", "run_list", "CHECKS", "DEFAULT_CAPS_FOLD_ID", "DEFAULT_CATALOG_DIR"]
14
+
15
+ DEFAULT_CATALOG_DIR = Path(__file__).resolve().parent.parent / "folds" / "catalog_defs"
16
+ # The caps check's fold is a per-deployment GuardEngine configuration
17
+ # (`GuardEngine(caps_fold=...)`); this listing resolves the one T3 wires up
18
+ # in its own tests/fixtures (`conftest.py`'s `caps_fold` fixture) as the
19
+ # representative default, rather than invent one of its own.
20
+ DEFAULT_CAPS_FOLD_ID = "spend.weekly/1.0.0"
21
+
22
+ # (check_id, check_type, method, description) -- method is None for `caps`,
23
+ # whose method is the configured fold_id, resolved at print time below.
24
+ # Public (not `_`-prefixed): the MCP server's `constraints.list` tool reuses
25
+ # this exact catalog rather than keeping its own copy in sync by hand.
26
+ CHECKS = [
27
+ (
28
+ "dedupe",
29
+ "policy",
30
+ "exact_match_index_v0",
31
+ "equivalence lookup over a rolling window — exact-match only, no fuzzy/semantic matching",
32
+ ),
33
+ (
34
+ "caps",
35
+ "policy",
36
+ None,
37
+ "fold predicate: weekly_spend + amount <= cap (integer minor units only)",
38
+ ),
39
+ (
40
+ "verify_before_dispatch",
41
+ "policy",
42
+ "agent_action_capsule.verify",
43
+ "the cited mandate capsule must exist in the ledger and independently re-verify",
44
+ ),
45
+ ]
46
+
47
+
48
+ def add_parser(sub: argparse._SubParsersAction) -> argparse.ArgumentParser:
49
+ constraints = sub.add_parser("constraints", help="the registered guard checks and the action-class taxonomy")
50
+ csub = constraints.add_subparsers(dest="constraints_command")
51
+ constraints.set_defaults(constraints_parser=constraints)
52
+
53
+ p_list = csub.add_parser("list", help="list the registered guard checks and the action-class taxonomy")
54
+ p_list.add_argument("--dir", help="fold catalog directory the caps check reads from (default: built-in catalog)")
55
+ p_list.set_defaults(func=run_list)
56
+
57
+ return constraints
58
+
59
+
60
+ def run_list(args: argparse.Namespace) -> int:
61
+ catalog_dir = Path(args.dir) if getattr(args, "dir", None) else DEFAULT_CATALOG_DIR
62
+ caps_entry = Catalog(catalog_dir).get(DEFAULT_CAPS_FOLD_ID)
63
+ caps_method = caps_entry.definition.fold_id if caps_entry is not None else "(no caps fold configured)"
64
+
65
+ print("checks:")
66
+ for check_id, check_type, method, description in CHECKS:
67
+ resolved_method = caps_method if check_id == "caps" else method
68
+ print(f" {check_id:<24} {check_type:<8} {resolved_method:<28} {description}")
69
+
70
+ print()
71
+ print("action classes (absent or unrecognized -> unclassified, fail-closed):")
72
+ for ac in (*TAXONOMY.values(), UNCLASSIFIED_DEFAULT):
73
+ print(f" {ac.name:<26} consequential={str(ac.consequential):<6} fail_open_allowed={ac.fail_open_allowed}")
74
+ return 0
@@ -0,0 +1,173 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Shared CLI output discipline: the DM Mono fold-envelope line, staleness
3
+ copy, the CLI-echo canonical form, and one capsule-summary helper.
4
+
5
+ The envelope-line format is copied verbatim from the design handoff
6
+ README's "Product laws" section (`design_handoff_capsule_ledger_ux/README.md`):
7
+ ``fold <id> · records N–M · checkpoint #X · as of <staleness>`` -- literal
8
+ text output, not a font choice. ``<id>`` is the fold's own ``fold`` envelope
9
+ field (the definition digest already computed by the fold engine), never a
10
+ name this module invents.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import shlex
16
+
17
+ from ..registry import describe_action_class
18
+
19
+ __all__ = [
20
+ "format_staleness",
21
+ "format_envelope_line",
22
+ "format_coverage_footnote",
23
+ "build_echo",
24
+ "cli_echo_leaks_absolute_path",
25
+ "summarize_action",
26
+ "format_action_class",
27
+ "assurance_grade_parts",
28
+ "format_assurance_grade",
29
+ ]
30
+
31
+ _WINDOWS_DRIVE_RE = re.compile(r"[A-Za-z]:\\")
32
+
33
+
34
+ def format_staleness(age_ms: int) -> str:
35
+ """Render a checkpoint age in the design's "as of 14s ago" style.
36
+
37
+ ``age_ms <= 0`` (a value freshly computed by this same CLI invocation,
38
+ never backdated) renders as "just now" rather than "0s ago".
39
+ """
40
+ if age_ms <= 0:
41
+ return "just now"
42
+ seconds = age_ms / 1000
43
+ if seconds < 60:
44
+ return f"{round(seconds)}s ago"
45
+ minutes = seconds / 60
46
+ if minutes < 60:
47
+ return f"{round(minutes)}m ago"
48
+ hours = minutes / 60
49
+ if hours < 24:
50
+ return f"{round(hours)}h ago"
51
+ days = hours / 24
52
+ return f"{round(days)}d ago"
53
+
54
+
55
+ def format_envelope_line(envelope: dict) -> str:
56
+ """Render a fold result envelope (spec §4 shape) as the literal DM Mono line."""
57
+ r0, r1 = envelope["range"]
58
+ checkpoint_num = envelope["checkpoint"].get("tree_size")
59
+ staleness_ms = envelope["staleness"].get("checkpoint_age_ms", 0)
60
+ return (
61
+ f"fold {envelope['fold']} · records {r0}–{r1} · "
62
+ f"checkpoint #{checkpoint_num} · as of {format_staleness(staleness_ms)}"
63
+ )
64
+
65
+
66
+ def format_coverage_footnote(coverage: dict) -> str:
67
+ """Render a fold's coverage statement (T2R rev9(a),
68
+ ``EvaluationTrace.coverage_statement()``) as the design's literal
69
+ per-number footnote: "range-complete through C · captured under boundary
70
+ B, rule R · reconciled against O over interval T". The rule this
71
+ enforces (rev9(a)): *a report cannot render a number without its
72
+ coverage statement* -- every caller that shows a fold's ``result`` shows
73
+ this alongside it, never bare.
74
+
75
+ ``capture: "unknown"`` (no boundary/rule declared for this evaluation)
76
+ renders visibly rather than being silently dropped from the footnote;
77
+ ``reconciled: None`` (no external comparison set) renders as
78
+ "reconciled: none" for the same reason.
79
+ """
80
+ _, range_end = coverage["range"]
81
+ capture = coverage["capture"]
82
+ if capture == "unknown":
83
+ capture_part = "captured under boundary: unknown"
84
+ else:
85
+ capture_part = f"captured under boundary {capture['boundary']}, rule {capture['rule']}"
86
+ reconciled = coverage["reconciled"]
87
+ reconciled_part = (
88
+ "reconciled: none"
89
+ if reconciled is None
90
+ else f"reconciled against {reconciled['source']} over interval {reconciled['interval']}"
91
+ )
92
+ return f"range-complete through {range_end} · {capture_part} · {reconciled_part}"
93
+
94
+
95
+ def build_echo(verb: str, *, positional: str | None = None, flags: list[tuple[str, object]] = ()) -> str:
96
+ """The CLI-echo canonical form: the exact, copy-pasteable invocation that
97
+ produced this output (design handoff README, "CLI echo standing rule").
98
+ Flag order is fixed (whatever the caller passes), not the order the user
99
+ typed them in, so the same query always echoes identically.
100
+ """
101
+ tokens = ["capsule", verb]
102
+ if positional is not None:
103
+ tokens.append(shlex.quote(str(positional)))
104
+ for flag, value in flags:
105
+ if value is None or value is False:
106
+ continue
107
+ tokens.append(flag)
108
+ if value is not True:
109
+ tokens.append(shlex.quote(str(value)))
110
+ return "≡ " + " ".join(tokens)
111
+
112
+
113
+ def cli_echo_leaks_absolute_path(cli_echo: str) -> bool:
114
+ """``cli_echo`` is rendered verbatim on the bundle permalink and the
115
+ offline viewer -- a public page -- so it must never carry an operator's
116
+ absolute filesystem layout (home directory, machine username). Flags any
117
+ token that is a POSIX absolute path (a leading ``/``, with no allowlisted
118
+ exception defined) or a Windows drive path (``C:\\...``); callers that
119
+ build ``cli_echo`` from user-supplied paths must relativize them first
120
+ rather than adding an exception here."""
121
+ tokens = shlex.split(cli_echo.removeprefix("≡ "))
122
+ for token in tokens:
123
+ if token.startswith("/") or _WINDOWS_DRIVE_RE.match(token):
124
+ return True
125
+ return False
126
+
127
+
128
+ def summarize_action(capsule: dict) -> str:
129
+ """The verb portion of ``action_id`` (e.g. "approve_purchase"), falling
130
+ back to ``action_type`` for records with no ``action_id``."""
131
+ action_id = capsule.get("action_id") or ""
132
+ verb = action_id.split("/", 1)[0] if action_id else ""
133
+ return verb or capsule.get("action_type") or "(unnamed action)"
134
+
135
+
136
+ def format_action_class(capsule: dict) -> str | None:
137
+ """Convention-label line for this capsule's ``asg_payload.action_class``
138
+ (design principle item 2) -- ``None`` when the capsule carries no
139
+ ``action_class`` at all (a legitimately different state from an
140
+ unregistered *value*, which renders instead of being hidden)."""
141
+ action_class = (capsule.get("asg_payload") or {}).get("action_class")
142
+ convention = describe_action_class(action_class)
143
+ if convention.action_class is None:
144
+ return None
145
+ if convention.registered:
146
+ return f"{convention.action_class} — {convention.label}"
147
+ return f"{convention.action_class} (unregistered)"
148
+
149
+
150
+ _ATTESTATION_LABELS = {"self_attested": "self-attested", "anchored": "anchored"}
151
+
152
+
153
+ def assurance_grade_parts(assurance: dict) -> tuple[str, bool]:
154
+ """(label, badged) for this assurance block -- the one place that
155
+ decides which attestation modes are "badged" (design principle item 3).
156
+ Extensible to a future ``countersigned`` value the same way, once that
157
+ assurance mode is a real field this codebase produces; ``badged`` is
158
+ never about paid/free, only about attestation strength."""
159
+ mode = assurance.get("attestation_mode")
160
+ label = _ATTESTATION_LABELS.get(mode, mode or "(none)")
161
+ ledger_mode = assurance.get("ledger_mode")
162
+ grade = f"{label} · ledger: {ledger_mode}" if ledger_mode else label
163
+ return grade, mode == "anchored"
164
+
165
+
166
+ def format_assurance_grade(assurance: dict) -> str:
167
+ """Assurance grades do the honesty work everywhere (design principle
168
+ item 3): ``self_attested`` renders plain, ``anchored`` is badged
169
+ (``[...]``) -- driven only by the real ``attestation_mode``/
170
+ ``ledger_mode`` values on the record, never invented, never upsell
171
+ copy."""
172
+ grade, badged = assurance_grade_parts(assurance)
173
+ return f"[{grade}]" if badged else grade