proofbundle 3.1.0__tar.gz → 3.1.2__tar.gz

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 (154) hide show
  1. {proofbundle-3.1.0/src/proofbundle.egg-info → proofbundle-3.1.2}/PKG-INFO +8 -1
  2. {proofbundle-3.1.0 → proofbundle-3.1.2}/README.md +7 -0
  3. {proofbundle-3.1.0 → proofbundle-3.1.2}/pyproject.toml +1 -1
  4. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/__init__.py +1 -1
  5. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/bundle.py +122 -10
  6. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/cli.py +163 -9
  7. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/decision.py +21 -10
  8. proofbundle-3.1.2/src/proofbundle/policies/decision-receipt-template-v1.json +24 -0
  9. proofbundle-3.1.2/src/proofbundle/policies/strict-eval-authenticated-root-template-v1.json +25 -0
  10. proofbundle-3.1.2/src/proofbundle/policies/strict-eval-template-v1.json +24 -0
  11. proofbundle-3.1.2/src/proofbundle/policies/strict-prereg-template-v1.json +25 -0
  12. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/policy.py +84 -4
  13. proofbundle-3.1.2/src/proofbundle/policy_profiles.py +236 -0
  14. {proofbundle-3.1.0 → proofbundle-3.1.2/src/proofbundle.egg-info}/PKG-INFO +8 -1
  15. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle.egg-info/SOURCES.txt +5 -4
  16. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_decision_hardening.py +17 -0
  17. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_decision_policy.py +73 -0
  18. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_kbjwt.py +29 -8
  19. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_ok_semantics.py +13 -3
  20. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_policy_profiles.py +21 -9
  21. proofbundle-3.1.2/tests/test_policy_templates.py +185 -0
  22. proofbundle-3.1.2/tests/test_root_authenticity.py +679 -0
  23. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_sdjwt_verify_binding.py +94 -1
  24. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_trust_policy.py +14 -4
  25. proofbundle-3.1.0/src/proofbundle/policies/decision-receipt-v1.json +0 -14
  26. proofbundle-3.1.0/src/proofbundle/policies/strict-eval-authenticated-root-v1.json +0 -9
  27. proofbundle-3.1.0/src/proofbundle/policies/strict-eval-v1.json +0 -9
  28. proofbundle-3.1.0/src/proofbundle/policies/strict-prereg-v1.json +0 -9
  29. proofbundle-3.1.0/src/proofbundle/policy_profiles.py +0 -86
  30. proofbundle-3.1.0/tests/test_root_authenticity.py +0 -244
  31. {proofbundle-3.1.0 → proofbundle-3.1.2}/LICENSE +0 -0
  32. {proofbundle-3.1.0 → proofbundle-3.1.2}/setup.cfg +0 -0
  33. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/_inspect_registry.py +0 -0
  34. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/_integration.py +0 -0
  35. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/_strict_json.py +0 -0
  36. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/adapters/__init__.py +0 -0
  37. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/adapters/_provenance.py +0 -0
  38. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/adapters/eee.py +0 -0
  39. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/adapters/inspect_ai.py +0 -0
  40. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/adapters/lm_eval.py +0 -0
  41. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/adapters/promptfoo.py +0 -0
  42. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/adapters/samples.py +0 -0
  43. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/anchors.py +0 -0
  44. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/anchors_chia.py +0 -0
  45. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/anchors_chia_add.py +0 -0
  46. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/anchors_markovian.py +0 -0
  47. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/anchors_ots.py +0 -0
  48. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/anchors_rfc3161.py +0 -0
  49. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/beacon.py +0 -0
  50. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/canonical.py +0 -0
  51. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/checkpoint.py +0 -0
  52. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/demo.py +0 -0
  53. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/dsse.py +0 -0
  54. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/eee_eval_schema.json +0 -0
  55. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/emit.py +0 -0
  56. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/errors.py +0 -0
  57. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/evalclaim.py +0 -0
  58. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/experimental/__init__.py +0 -0
  59. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/experimental/enclave.py +0 -0
  60. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/hf_evals.py +0 -0
  61. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/inspect_hook.py +0 -0
  62. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/intoto.py +0 -0
  63. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/kbjwt.py +0 -0
  64. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/merkle.py +0 -0
  65. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/persample.py +0 -0
  66. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/policies/research-preview-v1.json +0 -0
  67. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/prereg.py +0 -0
  68. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/py.typed +0 -0
  69. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/pytest_plugin.py +0 -0
  70. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/sdjwt.py +0 -0
  71. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/sdjwt_issue.py +0 -0
  72. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/signature.py +0 -0
  73. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/statuslist.py +0 -0
  74. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle/tlogproof.py +0 -0
  75. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  76. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle.egg-info/entry_points.txt +0 -0
  77. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle.egg-info/requires.txt +0 -0
  78. {proofbundle-3.1.0 → proofbundle-3.1.2}/src/proofbundle.egg-info/top_level.txt +0 -0
  79. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_adapters.py +0 -0
  80. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_adversarial.py +0 -0
  81. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_anchor_target_trustedtime.py +0 -0
  82. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_anchors_chia.py +0 -0
  83. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_anchors_chia_add.py +0 -0
  84. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_anchors_chia_claims.py +0 -0
  85. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_anchors_generic.py +0 -0
  86. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_anchors_markovian.py +0 -0
  87. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_anchors_ots.py +0 -0
  88. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_anchors_rfc3161.py +0 -0
  89. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_beacon.py +0 -0
  90. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_branch_base_check.py +0 -0
  91. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_bundle.py +0 -0
  92. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_bundle_robustness.py +0 -0
  93. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_canonical.py +0 -0
  94. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_checkpoint.py +0 -0
  95. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_claims_hygiene.py +0 -0
  96. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_cli.py +0 -0
  97. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_cli_eval.py +0 -0
  98. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_cli_require_anchor.py +0 -0
  99. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_conformance.py +0 -0
  100. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_cosignature.py +0 -0
  101. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_cosignature_mldsa.py +0 -0
  102. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_decision_anchors.py +0 -0
  103. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_decision_cli.py +0 -0
  104. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_decision_evidence.py +0 -0
  105. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_decision_fuzz.py +0 -0
  106. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_decision_schema.py +0 -0
  107. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_decision_validator_api.py +0 -0
  108. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_decision_verify.py +0 -0
  109. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_demo.py +0 -0
  110. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_docs_truth.py +0 -0
  111. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_dup_key_reject.py +0 -0
  112. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_ed25519_semantics.py +0 -0
  113. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_eee.py +0 -0
  114. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_emit.py +0 -0
  115. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_eval_claim_schema.py +0 -0
  116. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_eval_evidence_class.py +0 -0
  117. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_evalclaim.py +0 -0
  118. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_examples.py +0 -0
  119. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_experimental_enclave.py +0 -0
  120. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_fork_pr_secret_isolation.py +0 -0
  121. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_fuzz_parsers.py +0 -0
  122. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_hf_entry_verify_eee_digest.py +0 -0
  123. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_hf_evals.py +0 -0
  124. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_inspect_hook.py +0 -0
  125. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_intoto.py +0 -0
  126. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_intoto_claims_hygiene.py +0 -0
  127. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_intoto_content_root_migration.py +0 -0
  128. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_intoto_dsse.py +0 -0
  129. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_intoto_eval_result.py +0 -0
  130. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_intoto_examples.py +0 -0
  131. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_intoto_spec_diff.py +0 -0
  132. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_intoto_svr.py +0 -0
  133. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_merkle.py +0 -0
  134. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_merkle_property.py +0 -0
  135. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_oversized_int_guard.py +0 -0
  136. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_persample.py +0 -0
  137. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_pinned_key_validation.py +0 -0
  138. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_policy_explain_lint.py +0 -0
  139. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_predicate_type_enforcement.py +0 -0
  140. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_prereg.py +0 -0
  141. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_promptfoo.py +0 -0
  142. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_provenance.py +0 -0
  143. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_pytest_plugin.py +0 -0
  144. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_rekor_interop.py +0 -0
  145. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_release_integrity_gate.py +0 -0
  146. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_rfc6962_external_vectors.py +0 -0
  147. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_schema.py +0 -0
  148. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_sdjwt_duplicate_cnf.py +0 -0
  149. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_sdjwt_issue.py +0 -0
  150. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_sdjwt_reference.py +0 -0
  151. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_signature.py +0 -0
  152. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_statuslist.py +0 -0
  153. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_tlogproof.py +0 -0
  154. {proofbundle-3.1.0 → proofbundle-3.1.2}/tests/test_verify_matrix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 3.1.0
3
+ Version: 3.1.2
4
4
  Summary: Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT.
5
5
  Author: Konrad Gruszka
6
6
  License: MIT
@@ -123,6 +123,13 @@ proofbundle verify receipt.json # exit 0 = crypto OK, 1 = crypto/verifica
123
123
 
124
124
  # apply YOUR trust decision — verify makes NO trust decision on its own:
125
125
  proofbundle verify receipt.json --policy trust_policy.json # POLICY: OK | FAIL (exit 3) | NOT_EVALUATED
126
+
127
+ # start from a shipped TEMPLATE and pin your own signer — offline, no network:
128
+ proofbundle policy instantiate strict-eval-template-v1 \
129
+ --issuer-key org-eval.pub --policy-id org/strict-eval-v1 --output org.json
130
+ proofbundle verify receipt.json --json --policy org.json --expected-root <b64>
131
+ # → root_authenticity.safeForAutomation: true only when the policy pins a trusted signer AND the
132
+ # root is authenticated (else false, with automationBlockers naming every reason)
126
133
  ```
127
134
 
128
135
  ## Inspect-native? (METR Task Standard, UK-AISI ecosystem)
@@ -69,6 +69,13 @@ proofbundle verify receipt.json # exit 0 = crypto OK, 1 = crypto/verifica
69
69
 
70
70
  # apply YOUR trust decision — verify makes NO trust decision on its own:
71
71
  proofbundle verify receipt.json --policy trust_policy.json # POLICY: OK | FAIL (exit 3) | NOT_EVALUATED
72
+
73
+ # start from a shipped TEMPLATE and pin your own signer — offline, no network:
74
+ proofbundle policy instantiate strict-eval-template-v1 \
75
+ --issuer-key org-eval.pub --policy-id org/strict-eval-v1 --output org.json
76
+ proofbundle verify receipt.json --json --policy org.json --expected-root <b64>
77
+ # → root_authenticity.safeForAutomation: true only when the policy pins a trusted signer AND the
78
+ # root is authenticated (else false, with automationBlockers naming every reason)
72
79
  ```
73
80
 
74
81
  ## Inspect-native? (METR Task Standard, UK-AISI ecosystem)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "3.1.0"
7
+ version = "3.1.2"
8
8
  description = "Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "3.1.0"
16
+ __version__ = "3.1.2"
17
17
 
18
18
  # The `proofbundle/v0.1` normative spec revision this build implements — kept in sync with the
19
19
  # `Revision:` line at the top of SPEC.md by tests/test_docs_truth.py (WP-B1, closes #28). Bump
@@ -33,7 +33,27 @@ from .signature import verify_ed25519
33
33
  from .sdjwt import verify_sd_jwt
34
34
 
35
35
  __all__ = ["SCHEMA", "verify_bundle", "load_bundle", "recompute_merkle_root_b64",
36
- "root_authenticity_summary"]
36
+ "root_authenticity_summary", "AUTOMATION_BLOCKER_REASONS"]
37
+
38
+ # AP-1 §5.3: the human-legible reason for each automationBlockers enum value. Kept HERE, next to the
39
+ # blocker logic in root_authenticity_summary, so the human `SAFE_FOR_AUTOMATION` line and the JSON flag
40
+ # can never drift apart (one source of truth — Iteration F). A blocker with no entry falls back to its
41
+ # raw enum string, so a future blocker is never silently unexplained.
42
+ AUTOMATION_BLOCKER_REASONS = {
43
+ "CRYPTO_FAILED": "Cryptographic verification did not pass",
44
+ "ROOT_NOT_AUTHENTICATED": "The Merkle root was not authenticated against a relying-party value "
45
+ "(--expected-root or a policy trusted_roots entry)",
46
+ "POLICY_NOT_EVALUATED": "No trust policy was evaluated (supply --policy to authorise a signer)",
47
+ "POLICY_FAILED": "The supplied trust policy was not satisfied",
48
+ "SIGNER_NOT_PINNED": "The trust policy pins no trusted signer identity (attributes to nobody)",
49
+ "TEMPLATE_NOT_INSTANTIATED": "The trust policy is a raw template (requiresIdentityOverlay:true) — "
50
+ "instantiate it with a signer identity before depending on it for automation",
51
+ "POLICY_EXPIRED": "The trust policy has expired (its valid_until is in the past)",
52
+ "POLICY_WARNINGS_PRESENT": "The trust policy carries a warning that blocks automation",
53
+ "ANCHOR_REQUIRED_FAILED": "A required external time anchor did not verify",
54
+ "PUBLIC_TRANSPARENCY_REQUIRED_FAILED": "A required public-transparency proof did not verify",
55
+ "REPLAY_BINDING_REQUIRED_FAILED": "A required replay/audience binding did not verify",
56
+ }
37
57
 
38
58
 
39
59
  def _issuer_requires_holder_binding(sd_part: str) -> bool:
@@ -70,6 +90,27 @@ _MERKLE_KEYS = {"hash_alg", "leaf_index", "tree_size", "inclusion_proof_b64", "r
70
90
  _SD_KEYS = {"compact", "issuer_public_key_b64"}
71
91
 
72
92
 
93
+ def _sd_jwt_carries_eval_root_commitment(sd_payload) -> bool:
94
+ """N1 (audit 2026-07-13; discriminator hardened after pre-land L1 review): True iff an SD-JWT issuer
95
+ payload carries the eval-binding ROOT COMMITMENT (``receipt.root_b64``, a non-empty base64 string)
96
+ that ``check_binds_bundle`` binds against — i.e. it CLAIMS to be anchored to a proofbundle merkle
97
+ root. That commitment is the exact cross-receipt substitution vector, so it (NOT a heuristic
98
+ word-match on generic keys like ``passed``/``suite``/``threshold``) is what marks an eval SD-JWT
99
+ grafted onto a non-eval payload. `issue_sd_jwt` always writes ``receipt.root_b64`` always-open, so a
100
+ genuine eval SD-JWT is caught even if its ``passed``/``threshold`` facts are moved into selective
101
+ disclosures; a generic SD-JWT-VC (``iss``/``vct``, no receipt commitment) is not an eval graft and
102
+ stays in scope (backward-compatible). Residual (documented, out of scope): a SELF-SIGNED credential
103
+ that hides even ``receipt.root_b64`` in a disclosure asserts no always-open anchoring claim and cannot
104
+ forge a trusted-issuer graft (the genuine emitter never does this)."""
105
+ if not isinstance(sd_payload, dict):
106
+ return False
107
+ receipt = sd_payload.get("receipt")
108
+ # Fire on the PRESENCE of a receipt.root_b64 string, INCLUDING "" (L1 pre-land audit F3): an empty root
109
+ # commits nothing concrete, but "an eval-shaped commitment present yet evading N1" should not exist. A
110
+ # generic SD-JWT-VC has no receipt object at all, so this never false-refuses one.
111
+ return isinstance(receipt, dict) and isinstance(receipt.get("root_b64"), str)
112
+
113
+
73
114
  def _b64d(value: str, field: str) -> bytes:
74
115
  try:
75
116
  return base64.b64decode(value, validate=True)
@@ -350,6 +391,25 @@ def verify_bundle(bundle: Union[dict, str], *, expected_aud=None, expected_nonce
350
391
  "sd_jwt_vc disclosures do NOT bind this bundle (reason: unbound/mismatch — cross-receipt "
351
392
  "substitution; the SD-JWT's passed/threshold/comparator/suite/issuer/root differ from the "
352
393
  "signed claim)")
394
+ elif _sd_jwt_carries_eval_root_commitment(_sd_p):
395
+ # N1 (audit 2026-07-13, L1 live PoC; discriminator hardened after pre-land L1 review): an
396
+ # EVAL SD-JWT (issue_sd_jwt writes the always-open root commitment receipt.root_b64) MUST
397
+ # bind to a proofbundle/eval-claim/v0.1 payload — check_binds_bundle above is the only
398
+ # binding for it. Grafted onto a NON-eval payload it has nothing to bind against, so
399
+ # previously the exact same issuer-valid eval SD-JWT verified CRYPTO: OK on ANY
400
+ # opaque-payload bundle (cross-receipt substitution, sd_jwt_ok stayed true, zero operator
401
+ # signal). Fail-closed (Reifegradpolitik §0.6: never a silent PASS for insecure legacy
402
+ # behaviour). Keying on receipt.root_b64 (the real substitution vector) rather than a
403
+ # word-match on generic keys catches a graft even when passed/threshold are moved into
404
+ # disclosures, and never false-refuses a GENERIC SD-JWT-VC (iss/vct, no receipt commitment,
405
+ # e.g. examples/example_bundle.json), which carries no eval anchoring claim and is out of
406
+ # scope (backward-compatible).
407
+ result.add(
408
+ "sd-jwt-bundle-binding", False,
409
+ "sd_jwt_vc carries an eval-claim root commitment (receipt.root_b64) but the signed "
410
+ "payload is not a proofbundle/eval-claim/v0.1 claim, so that anchoring claim cannot be "
411
+ "bound to this bundle (reason: unbindable eval SD-JWT — a valid issuer signature does not "
412
+ "make an unbound eval anchoring claim belong to this receipt; refused fail-closed)")
353
413
 
354
414
  # F4 (v1.9.2, fail-closed): supplying expected_aud/expected_nonce asks for RFC 9901 §7.3
355
415
  # replay/audience binding. A bundle with no verifiable KB-JWT (no sd_jwt_vc at all, or an
@@ -369,7 +429,13 @@ def verify_bundle(bundle: Union[dict, str], *, expected_aud=None, expected_nonce
369
429
  def root_authenticity_summary(result: VerificationResult, *,
370
430
  policy_authenticated_root: Optional[bool] = None,
371
431
  policy_ok: Optional[bool] = None,
372
- anchor_ok: Optional[bool] = None) -> dict:
432
+ anchor_ok: Optional[bool] = None,
433
+ signer_trusted: Optional[bool] = None,
434
+ policy_warnings: Optional[list] = None,
435
+ policy_expired: Optional[bool] = None,
436
+ requires_identity_overlay: Optional[bool] = None,
437
+ public_transparency_ok: Optional[bool] = None,
438
+ replay_ok: Optional[bool] = None) -> dict:
373
439
  """Structured root-authenticity verdicts (P0-A §6.3), derived from a completed VerificationResult.
374
440
 
375
441
  Separates what Merkle inclusion actually proves from what it does NOT, as three-state strings so a
@@ -381,14 +447,18 @@ def root_authenticity_summary(result: VerificationResult, *,
381
447
  relying-party value (``expected_root``, or a policy's ``trusted_roots``)?
382
448
  publicTransparency NOT_EVALUATED — a public-log receipt is the separate §10 profile
383
449
  safeForAutomation bool — True ONLY if the whole crypto verdict passed, the root was
384
- affirmatively authenticated, AND no supplied trust policy /
385
- anchor requirement FAILED (§6.3: root authenticity AND policy)
450
+ affirmatively authenticated, AND a supplied trust policy
451
+ PASSED with a real signer pin (P0-B, audit 2026-07-13)
452
+ automationBlockers list[str] — every reason safeForAutomation is false, so a consumer
453
+ keying off the flag sees exactly WHY (fail-closed)
386
454
 
387
455
  ``policy_authenticated_root`` folds the policy layer's root verdict in when no explicit
388
456
  ``root-authenticity`` check ran (e.g. the root matched a policy ``trusted_roots`` entry).
389
- ``policy_ok`` / ``anchor_ok`` are the relying-party gate verdicts (True/False/None=not-evaluated); a
390
- FAILED gate makes ``safeForAutomation`` false even when the root itself authenticated, so a consumer
391
- keying off this flag can never auto-trust a bundle its own policy rejected.
457
+ ``policy_ok`` / ``anchor_ok`` are the relying-party gate verdicts (True/False/None=not-evaluated).
458
+ P0-B: ``safeForAutomation`` is a GLOBAL trust verdict, so ``policy_ok`` must be True (a policy that
459
+ passed) ``None`` (no policy evaluated) can never make it true. ``policy_warnings`` (the vacuous
460
+ 'attributes to nobody' lint) forces it false too: a policy that pins no signer authorises no
461
+ identity, so a crypto-valid, root-pinned receipt under it is NOT automation-safe.
392
462
  """
393
463
  by = {c.name: c.ok for c in result.checks}
394
464
 
@@ -403,14 +473,56 @@ def root_authenticity_summary(result: VerificationResult, *,
403
473
  root_auth = "FAIL"
404
474
  else:
405
475
  root_auth = "NOT_EVALUATED"
406
- safe = (bool(result.ok) and root_auth == "PASS"
407
- and policy_ok is not False and anchor_ok is not False)
476
+ # P0-B (audit 2026-07-13): the former `policy_ok is not False` let policy_ok=None (no policy
477
+ # evaluated) through a crypto-valid, root-pinned receipt looked automation-safe though NO trusted
478
+ # signer was ever authorised. safeForAutomation is now a GLOBAL trust verdict: policy_ok must be True
479
+ # AND carry no vacuous 'attributes to nobody' warning. Every failed condition is surfaced in
480
+ # automationBlockers (fail-closed, so the flag is never a silent yes).
481
+ # P0-B / AP-1 §5 (audit 2026-07-13): safeForAutomation is a GLOBAL trust verdict. Variant A (strict):
482
+ # true ONLY when crypto passed, the root was authenticated, a supplied policy PASSED (policy_ok is
483
+ # True — None/no-policy never qualifies), that policy actually PINS a trusted signer (signer_trusted),
484
+ # and no required anchor / public-transparency / replay gate FAILED. automationBlockers enumerates
485
+ # every reason it is false (fail-closed, never a silent yes). NOTE for 3.1.1: public_transparency_ok
486
+ # and replay_ok are relying-party gates that are None (not-requested) on the current core — the §10
487
+ # public-transparency policy section is 3.2.0, and replay (aud/nonce) already fails the crypto verdict
488
+ # (CRYPTO_FAILED) when a required KB-JWT is absent — so these two blockers are defined for forward
489
+ # compatibility and stay dormant unless a future policy layer supplies a False verdict.
490
+ blockers: list[str] = []
491
+ if not bool(result.ok):
492
+ blockers.append("CRYPTO_FAILED")
493
+ if root_auth != "PASS":
494
+ blockers.append("ROOT_NOT_AUTHENTICATED")
495
+ if policy_ok is None:
496
+ blockers.append("POLICY_NOT_EVALUATED")
497
+ elif policy_ok is False:
498
+ blockers.append("POLICY_FAILED")
499
+ elif requires_identity_overlay:
500
+ # AP-2 §6.2 (L2 pre-land audit): a RAW template (requiresIdentityOverlay:true) is never automation-safe
501
+ # — reported as its OWN blocker, not SIGNER_NOT_PINNED, which would be factually wrong when the template
502
+ # actually does match a signer (the real reason is the un-cleared template-lifecycle flag).
503
+ blockers.append("TEMPLATE_NOT_INSTANTIATED")
504
+ elif signer_trusted is not True:
505
+ blockers.append("SIGNER_NOT_PINNED") # policy passed but pins no trusted identity (attributes to nobody)
506
+ elif policy_warnings:
507
+ blockers.append("POLICY_WARNINGS_PRESENT") # signer pinned yet the policy still warns (forward-compat)
508
+ # AP-2 §6.4 lifecycle: an EXPIRED policy is unsafe to automate on even if it otherwise passed (a stale
509
+ # signer pin the relying party has since rotated away from). Independent of the signer/warning chain so
510
+ # it is reported alongside, never in place of, another reason.
511
+ if policy_expired is True:
512
+ blockers.append("POLICY_EXPIRED")
513
+ if anchor_ok is False:
514
+ blockers.append("ANCHOR_REQUIRED_FAILED")
515
+ if public_transparency_ok is False:
516
+ blockers.append("PUBLIC_TRANSPARENCY_REQUIRED_FAILED")
517
+ if replay_ok is False:
518
+ blockers.append("REPLAY_BINDING_REQUIRED_FAILED")
408
519
  return {
409
520
  "payloadSignature": _tri("ed25519-signature"),
410
521
  "merkleConsistency": _tri("merkle-inclusion"),
411
522
  "rootAuthenticity": root_auth,
412
523
  "publicTransparency": "NOT_EVALUATED",
413
- "safeForAutomation": safe,
524
+ "safeForAutomation": not blockers,
525
+ "automationBlockers": blockers,
414
526
  }
415
527
 
416
528
 
@@ -549,18 +549,54 @@ def _cmd_verify(args: argparse.Namespace) -> int:
549
549
  # in when no --expected-root was given. Always separate, so merkle-inclusion is never read as root
550
550
  # authentication (additive JSON field + a human line).
551
551
  from .bundle import root_authenticity_summary # noqa: PLC0415
552
+ # P0-B (audit 2026-07-13): thread the vacuous-policy lint into the summary — a PASSING policy that
553
+ # pins no signer ('attributes to nobody') must NOT set safeForAutomation. Computed once here, reused
554
+ # for fields["policy_warnings"] below (no double lint).
555
+ from .policy import policy_warnings as _policy_warnings # noqa: PLC0415
556
+ pol_warns = _policy_warnings(policy) if (policy is not None and policy_ok) else []
557
+ # AP-1 §5 (+ L2 pre-land review fix): signer_trusted requires an ACTUALLY-PASSED signer-binding check
558
+ # on THIS (eval/verify) path — a passing `policy:signer_allowed` from evaluate_policy — NOT merely the
559
+ # absence of the 'attributes to nobody' warning. That warning counts
560
+ # decision_receipt.trusted_decision_makers as a signer pin, but evaluate_policy (the verify path) never
561
+ # matches the bundle signer against it (that is the verify-decision path); keying off the warning would
562
+ # let a decision-maker-only policy FAKE a trusted signer and yield safeForAutomation:true against an
563
+ # unpinned bundle signer (fail-open). Key off the positive verdict instead.
564
+ # AP-2 §6.2: a RAW template (requiresIdentityOverlay:true) never qualifies (no identity overlay applied).
565
+ _signer_checks = [c for c in (policy_result or {}).get("checks", [])
566
+ if c.get("name") == "policy:signer_allowed"]
567
+ signer_matched = bool(_signer_checks) and all(c.get("ok") for c in _signer_checks)
568
+ signer_trusted = bool(policy_ok) and signer_matched
569
+ # AP-2 §6.2/§6.4 (L2 pre-land audit): a RAW template (requiresIdentityOverlay:true) and an EXPIRED policy
570
+ # each force safeForAutomation:false with their OWN honest blocker (TEMPLATE_NOT_INSTANTIATED /
571
+ # POLICY_EXPIRED) — not a mislabelled SIGNER_NOT_PINNED. signer_trusted now reflects ONLY whether a signer
572
+ # was actually matched; the template/expiry conditions are surfaced as distinct blockers.
573
+ _requires_overlay = (policy or {}).get("requiresIdentityOverlay") is True
574
+ from .policy import policy_expired as _policy_expired # noqa: PLC0415
575
+ pol_expired = bool(policy is not None and _policy_expired(policy))
552
576
  root_summary = root_authenticity_summary(
553
577
  result, policy_authenticated_root=(policy_result or {}).get("root_authenticated"),
554
- policy_ok=policy_ok, anchor_ok=anchor_required_ok)
578
+ policy_ok=policy_ok, anchor_ok=anchor_required_ok,
579
+ signer_trusted=signer_trusted, policy_warnings=pol_warns, policy_expired=pol_expired,
580
+ requires_identity_overlay=_requires_overlay)
555
581
  fields["root_authenticity"] = root_summary
582
+ # P0-A (audit 2026-07-13): surface --expected-tree-size as a machine-readable object. The check
583
+ # itself already runs INDEPENDENTLY in verify_bundle (never gated on --expected-root), so a mismatch
584
+ # already fails the crypto verdict; this makes status/expected/actual explicit so an integrator can
585
+ # confirm the gate ran rather than inferring it from a check name. NOT_REQUESTED when the flag is absent.
586
+ _ets = getattr(args, "expected_tree_size", None)
587
+ _tsc = next((c for c in result.checks if c.name == "tree-size"), None)
588
+ fields["treeSizeExpectation"] = {
589
+ "status": (("PASS" if _tsc.ok else "FAIL") if _tsc is not None else "NOT_REQUESTED"),
590
+ "expected": _ets,
591
+ "actual": (bundle.get("merkle") or {}).get("tree_size") if isinstance(bundle, dict) else None,
592
+ }
556
593
  if policy is not None:
557
594
  fields["policy_id"] = policy.get("policy_id")
558
595
  # WP-TP1: non-fatal honesty warnings (e.g. "attributes to nobody") — exit code unchanged.
559
596
  # Mirror the human line (six-lens review): the warning is meaningful only for a PASSING
560
597
  # policy (a POLICY: OK that attributes to nobody); on FAIL / crypto-FAIL the FAIL is the
561
598
  # message, so the JSON field is empty too — the key stays present for contract stability.
562
- from .policy import policy_warnings # noqa: PLC0415
563
- fields["policy_warnings"] = policy_warnings(policy) if policy_ok else []
599
+ fields["policy_warnings"] = pol_warns # P0-B: computed once above (no double lint)
564
600
  if policy_result is not None:
565
601
  fields["policy_checks"] = policy_result["checks"]
566
602
  if anchor_report is not None:
@@ -614,6 +650,17 @@ def _cmd_verify(args: argparse.Namespace) -> int:
614
650
  f"(payload-signature {root_summary['payloadSignature']}, "
615
651
  f"merkle-consistency {root_summary['merkleConsistency']}, "
616
652
  f"safe-for-automation {str(root_summary['safeForAutomation']).lower()})")
653
+ # AP-1 §5.3: a dedicated, human-legible automation verdict — YES, or NO with the reason(s). The
654
+ # reasons are derived one-for-one from root_summary['automationBlockers'] via the SSOT map, so the
655
+ # human and JSON forms of safeForAutomation can never disagree (Iteration F).
656
+ from .bundle import AUTOMATION_BLOCKER_REASONS # noqa: PLC0415
657
+ if root_summary["safeForAutomation"]:
658
+ print("SAFE_FOR_AUTOMATION: YES")
659
+ else:
660
+ print("SAFE_FOR_AUTOMATION: NO")
661
+ print("Reason:")
662
+ for _blk in root_summary["automationBlockers"]:
663
+ print(f" {AUTOMATION_BLOCKER_REASONS.get(_blk, _blk)}")
617
664
  if policy is not None and not crypto_ok:
618
665
  print("POLICY: NOT_EVALUATED (crypto failed — policy not checked)")
619
666
  else:
@@ -963,8 +1010,11 @@ def _cmd_decision_verify(args: argparse.Namespace) -> int:
963
1010
  policy = None
964
1011
  if args.policy:
965
1012
  from .policy import PolicyError, load_policy # noqa: PLC0415
1013
+ from .policy_profiles import resolve_policy_source # noqa: PLC0415
966
1014
  try:
967
- policy = load_policy(args.policy)
1015
+ # parity with the eval verify path (L5 audit L5-F3): accept a packaged profile NAME
1016
+ # (e.g. decision-receipt-template-v1), not only a file path, via resolve_policy_source.
1017
+ policy = load_policy(resolve_policy_source(args.policy))
968
1018
  except PolicyError as exc:
969
1019
  print(f"ERROR: {exc}", file=sys.stderr)
970
1020
  return 2
@@ -1151,17 +1201,91 @@ def _cmd_policy_lint(args: argparse.Namespace) -> int:
1151
1201
 
1152
1202
  def _cmd_policy_list_profiles(args: argparse.Namespace) -> int:
1153
1203
  from .policy import explain_policy, load_policy # noqa: PLC0415
1154
- from .policy_profiles import list_profiles, profile_path # noqa: PLC0415
1204
+ from .policy_profiles import list_profiles, profile_aliases, profile_path # noqa: PLC0415
1205
+ aliases = profile_aliases()
1206
+ # canonical -> [deprecated old names] so each canonical row can list what still resolves to it.
1207
+ canonical_aliases: dict = {}
1208
+ for old, canonical in aliases.items():
1209
+ canonical_aliases.setdefault(canonical, []).append(old)
1155
1210
  rows = []
1156
- for name in list_profiles():
1211
+ for name in list_profiles(): # AP-2 §6.1: canonical names FIRST
1212
+ # load via the packaged file directly (no alias, no deprecation line) for the metadata
1157
1213
  policy = load_policy(profile_path(name))
1158
1214
  rows.append({"name": name, "policy_id": policy.get("policy_id"),
1159
- "schema": policy.get("schema"), "pin_count": len(explain_policy(policy))})
1215
+ "schema": policy.get("schema"), "pin_count": len(explain_policy(policy)),
1216
+ "deploymentReady": policy.get("deploymentReady"),
1217
+ "requiresIdentityOverlay": policy.get("requiresIdentityOverlay"),
1218
+ "is_template": policy.get("requiresIdentityOverlay") is True
1219
+ or policy.get("deploymentReady") is False,
1220
+ "deprecated_aliases": sorted(canonical_aliases.get(name, []))})
1160
1221
  if args.json:
1161
1222
  print(json.dumps(rows, indent=2, ensure_ascii=False))
1162
1223
  return 0
1163
1224
  for row in rows:
1164
- print(f"{row['name']:22} {row['schema']:32} {row['pin_count']} pin(s) {row['policy_id']}")
1225
+ kind = "template" if row["is_template"] else "profile "
1226
+ print(f"{row['name']:44} {kind} {row['schema']:32} {row['pin_count']} pin(s) {row['policy_id']}")
1227
+ if row["deprecated_aliases"]:
1228
+ print(f"{'':44} (deprecated aliases: {', '.join(row['deprecated_aliases'])})")
1229
+ return 0
1230
+
1231
+
1232
+ def _read_pubkey_line(text: str) -> str:
1233
+ """Extract a base64 Ed25519 public key from a key file's content: accepts a bare base64 string, an
1234
+ ``ed25519:<b64>`` issuer string, or a JSON object with ``public_key_b64`` / ``issuer_public_key_b64``.
1235
+ Returns the base64 string (empty if none found — the caller fail-closes on that)."""
1236
+ s = text.strip()
1237
+ if s.startswith("{"):
1238
+ try:
1239
+ obj = json.loads(s)
1240
+ except ValueError:
1241
+ return ""
1242
+ return obj.get("public_key_b64") or obj.get("issuer_public_key_b64") or ""
1243
+ if s.startswith("ed25519:"):
1244
+ return s[len("ed25519:"):].strip()
1245
+ return s
1246
+
1247
+
1248
+ def _cmd_policy_instantiate(args: argparse.Namespace) -> int:
1249
+ """AP-2 §6.3: turn a shipped template into a deployment-ready org policy, offline. Reads the issuer
1250
+ key file(s) and optional expected-root file, pins them, and writes the instantiated policy JSON."""
1251
+ from .policy import PolicyError # noqa: PLC0415
1252
+ from .policy_profiles import instantiate_template # noqa: PLC0415
1253
+ try:
1254
+ keys = []
1255
+ for kf in args.issuer_key:
1256
+ with open(kf, encoding="utf-8") as fh:
1257
+ key = _read_pubkey_line(fh.read())
1258
+ if not key:
1259
+ raise PolicyError(f"issuer key file {kf!r} carries no public key")
1260
+ keys.append(key)
1261
+ expected_root = None
1262
+ if args.expected_root_file:
1263
+ with open(args.expected_root_file, encoding="utf-8") as fh:
1264
+ expected_root = fh.read().strip()
1265
+ inst = instantiate_template(args.template, issuer_keys=keys, policy_id=args.policy_id,
1266
+ expected_root=expected_root, valid_until=args.valid_until)
1267
+ except (PolicyError, OSError, ValueError) as exc:
1268
+ if getattr(args, "json", False):
1269
+ print(json.dumps({"ok": False, "error": str(exc)}))
1270
+ else:
1271
+ print(f"ERROR: {exc}", file=sys.stderr)
1272
+ return 2
1273
+ out = json.dumps(inst, indent=2, ensure_ascii=False)
1274
+ if not inst.get("deploymentReady"):
1275
+ # honest, loud: the instance is missing a required field (e.g. an authenticated-root template
1276
+ # instantiated without --expected-root-file). It is written so it can be completed, but it is NOT
1277
+ # production-ready and `policy lint --strict` will refuse it (No-Fake).
1278
+ print("[policy-instantiate] WARNING: result is deploymentReady=false — a required field is "
1279
+ "missing (this template needs --expected-root-file); complete it before deploying.",
1280
+ file=sys.stderr)
1281
+ if args.output and args.output != "-":
1282
+ with open(args.output, "w", encoding="utf-8") as fh:
1283
+ fh.write(out + "\n")
1284
+ print(f"[policy-instantiate] {args.template} -> {args.output} "
1285
+ f"deploymentReady={inst.get('deploymentReady')} policy_id={inst.get('policy_id')}",
1286
+ file=sys.stderr)
1287
+ else:
1288
+ print(out)
1165
1289
  return 0
1166
1290
 
1167
1291
 
@@ -1382,9 +1506,39 @@ def build_parser() -> argparse.ArgumentParser:
1382
1506
  p_lint.add_argument("--json", action="store_true", help="machine readable output")
1383
1507
  p_lint.set_defaults(func=_cmd_policy_lint)
1384
1508
  p_list = psub.add_parser(
1385
- "list-profiles", help="list the named trust-policy profiles shipped with this package (WP3)")
1509
+ "list-profiles", help="list the named trust-policy profiles shipped with this package (WP3); "
1510
+ "canonical template names first, deprecated aliases marked")
1386
1511
  p_list.add_argument("--json", action="store_true", help="machine readable output")
1387
1512
  p_list.set_defaults(func=_cmd_policy_list_profiles)
1513
+ p_inst = psub.add_parser(
1514
+ "instantiate", help="AP-2 §6.3: turn a shipped TEMPLATE into a deployment-ready org policy by "
1515
+ "pinning your signer identity (and root, when required); offline",
1516
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1517
+ epilog="Example:\n"
1518
+ " proofbundle policy instantiate strict-eval-template-v1 \\\n"
1519
+ " --issuer-key org-eval.pub --policy-id org/strict-eval-v1 \\\n"
1520
+ " --output org-strict-eval-v1.json\n"
1521
+ "The result pins the issuer key(s), sets requiresIdentityOverlay=false, and is\n"
1522
+ "deploymentReady=true only when every required field is filled (an authenticated-root\n"
1523
+ "template also needs --expected-root-file). Exit 0 on success, 2 on any error.")
1524
+ p_inst.add_argument("template", help="a template profile name, e.g. strict-eval-template-v1 "
1525
+ "(deprecated aliases resolve with a warning)")
1526
+ p_inst.add_argument("--issuer-key", action="append", required=True, metavar="FILE",
1527
+ help="file with a base64 Ed25519 public key to pin (repeat to pin several)")
1528
+ p_inst.add_argument("--policy-id", required=True,
1529
+ help="the new policy_id in YOUR organisation namespace (must differ from the "
1530
+ "template's)")
1531
+ p_inst.add_argument("--expected-root-file", metavar="FILE",
1532
+ help="file with a base64 merkle root to pin as trusted_roots — REQUIRED for a "
1533
+ "template that sets merkle.require_authenticated_root, optional otherwise")
1534
+ p_inst.add_argument("--valid-until", metavar="ISO8601",
1535
+ help="optional expiry stamped onto the instance (e.g. 2027-01-01T00:00:00Z); "
1536
+ "policy lint --strict fails once it is in the past")
1537
+ p_inst.add_argument("--output", "-o", metavar="FILE",
1538
+ help="write the instantiated policy JSON here (default: stdout)")
1539
+ p_inst.add_argument("--json", action="store_true",
1540
+ help="on error, emit a JSON error object instead of a stderr line")
1541
+ p_inst.set_defaults(func=_cmd_policy_instantiate)
1388
1542
 
1389
1543
  prereg = sub.add_parser(
1390
1544
  "prereg",
@@ -429,16 +429,27 @@ def verify_decision_receipt(envelope: dict, public_key: bytes, *, strict: bool =
429
429
  # confirmation is a separate step (resolve_evidence_ref), never implied by evidence_bound.
430
430
  if isinstance(ev, list) and ev:
431
431
  r["evidence_bound"] = all(isinstance(x, dict) and _is_digest(x.get("digest")) for x in ev)
432
- val = predicate.get("validity")
433
- if isinstance(val, dict):
434
- if expected_audience is not None:
435
- r["audience_ok"] = expected_audience in (val.get("audience") or [])
436
- if not r["audience_ok"]:
437
- r["errors"].append("audience mismatch (cross-audience replay?)")
438
- if expected_nonce is not None:
439
- r["nonce_ok"] = val.get("nonce") == expected_nonce
440
- if not r["nonce_ok"]:
441
- r["errors"].append("nonce mismatch (replay?)")
432
+ # 3.1.2 fail-closed fix (audit 2026-07-13, sibling of the eval-path F4 hardening and the decision
433
+ # template/expiry gates): a relying party who supplies expected_audience/expected_nonce is ASKING for
434
+ # RFC-9901-§7.3-style replay/audience binding. If the receipt carries NO validity object (or a
435
+ # non-dict one), the previous `if isinstance(val, dict):` guard SKIPPED the checks entirely →
436
+ # audience_ok/nonce_ok stayed None → the CLI exit gate (None is not False) let it pass exit 0: a
437
+ # requested binding silently unenforced (fail-OPEN downgrade). Evaluate against {} instead, so an
438
+ # absent validity/audience/nonce is a FAIL (fail-closed), never a silent pass.
439
+ _val = predicate.get("validity")
440
+ _validity = _val if isinstance(_val, dict) else {}
441
+ if expected_audience is not None:
442
+ r["audience_ok"] = expected_audience in (_validity.get("audience") or [])
443
+ if not r["audience_ok"]:
444
+ r["errors"].append(
445
+ "audience mismatch or absent validity.audience — requested audience binding cannot be "
446
+ "enforced (cross-audience replay?, fail-closed)")
447
+ if expected_nonce is not None:
448
+ r["nonce_ok"] = _validity.get("nonce") == expected_nonce
449
+ if not r["nonce_ok"]:
450
+ r["errors"].append(
451
+ "nonce mismatch or absent validity.nonce — requested replay binding cannot be enforced "
452
+ "(replay?, fail-closed)")
442
453
 
443
454
  # Detached anchors (Fix 2): verify anchor evidence for the statement's OWN content root — sha256 over the
444
455
  # EXACT signed payload bytes (never re-canonicalized), computed only once the bytes are authentic+canonical.
@@ -0,0 +1,24 @@
1
+ {
2
+ "schema": "proofbundle/trust-policy/v0.2",
3
+ "policy_id": "proofbundle-policy/decision-receipt-template-v1",
4
+ "deploymentReady": false,
5
+ "requiresIdentityOverlay": true,
6
+ "allowed_schema_versions": [
7
+ "proofbundle/v0.1"
8
+ ],
9
+ "signature": {
10
+ "allowed_algs": [
11
+ "ed25519"
12
+ ]
13
+ },
14
+ "decision_receipt": {
15
+ "accepted_predicate_types": [
16
+ "https://b7n0de.com/proofbundle/predicates/decision-receipt/v0.1"
17
+ ],
18
+ "require_not_checked": true,
19
+ "require_decision_change_conditions": true,
20
+ "require_audience": true,
21
+ "require_nonce": true,
22
+ "allow_raw_inputs": false
23
+ }
24
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "schema": "proofbundle/trust-policy/v0.1",
3
+ "policy_id": "proofbundle-policy/strict-eval-authenticated-root-template-v1",
4
+ "deploymentReady": false,
5
+ "requiresIdentityOverlay": true,
6
+ "allowed_schema_versions": [
7
+ "proofbundle/v0.1"
8
+ ],
9
+ "signature": {
10
+ "allowed_algs": [
11
+ "ed25519"
12
+ ]
13
+ },
14
+ "merkle": {
15
+ "required_hash_alg": "sha256-rfc6962",
16
+ "require_authenticated_root": true
17
+ },
18
+ "sd_jwt": {
19
+ "require_key_binding_when_cnf_present": true
20
+ },
21
+ "assurance": {
22
+ "minimum_level": "reproduced",
23
+ "reject_self_attested_without_prereg": true
24
+ }
25
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "schema": "proofbundle/trust-policy/v0.1",
3
+ "policy_id": "proofbundle-policy/strict-eval-template-v1",
4
+ "deploymentReady": false,
5
+ "requiresIdentityOverlay": true,
6
+ "allowed_schema_versions": [
7
+ "proofbundle/v0.1"
8
+ ],
9
+ "signature": {
10
+ "allowed_algs": [
11
+ "ed25519"
12
+ ]
13
+ },
14
+ "merkle": {
15
+ "required_hash_alg": "sha256-rfc6962"
16
+ },
17
+ "sd_jwt": {
18
+ "require_key_binding_when_cnf_present": true
19
+ },
20
+ "assurance": {
21
+ "minimum_level": "reproduced",
22
+ "reject_self_attested_without_prereg": true
23
+ }
24
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "schema": "proofbundle/trust-policy/v0.2",
3
+ "policy_id": "proofbundle-policy/strict-prereg-template-v1",
4
+ "deploymentReady": false,
5
+ "requiresIdentityOverlay": true,
6
+ "allowed_schema_versions": [
7
+ "proofbundle/v0.1"
8
+ ],
9
+ "signature": {
10
+ "allowed_algs": [
11
+ "ed25519"
12
+ ]
13
+ },
14
+ "merkle": {
15
+ "required_hash_alg": "sha256-rfc6962"
16
+ },
17
+ "assurance": {
18
+ "reject_self_attested_without_prereg": true
19
+ },
20
+ "anchors": {
21
+ "require_anchor": "any",
22
+ "require_anchor_target": "preRegistration",
23
+ "allow_pending": false
24
+ }
25
+ }