proofbundle 1.3.0__tar.gz → 1.4.0__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 (70) hide show
  1. {proofbundle-1.3.0/src/proofbundle.egg-info → proofbundle-1.4.0}/PKG-INFO +33 -19
  2. {proofbundle-1.3.0 → proofbundle-1.4.0}/README.md +29 -14
  3. {proofbundle-1.3.0 → proofbundle-1.4.0}/pyproject.toml +6 -7
  4. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/__init__.py +6 -1
  5. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/adapters/__init__.py +3 -1
  6. proofbundle-1.4.0/src/proofbundle/adapters/promptfoo.py +131 -0
  7. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/cli.py +31 -0
  8. proofbundle-1.4.0/src/proofbundle/hf_evals.py +185 -0
  9. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/sdjwt.py +5 -0
  10. {proofbundle-1.3.0 → proofbundle-1.4.0/src/proofbundle.egg-info}/PKG-INFO +33 -19
  11. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle.egg-info/SOURCES.txt +4 -0
  12. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle.egg-info/requires.txt +0 -4
  13. proofbundle-1.4.0/tests/test_hf_evals.py +152 -0
  14. proofbundle-1.4.0/tests/test_promptfoo.py +177 -0
  15. {proofbundle-1.3.0 → proofbundle-1.4.0}/LICENSE +0 -0
  16. {proofbundle-1.3.0 → proofbundle-1.4.0}/setup.cfg +0 -0
  17. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/_inspect_registry.py +0 -0
  18. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/_integration.py +0 -0
  19. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/adapters/eee.py +0 -0
  20. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/adapters/inspect_ai.py +0 -0
  21. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/adapters/lm_eval.py +0 -0
  22. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/bundle.py +0 -0
  23. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/checkpoint.py +0 -0
  24. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/dsse.py +0 -0
  25. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/eee_eval_schema.json +0 -0
  26. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/emit.py +0 -0
  27. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/errors.py +0 -0
  28. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/evalclaim.py +0 -0
  29. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/inspect_hook.py +0 -0
  30. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/intoto.py +0 -0
  31. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/kbjwt.py +0 -0
  32. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/merkle.py +0 -0
  33. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/py.typed +0 -0
  34. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/pytest_plugin.py +0 -0
  35. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/sdjwt_issue.py +0 -0
  36. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/signature.py +0 -0
  37. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/statuslist.py +0 -0
  38. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle/tlogproof.py +0 -0
  39. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle.egg-info/dependency_links.txt +0 -0
  40. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle.egg-info/entry_points.txt +0 -0
  41. {proofbundle-1.3.0 → proofbundle-1.4.0}/src/proofbundle.egg-info/top_level.txt +0 -0
  42. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_adapters.py +0 -0
  43. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_adversarial.py +0 -0
  44. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_bundle.py +0 -0
  45. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_bundle_robustness.py +0 -0
  46. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_checkpoint.py +0 -0
  47. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_cli.py +0 -0
  48. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_cli_eval.py +0 -0
  49. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_cosignature.py +0 -0
  50. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_cosignature_mldsa.py +0 -0
  51. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_eee.py +0 -0
  52. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_emit.py +0 -0
  53. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_eval_claim_schema.py +0 -0
  54. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_evalclaim.py +0 -0
  55. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_examples.py +0 -0
  56. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_inspect_hook.py +0 -0
  57. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_intoto.py +0 -0
  58. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_intoto_dsse.py +0 -0
  59. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_kbjwt.py +0 -0
  60. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_merkle.py +0 -0
  61. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_merkle_property.py +0 -0
  62. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_pytest_plugin.py +0 -0
  63. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_rekor_interop.py +0 -0
  64. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_rfc6962_external_vectors.py +0 -0
  65. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_schema.py +0 -0
  66. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_sdjwt_issue.py +0 -0
  67. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_sdjwt_reference.py +0 -0
  68. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_signature.py +0 -0
  69. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_statuslist.py +0 -0
  70. {proofbundle-1.3.0 → proofbundle-1.4.0}/tests/test_tlogproof.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proofbundle
3
- Version: 1.3.0
3
+ Version: 1.4.0
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
@@ -14,14 +14,13 @@ Classifier: Development Status :: 3 - Alpha
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.9
18
17
  Classifier: Programming Language :: Python :: 3.10
19
18
  Classifier: Programming Language :: Python :: 3.11
20
19
  Classifier: Programming Language :: Python :: 3.12
21
20
  Classifier: Programming Language :: Python :: 3.13
22
21
  Classifier: Programming Language :: Python :: 3.14
23
22
  Classifier: Topic :: Security :: Cryptography
24
- Requires-Python: >=3.9
23
+ Requires-Python: >=3.10
25
24
  Description-Content-Type: text/markdown
26
25
  License-File: LICENSE
27
26
  Requires-Dist: cryptography>=42
@@ -34,7 +33,7 @@ Requires-Dist: cryptography>=48; extra == "pq"
34
33
  Provides-Extra: pytest
35
34
  Requires-Dist: pytest>=7; extra == "pytest"
36
35
  Provides-Extra: inspect
37
- Requires-Dist: inspect_ai<0.4,>=0.3.112; python_version >= "3.10" and extra == "inspect"
36
+ Requires-Dist: inspect_ai<0.4,>=0.3.112; extra == "inspect"
38
37
  Provides-Extra: dev
39
38
  Requires-Dist: pytest>=7; extra == "dev"
40
39
  Requires-Dist: ruff>=0.5; extra == "dev"
@@ -44,7 +43,7 @@ Requires-Dist: build>=1; extra == "dev"
44
43
  Requires-Dist: hypothesis>=6; extra == "dev"
45
44
  Requires-Dist: rfc8785>=0.1.4; extra == "dev"
46
45
  Requires-Dist: sd-jwt>=0.10; extra == "dev"
47
- Requires-Dist: inspect_ai<0.4,>=0.3.112; python_version >= "3.10" and extra == "dev"
46
+ Requires-Dist: inspect_ai<0.4,>=0.3.112; extra == "dev"
48
47
  Dynamic: license-file
49
48
 
50
49
  <div align="center">
@@ -82,7 +81,7 @@ file, no server, no network.**
82
81
 
83
82
  **At a glance:** `proofbundle emit` signs and anchors a payload; `proofbundle
84
83
  verify` checks one self-contained `bundle.json` with three offline cryptographic
85
- checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 188 tests + a CI mutation gate.
84
+ checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 209 tests + a CI mutation gate.
86
85
 
87
86
  ## Contents
88
87
 
@@ -159,12 +158,19 @@ A bundle is a single JSON document. `proofbundle` checks, offline:
159
158
  Certificate Transparency)
160
159
  3. **sd-jwt** (optional) — an embedded SD-JWT selective-disclosure credential is
161
160
  well formed, and if an issuer key is given, correctly issuer-signed
162
- 4. **sd-jwt-key-binding** (optional, v1.2) — if the SD-JWT carries a Key Binding
163
- JWT (RFC 9901 §4.3), it is verified **fail-closed**: `typ` is `kb+jwt`,
164
- `iat`/`aud`/`nonce`/`sd_hash` are present, `sd_hash` binds the exact presented
165
- disclosure set, and the signature verifies under the issuer-bound `cnf.jwk`
166
- holder key. A present-but-broken KB-JWT fails the bundle it is never
167
- silently ignored.
161
+ 4. **sd-jwt-key-binding** (optional, v1.2; hardened v1.3) — proof-of-possession, **fail-closed**.
162
+ The check runs only once the issuer signature itself verified (an unauthenticated SD-JWT can
163
+ never report a valid holder binding). Then: `typ` is `kb+jwt`, `iat`/`aud`/`nonce`/`sd_hash` are
164
+ present, `sd_hash` binds the exact presented disclosure set, and the signature verifies under the
165
+ issuer-bound `cnf.jwk` holder key. A present-but-broken KB-JWT fails the bundle; and if the issuer
166
+ **bound** a `cnf` holder key but the presentation carries **no** KB-JWT, that fails too — stripping
167
+ the binding is a bearer downgrade, not a valid receipt. `verify --aud/--nonce` enforce RFC 9901
168
+ §7.3 audience/replay binding when the relying party supplies them.
169
+
170
+ Beyond the single-file bundle, the library also verifies, all offline: a **witnessed C2SP checkpoint**
171
+ (`verify_witnessed_checkpoint`, Ed25519 + post-quantum ML-DSA-44 cosignatures — the quorum counts distinct
172
+ witness **keys**, not names, so one key under many names can never stuff a threshold), a **C2SP
173
+ `.tlog-proof`** (`proofbundle verify-proof`), and a **Token Status List** revocation snapshot.
168
174
 
169
175
  The verifier treats the payload as opaque bytes. It proves that these exact
170
176
  bytes were signed and anchored, not what they mean. That is on purpose: it keeps
@@ -203,7 +209,7 @@ flowchart LR
203
209
  pip install proofbundle
204
210
  ```
205
211
 
206
- Requires Python 3.9+ and [`cryptography`](https://cryptography.io). Signature
212
+ Requires Python 3.10+ and [`cryptography`](https://cryptography.io). Signature
207
213
  math is delegated to `cryptography`; this project never rolls its own crypto.
208
214
  The Merkle and SD-JWT logic is pure standard library.
209
215
 
@@ -480,7 +486,7 @@ threshold was met without revealing the model or the data. See [INTEROP.md](INTE
480
486
  [lm-evaluation-harness](https://github.com/EleutherAI/lm-evaluation-harness) `results_*.json` (the
481
487
  genuine `acc,none` filter-suffix format). **`proofbundle.adapters.from_eee_dataset`** (v0.9) reads an
482
488
  Every Eval Ever v0.2.2 aggregate JSON and builds a signed receipt — validated against the vendored EEE
483
- schema, with **no runtime import** of `every_eval_ever` (it needs Python 3.12; proofbundle stays 3.9+).
489
+ schema, with **no runtime import** of `every_eval_ever` (it needs Python 3.12; proofbundle stays 3.10+).
484
490
  - **in-toto test-result export, DSSE-signed** (v0.9) — `proofbundle.intoto.export_intoto_dsse(claim,
485
491
  signer)` emits the receipt as a DSSE-signed in-toto Statement v1 with the **generic
486
492
  `test-result/v0.1` predicate** (result PASSED/FAILED, `configuration` ResourceDescriptors), so a generic
@@ -540,15 +546,23 @@ ISO/IEC DIS 24970), including the anti-patterns no one should claim.
540
546
  (RFC 9901 §4.3, fail-closed; closes #1) with `cnf.jwk` issuance and holder presentation, **C2SP
541
547
  tlog-cosignature** verification (Ed25519 cosignature/v1, witness quorum, split-view resistance), and
542
548
  `verify --verbose` with the recomputed Merkle root (closes #2).
543
- - **v1.3 (current release)** — the portable proof: **C2SP tlog-proof** emit/verify (+
549
+ - **v1.3** — the portable proof: **C2SP tlog-proof** emit/verify (+
544
550
  `verify-proof` CLI), **ML-DSA-44** witness cosignatures (post-quantum, `[pq]` extra), **Token
545
551
  Status List** snapshot verification (offline revocation), SD-JWT VC markers (`dc+sd-jwt`,
546
552
  `vct`, `status`), COMPLIANCE.md (EU AI Act Art. 12 / NIST AI RMF mapping), and the mutation
547
553
  suite as a CI gate.
548
- - **Deferred** (explicitly not yet built) — full SD-JWT VC conformance + `vct` type-metadata
549
- resolution (pre-IESG), per-sample Merkle receipts (the THREAT_MODEL's named gap; v2.0
550
- direction), an official in-toto eval predicate (proposal path via OpenSSF/CoSAI), a full
551
- in-toto client, a Python-3.10 floor.
554
+ - **v1.4 (current release)**distribution: a **promptfoo** adapter (results.json v3
555
+ pass_rate receipt), the **Hugging Face Community Evals bridge** (`pb1.` receipt tokens for the
556
+ `.eval_results` `verifyToken` field + a strict YAML entry emitter — proofbundle-verifiable,
557
+ explicitly NOT the HF-internal badge token), `hf-token` CLI, and the **Python 3.10+ floor**
558
+ (3.9 is EOL since 2025-10).
559
+ - **v2.0 (next major, research differentiation)** — **per-sample Merkle receipts**: the payload carries a
560
+ Merkle root over *individual* sample results, so a verifier can force a random-sample re-check instead of
561
+ trusting only the reported aggregate. This is the THREAT_MODEL's one named structural gap ("forced random
562
+ sub-sampling"), and closing it is the road from *authorship* to *sampled correctness*.
563
+ - **Deferred** (explicitly not yet built, stated honestly) — full SD-JWT VC conformance + `vct`
564
+ type-metadata resolution (pre-IESG), an official in-toto eval predicate (proposal path via
565
+ OpenSSF/CoSAI), and a full in-toto client.
552
566
 
553
567
  ## Contributing
554
568
 
@@ -33,7 +33,7 @@ file, no server, no network.**
33
33
 
34
34
  **At a glance:** `proofbundle emit` signs and anchors a payload; `proofbundle
35
35
  verify` checks one self-contained `bundle.json` with three offline cryptographic
36
- checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 188 tests + a CI mutation gate.
36
+ checks → `OK` or `FAILED`. No network, no daemon, no own crypto. 209 tests + a CI mutation gate.
37
37
 
38
38
  ## Contents
39
39
 
@@ -110,12 +110,19 @@ A bundle is a single JSON document. `proofbundle` checks, offline:
110
110
  Certificate Transparency)
111
111
  3. **sd-jwt** (optional) — an embedded SD-JWT selective-disclosure credential is
112
112
  well formed, and if an issuer key is given, correctly issuer-signed
113
- 4. **sd-jwt-key-binding** (optional, v1.2) — if the SD-JWT carries a Key Binding
114
- JWT (RFC 9901 §4.3), it is verified **fail-closed**: `typ` is `kb+jwt`,
115
- `iat`/`aud`/`nonce`/`sd_hash` are present, `sd_hash` binds the exact presented
116
- disclosure set, and the signature verifies under the issuer-bound `cnf.jwk`
117
- holder key. A present-but-broken KB-JWT fails the bundle it is never
118
- silently ignored.
113
+ 4. **sd-jwt-key-binding** (optional, v1.2; hardened v1.3) — proof-of-possession, **fail-closed**.
114
+ The check runs only once the issuer signature itself verified (an unauthenticated SD-JWT can
115
+ never report a valid holder binding). Then: `typ` is `kb+jwt`, `iat`/`aud`/`nonce`/`sd_hash` are
116
+ present, `sd_hash` binds the exact presented disclosure set, and the signature verifies under the
117
+ issuer-bound `cnf.jwk` holder key. A present-but-broken KB-JWT fails the bundle; and if the issuer
118
+ **bound** a `cnf` holder key but the presentation carries **no** KB-JWT, that fails too — stripping
119
+ the binding is a bearer downgrade, not a valid receipt. `verify --aud/--nonce` enforce RFC 9901
120
+ §7.3 audience/replay binding when the relying party supplies them.
121
+
122
+ Beyond the single-file bundle, the library also verifies, all offline: a **witnessed C2SP checkpoint**
123
+ (`verify_witnessed_checkpoint`, Ed25519 + post-quantum ML-DSA-44 cosignatures — the quorum counts distinct
124
+ witness **keys**, not names, so one key under many names can never stuff a threshold), a **C2SP
125
+ `.tlog-proof`** (`proofbundle verify-proof`), and a **Token Status List** revocation snapshot.
119
126
 
120
127
  The verifier treats the payload as opaque bytes. It proves that these exact
121
128
  bytes were signed and anchored, not what they mean. That is on purpose: it keeps
@@ -154,7 +161,7 @@ flowchart LR
154
161
  pip install proofbundle
155
162
  ```
156
163
 
157
- Requires Python 3.9+ and [`cryptography`](https://cryptography.io). Signature
164
+ Requires Python 3.10+ and [`cryptography`](https://cryptography.io). Signature
158
165
  math is delegated to `cryptography`; this project never rolls its own crypto.
159
166
  The Merkle and SD-JWT logic is pure standard library.
160
167
 
@@ -431,7 +438,7 @@ threshold was met without revealing the model or the data. See [INTEROP.md](INTE
431
438
  [lm-evaluation-harness](https://github.com/EleutherAI/lm-evaluation-harness) `results_*.json` (the
432
439
  genuine `acc,none` filter-suffix format). **`proofbundle.adapters.from_eee_dataset`** (v0.9) reads an
433
440
  Every Eval Ever v0.2.2 aggregate JSON and builds a signed receipt — validated against the vendored EEE
434
- schema, with **no runtime import** of `every_eval_ever` (it needs Python 3.12; proofbundle stays 3.9+).
441
+ schema, with **no runtime import** of `every_eval_ever` (it needs Python 3.12; proofbundle stays 3.10+).
435
442
  - **in-toto test-result export, DSSE-signed** (v0.9) — `proofbundle.intoto.export_intoto_dsse(claim,
436
443
  signer)` emits the receipt as a DSSE-signed in-toto Statement v1 with the **generic
437
444
  `test-result/v0.1` predicate** (result PASSED/FAILED, `configuration` ResourceDescriptors), so a generic
@@ -491,15 +498,23 @@ ISO/IEC DIS 24970), including the anti-patterns no one should claim.
491
498
  (RFC 9901 §4.3, fail-closed; closes #1) with `cnf.jwk` issuance and holder presentation, **C2SP
492
499
  tlog-cosignature** verification (Ed25519 cosignature/v1, witness quorum, split-view resistance), and
493
500
  `verify --verbose` with the recomputed Merkle root (closes #2).
494
- - **v1.3 (current release)** — the portable proof: **C2SP tlog-proof** emit/verify (+
501
+ - **v1.3** — the portable proof: **C2SP tlog-proof** emit/verify (+
495
502
  `verify-proof` CLI), **ML-DSA-44** witness cosignatures (post-quantum, `[pq]` extra), **Token
496
503
  Status List** snapshot verification (offline revocation), SD-JWT VC markers (`dc+sd-jwt`,
497
504
  `vct`, `status`), COMPLIANCE.md (EU AI Act Art. 12 / NIST AI RMF mapping), and the mutation
498
505
  suite as a CI gate.
499
- - **Deferred** (explicitly not yet built) — full SD-JWT VC conformance + `vct` type-metadata
500
- resolution (pre-IESG), per-sample Merkle receipts (the THREAT_MODEL's named gap; v2.0
501
- direction), an official in-toto eval predicate (proposal path via OpenSSF/CoSAI), a full
502
- in-toto client, a Python-3.10 floor.
506
+ - **v1.4 (current release)**distribution: a **promptfoo** adapter (results.json v3
507
+ pass_rate receipt), the **Hugging Face Community Evals bridge** (`pb1.` receipt tokens for the
508
+ `.eval_results` `verifyToken` field + a strict YAML entry emitter — proofbundle-verifiable,
509
+ explicitly NOT the HF-internal badge token), `hf-token` CLI, and the **Python 3.10+ floor**
510
+ (3.9 is EOL since 2025-10).
511
+ - **v2.0 (next major, research differentiation)** — **per-sample Merkle receipts**: the payload carries a
512
+ Merkle root over *individual* sample results, so a verifier can force a random-sample re-check instead of
513
+ trusting only the reported aggregate. This is the THREAT_MODEL's one named structural gap ("forced random
514
+ sub-sampling"), and closing it is the road from *authorship* to *sampled correctness*.
515
+ - **Deferred** (explicitly not yet built, stated honestly) — full SD-JWT VC conformance + `vct`
516
+ type-metadata resolution (pre-IESG), an official in-toto eval predicate (proposal path via
517
+ OpenSSF/CoSAI), and a full in-toto client.
503
518
 
504
519
  ## Contributing
505
520
 
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proofbundle"
7
- version = "1.3.0"
7
+ version = "1.4.0"
8
8
  description = "Emit and verify portable cryptographic evidence bundles, offline: Ed25519 + RFC 6962 Merkle + optional SD-JWT."
9
9
  readme = "README.md"
10
- requires-python = ">=3.9"
10
+ requires-python = ">=3.10"
11
11
  license = { text = "MIT" }
12
12
  authors = [{ name = "Konrad Gruszka" }]
13
13
  keywords = [
@@ -26,7 +26,6 @@ classifiers = [
26
26
  "Intended Audience :: Developers",
27
27
  "License :: OSI Approved :: MIT License",
28
28
  "Programming Language :: Python :: 3",
29
- "Programming Language :: Python :: 3.9",
30
29
  "Programming Language :: Python :: 3.10",
31
30
  "Programming Language :: Python :: 3.11",
32
31
  "Programming Language :: Python :: 3.12",
@@ -56,11 +55,11 @@ pq = ["cryptography>=48"]
56
55
  pytest = ["pytest>=7"]
57
56
  # The inspect_ai adapter uses the STABLE read_eval_log API (lazy import). Pinned with an UPPER bound:
58
57
  # the .eval format + pydantic schema change between versions (inspect_ai issue 834), and the fixture
59
- # test is bound to this range. inspect_ai requires Python >= 3.10, so the marker gates it out on 3.9
60
- # (base + [eval]/[sdjwt] still work on 3.9; the inspect adapter test skips there). Fixes the red 3.9 CI.
61
- inspect = ['inspect_ai>=0.3.112,<0.4; python_version >= "3.10"']
58
+ # test is bound to this range. Since v1.4.0 the project floor is Python 3.10, so no per-extra version
59
+ # marker is needed here (inspect_ai's own >=3.10 requirement is always satisfiable on a supported base).
60
+ inspect = ['inspect_ai>=0.3.112,<0.4']
62
61
  dev = ["pytest>=7", "ruff>=0.5", "jsonschema>=4", "mypy>=1.8", "build>=1", "hypothesis>=6",
63
- "rfc8785>=0.1.4", "sd-jwt>=0.10", 'inspect_ai>=0.3.112,<0.4; python_version >= "3.10"']
62
+ "rfc8785>=0.1.4", "sd-jwt>=0.10", 'inspect_ai>=0.3.112,<0.4']
64
63
 
65
64
  [project.urls]
66
65
  Homepage = "https://b7n0de.com"
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import TYPE_CHECKING
15
15
 
16
- __version__ = "1.3.0"
16
+ __version__ = "1.4.0"
17
17
 
18
18
  __all__ = [
19
19
  "__version__",
@@ -30,6 +30,8 @@ __all__ = [
30
30
  "verify_cosignature",
31
31
  "verify_witnessed_checkpoint",
32
32
  "verify_status_snapshot",
33
+ "receipt_token",
34
+ "verify_receipt_token",
33
35
  "VerificationResult",
34
36
  "Check",
35
37
  "ProofBundleError",
@@ -47,6 +49,8 @@ _LAZY = {
47
49
  "verify_cosignature": ".checkpoint",
48
50
  "verify_witnessed_checkpoint": ".checkpoint",
49
51
  "verify_status_snapshot": ".statuslist",
52
+ "receipt_token": ".hf_evals",
53
+ "verify_receipt_token": ".hf_evals",
50
54
  }
51
55
 
52
56
  if TYPE_CHECKING: # static analysers + IDEs see the real names/types; runtime stays lazy
@@ -55,6 +59,7 @@ if TYPE_CHECKING: # static analysers + IDEs see the real names/types; runtime s
55
59
  from .errors import Check, ProofBundleError, VerificationResult
56
60
  from .checkpoint import verify_cosignature, verify_witnessed_checkpoint
57
61
  from .kbjwt import verify_key_binding
62
+ from .hf_evals import receipt_token, verify_receipt_token
58
63
  from .statuslist import verify_status_snapshot
59
64
  from .tlogproof import verify_tlog_proof
60
65
  from .merkle import verify_consistency, verify_inclusion
@@ -7,5 +7,7 @@ each fixture in tests/fixtures documents its source + version.
7
7
  from .inspect_ai import from_inspect_ai_log
8
8
  from .eee import from_eee_dataset
9
9
  from .lm_eval import from_lm_eval_results
10
+ from .promptfoo import from_promptfoo_results
10
11
 
11
- __all__ = ["from_lm_eval_results", "from_inspect_ai_log", "from_eee_dataset"]
12
+ __all__ = ["from_lm_eval_results", "from_inspect_ai_log", "from_eee_dataset",
13
+ "from_promptfoo_results"]
@@ -0,0 +1,131 @@
1
+ """Adapter for promptfoo `eval -o results.json` output (file-based, NO promptfoo import) — v1.4.
2
+
3
+ Parses the exported OutputFile JSON only. Format verified 2026-07-02 against promptfoo `main`
4
+ (`src/types/index.ts` OutputFile / EvaluateSummaryV3 / EvaluateStats, `src/util/output.ts`
5
+ writeOutput): top level carries `evalId`, `results`, `config`, `metadata`; the current summary is
6
+ **version 3** (`results.version == 3`) with whole-run aggregates in `results.stats` as
7
+ `successes` / `failures` / `errors`. The metric emitted is **pass_rate** = successes / (successes
8
+ + failures + errors), rendered as a fixed-point decimal string (the claim schema takes decimal
9
+ strings, never floats).
10
+
11
+ Version honesty: legacy files with `results.version` 1/2 have a different shape (`table` instead
12
+ of `prompts`) and are REJECTED with a clear message rather than half-parsed; the "v4" storage
13
+ version never appears in output files (they still say `version: 3`). Redaction flags
14
+ (`PROMPTFOO_STRIP_*`) only affect prompt/response bodies — nothing this adapter reads.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ from decimal import Decimal
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ from ..evalclaim import build_eval_claim
25
+
26
+ _SCALE = 6 # pass_rate decimal places — fixed-point, schema-conformant
27
+
28
+
29
+ def _pass_rate(successes: int, failures: int, errors: int) -> "tuple[str, int]":
30
+ total = successes + failures + errors
31
+ if total <= 0:
32
+ raise ValueError("promptfoo results contain no test outcomes (stats all zero)")
33
+ rate = (Decimal(successes) / Decimal(total)).quantize(Decimal(1).scaleb(-_SCALE))
34
+ return f"{rate:.{_SCALE}f}", total
35
+
36
+
37
+ def from_promptfoo_results(path, *, comparator: str, threshold: str, timestamp: str,
38
+ model_salt: Optional[bytes] = None,
39
+ dataset_salt: Optional[bytes] = None):
40
+ """Read a promptfoo results.json (summary v3) and build an eval claim over the run's
41
+ pass_rate. Returns (claim, salts).
42
+
43
+ - suite = `config.description` when present, else the `evalId` (the run's identity).
44
+ - model_id = the sorted, de-duplicated provider ids of the run (a promptfoo eval may span
45
+ several providers; the salted commitment pins the exact set).
46
+ - dataset_id = sha256 over the canonical JSON of `config.tests` AS RECORDED in results.json.
47
+ Inline tests bind the actual content (the suite IS the dataset); a `file://`/glob reference binds
48
+ the reference, not the resolved file content (an offline reader cannot resolve it) — provenance
49
+ records which case applies (`dataset_commitment_scope`) so the binding is never overstated.
50
+ - provenance: promptfooVersion, evalId, summary timestamp, per-outcome counts.
51
+ """
52
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
53
+ if not isinstance(data, dict) or not isinstance(data.get("results"), dict):
54
+ raise ValueError("not a promptfoo output file (missing results object)")
55
+ summary = data["results"]
56
+ version = summary.get("version")
57
+ if version != 3:
58
+ raise ValueError(
59
+ f"unsupported promptfoo summary version {version!r} — this adapter parses the "
60
+ "current version 3 (legacy v1/v2 files use a different 'table' shape; re-export "
61
+ "with a current promptfoo)")
62
+ stats = summary.get("stats")
63
+ if not isinstance(stats, dict):
64
+ raise ValueError("promptfoo results.stats missing")
65
+ counts = {}
66
+ for key in ("successes", "failures", "errors"):
67
+ val = stats.get(key, 0)
68
+ if isinstance(val, bool) or not isinstance(val, int) or val < 0:
69
+ raise ValueError(f"promptfoo stats.{key} must be a non-negative integer")
70
+ counts[key] = val
71
+ score, n = _pass_rate(counts["successes"], counts["failures"], counts["errors"])
72
+
73
+ config = data.get("config") or {}
74
+ eval_id = data.get("evalId")
75
+ suite = str(config.get("description") or eval_id or "promptfoo-run")
76
+
77
+ # Commit to the providers that ACTUALLY produced a result in this run — not config.providers, which can
78
+ # list providers that never ran (e.g. --filter-providers). Only fall back to config.providers if the
79
+ # summary recorded no per-result provider at all (release-review fix).
80
+ providers = set()
81
+ for res in summary.get("results", []):
82
+ prov = (res or {}).get("provider")
83
+ if isinstance(prov, dict) and prov.get("id"):
84
+ providers.add(str(prov["id"]))
85
+ elif isinstance(prov, str):
86
+ providers.add(prov)
87
+ if not providers:
88
+ for prov in config.get("providers") or []:
89
+ if isinstance(prov, str):
90
+ providers.add(prov)
91
+ elif isinstance(prov, dict) and prov.get("id"):
92
+ providers.add(str(prov["id"]))
93
+ model_id = "+".join(sorted(providers)) if providers else "unknown:promptfoo-provider"
94
+
95
+ tests = config.get("tests")
96
+ tests_canonical = json.dumps(tests, sort_keys=True, separators=(",", ":")) if tests else ""
97
+ dataset_id = ("promptfoo-tests-sha256:"
98
+ + hashlib.sha256(tests_canonical.encode("utf-8")).hexdigest())
99
+ # HONESTY (release-review): the commitment is over `config.tests` AS RECORDED in results.json. When tests are
100
+ # INLINE, that is the content (the suite IS the dataset). When they are a `file://`/glob REFERENCE — whether a
101
+ # top-level string, a list containing a string, OR a `file://` nested inside an inline test's vars/assert —
102
+ # promptfoo records the unresolved reference, so the commitment binds the reference, NOT the file content
103
+ # (an offline reader cannot resolve external files). When there are NO tests at all the commitment is over the
104
+ # empty string and binds nothing. We label each case so a verifier is never misled that content was bound.
105
+ tests_by_reference = isinstance(tests, str) or (
106
+ isinstance(tests, list) and any(isinstance(t, str) for t in tests)) or ("file://" in tests_canonical)
107
+ if not tests:
108
+ commitment_scope = "config.tests_absent"
109
+ elif tests_by_reference:
110
+ commitment_scope = "config.tests_reference_only"
111
+ else:
112
+ commitment_scope = "config.tests_inline_content"
113
+
114
+ metadata = data.get("metadata") or {}
115
+ provenance = {"harness": "promptfoo",
116
+ "successes": str(counts["successes"]), "failures": str(counts["failures"]),
117
+ "errors": str(counts["errors"]),
118
+ "pass_rate_formula": "successes/(successes+failures+errors)",
119
+ "dataset_commitment_scope": commitment_scope}
120
+ if eval_id:
121
+ provenance["eval_id"] = str(eval_id)
122
+ if metadata.get("promptfooVersion"):
123
+ provenance["promptfoo_version"] = str(metadata["promptfooVersion"])
124
+ if summary.get("timestamp"):
125
+ provenance["run_timestamp"] = str(summary["timestamp"])
126
+
127
+ return build_eval_claim(
128
+ suite=suite, suite_version=f"promptfoo-summary-v{version}",
129
+ metric="pass_rate", comparator=comparator, threshold=threshold, score=score, n=n,
130
+ model_id=model_id, dataset_id=dataset_id, issuer="", timestamp=timestamp,
131
+ provenance=provenance, model_salt=model_salt, dataset_salt=dataset_salt)
@@ -155,6 +155,28 @@ def _cmd_verify_proof(args: argparse.Namespace) -> int:
155
155
  return 0 if res["ok"] else 1
156
156
 
157
157
 
158
+ def _cmd_hf_token(args: argparse.Namespace) -> int:
159
+ from .bundle import load_bundle # noqa: PLC0415
160
+ from .hf_evals import receipt_token, verify_receipt_token # noqa: PLC0415
161
+ try:
162
+ if args.verify:
163
+ token = args.bundle_or_token
164
+ if token.endswith(".txt") or "/" in token:
165
+ with open(token, encoding="utf-8") as handle:
166
+ token = handle.read().strip()
167
+ result, _bundle = verify_receipt_token(token)
168
+ for check in result.checks:
169
+ print(str(check))
170
+ print("=> OK" if result.ok else "=> FAILED")
171
+ return 0 if result.ok else 1
172
+ token = receipt_token(load_bundle(args.bundle_or_token))
173
+ print(token)
174
+ return 0
175
+ except (ProofBundleError, OSError, ValueError) as exc: # file/JSON/format errors → clean exit
176
+ print(f"ERROR: {exc}", file=sys.stderr)
177
+ return 2
178
+
179
+
158
180
  def build_parser() -> argparse.ArgumentParser:
159
181
  parser = argparse.ArgumentParser(
160
182
  prog="proofbundle",
@@ -207,6 +229,15 @@ def build_parser() -> argparse.ArgumentParser:
207
229
  verify_proof.add_argument("--json", action="store_true", help="machine readable output")
208
230
  verify_proof.set_defaults(func=_cmd_verify_proof)
209
231
 
232
+ hf_token = sub.add_parser(
233
+ "hf-token",
234
+ help="pack a receipt into a pb1. token for HF eval_results, or verify one (v1.4)")
235
+ hf_token.add_argument("bundle_or_token",
236
+ help="bundle JSON path (emit) or pb1. token / token file (--verify)")
237
+ hf_token.add_argument("--verify", action="store_true",
238
+ help="verify a pb1. token instead of emitting one")
239
+ hf_token.set_defaults(func=_cmd_hf_token)
240
+
210
241
  return parser
211
242
 
212
243
 
@@ -0,0 +1,185 @@
1
+ """Hugging Face Community Evals bridge — receipt tokens + `.eval_results/*.yaml` entries (v1.4).
2
+
3
+ HF Community Evals (Feb 2026, beta) lets anyone PR benchmark results into a model repo as
4
+ `.eval_results/*.yaml`; entries carry an optional, string-typed ``verifyToken`` documented as "a
5
+ signature that can be used to prove that evaluation is provably auditable and reproducible".
6
+ Schema verified 2026-07-02 against hub-docs (`eval_results.yaml` spec + docs page).
7
+
8
+ **Honesty first — what this module does and does not claim.** The Hub's own "verified" badge is
9
+ granted by HF **server-side** (currently: evaluation ran in HF Jobs with inspect-ai); the token
10
+ format HF validates is not publicly specified. proofbundle therefore does NOT fabricate an
11
+ HF-validated token. It defines its own self-describing, offline-verifiable token profile —
12
+
13
+ ``pb1.`` + base64url(zlib(bundle JSON))
14
+
15
+ — which any third party (and, should they choose, HF) can verify with `verify_receipt_token`:
16
+ the full receipt travels inside the token, so verification is exactly `verify_bundle`, offline.
17
+ Putting it in the schema-valid `verifyToken` field makes the result *proofbundle-verifiable*;
18
+ it does not and must not be presented as HF-endorsed. The docs and `to_eval_results_entry` keep
19
+ that distinction explicit (the receipt link belongs in `source.url` / `notes` either way).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import json
26
+ import math
27
+ import zlib
28
+ from typing import Optional, Tuple
29
+
30
+ from .bundle import verify_bundle
31
+ from .errors import BundleFormatError, UnsupportedError, VerificationResult
32
+
33
+ __all__ = ["TOKEN_PREFIX", "receipt_token", "verify_receipt_token",
34
+ "to_eval_results_entry", "eval_results_yaml"]
35
+
36
+ TOKEN_PREFIX = "pb1."
37
+ _MAX_TOKEN_BYTES = 262_144 # 256 KiB decompressed cap — a receipt is a few KB; refuse zip bombs
38
+
39
+
40
+ def _b64url(data: bytes) -> str:
41
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
42
+
43
+
44
+ def _b64url_decode(s: str) -> bytes:
45
+ raw = s.encode("ascii")
46
+ return base64.urlsafe_b64decode(raw + b"=" * (-len(raw) % 4))
47
+
48
+
49
+ def receipt_token(bundle: dict) -> str:
50
+ """Pack a receipt bundle into a compact, self-contained token: ``pb1.`` +
51
+ base64url(zlib(canonical bundle JSON)). The token IS the receipt — verifying it is verifying
52
+ the bundle, offline, no lookup."""
53
+ if not isinstance(bundle, dict) or "payload_b64" not in bundle:
54
+ raise BundleFormatError("receipt_token needs a bundle dict")
55
+ canonical = json.dumps(bundle, sort_keys=True, separators=(",", ":")).encode("utf-8")
56
+ return TOKEN_PREFIX + _b64url(zlib.compress(canonical, 9))
57
+
58
+
59
+ def verify_receipt_token(token: str) -> Tuple[VerificationResult, Optional[dict]]:
60
+ """Unpack and verify a ``pb1.`` receipt token. Returns (VerificationResult, bundle_dict).
61
+ Malformed tokens raise BundleFormatError — never a crash, never a silent pass."""
62
+ if not isinstance(token, str) or not token.startswith(TOKEN_PREFIX):
63
+ raise BundleFormatError(f"not a proofbundle receipt token (expected {TOKEN_PREFIX!r} prefix)")
64
+ try:
65
+ decomp = zlib.decompressobj()
66
+ raw = decomp.decompress(_b64url_decode(token[len(TOKEN_PREFIX):]), _MAX_TOKEN_BYTES)
67
+ if decomp.unconsumed_tail:
68
+ raise BundleFormatError("receipt token exceeds the decompression cap")
69
+ bundle = json.loads(raw)
70
+ except BundleFormatError:
71
+ raise
72
+ except (ValueError, TypeError, zlib.error) as exc:
73
+ raise BundleFormatError("receipt token is not valid base64url(zlib(JSON))") from exc
74
+ if not isinstance(bundle, dict):
75
+ raise BundleFormatError("receipt token does not contain a bundle object")
76
+ # Normalize an unsupported schema/alg to BundleFormatError so the documented contract holds — a malformed
77
+ # token never escapes as a different exception type (release-review fix).
78
+ try:
79
+ return verify_bundle(bundle), bundle
80
+ except UnsupportedError as exc:
81
+ raise BundleFormatError(f"receipt token bundle uses an unsupported schema/algorithm: {exc}") from exc
82
+
83
+
84
+ def to_eval_results_entry(bundle: dict, *, dataset_id: str, task_id: str, value,
85
+ date: Optional[str] = None, source_url: Optional[str] = None,
86
+ source_name: Optional[str] = None, source_user: Optional[str] = None,
87
+ notes: Optional[str] = None, include_token: bool = True,
88
+ require_verified: bool = True) -> dict:
89
+ """Build one HF `.eval_results/*.yaml` entry for a receipt.
90
+
91
+ ``dataset_id``/``task_id`` name the Hub benchmark (per its `eval.yaml`); ``value`` is the
92
+ REQUIRED metric number — the caller chooses what to disclose (a receipt may withhold the
93
+ exact score via SD-JWT; publishing a value here IS a disclosure decision).
94
+
95
+ No-Fake guard: with ``require_verified=True`` (default) the bundle must verify NOW — an entry
96
+ is never generated from a broken receipt. ``include_token=True`` puts the ``pb1.`` token in
97
+ ``verifyToken`` (schema-valid, proofbundle-verifiable; NOT the HF-internal badge token — HF's
98
+ "verified" badge is HF's server-side decision, and this module makes no claim about it).
99
+ """
100
+ if require_verified:
101
+ result = verify_bundle(bundle)
102
+ if not result.ok:
103
+ raise BundleFormatError(
104
+ "refusing to build an eval_results entry from a bundle that does not verify: "
105
+ + "; ".join(f"{c.name}: {c.detail}" for c in result.checks if not c.ok))
106
+ if not dataset_id or not task_id:
107
+ raise BundleFormatError("dataset_id and task_id are required (the Hub benchmark identity)")
108
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
109
+ raise BundleFormatError("value must be a number (or numeric string)")
110
+ # Reject non-numeric strings AND non-finite values (release-review fix): inf/-inf/nan (whether a float or a
111
+ # string like '1e400'/'nan' that float() accepts) would serialize to the non-standard tokens Infinity/NaN —
112
+ # neither valid JSON nor unambiguous YAML — so an eval_results.yaml value must be a FINITE number.
113
+ try:
114
+ numeric = float(value)
115
+ except (ValueError, TypeError, OverflowError) as exc: # OverflowError: an int beyond float range
116
+ raise BundleFormatError(f"value {value!r} is not a representable finite number") from exc
117
+ if not math.isfinite(numeric):
118
+ raise BundleFormatError(
119
+ "value must be a finite number — inf/-inf/nan cannot be represented in eval_results.yaml")
120
+
121
+ entry: dict = {"dataset": {"id": dataset_id, "task_id": task_id},
122
+ "value": numeric if isinstance(value, str) else value}
123
+ if include_token:
124
+ entry["verifyToken"] = receipt_token(bundle)
125
+ if date is not None:
126
+ entry["date"] = date
127
+ source = {}
128
+ if source_url:
129
+ source["url"] = source_url
130
+ if source_name:
131
+ source["name"] = source_name
132
+ if source_user:
133
+ source["user"] = source_user
134
+ if source:
135
+ if "url" not in source:
136
+ raise BundleFormatError("source.url is required when a source is given (HF schema)")
137
+ entry["source"] = source
138
+ if notes is not None:
139
+ entry["notes"] = notes
140
+ return entry
141
+
142
+
143
+ def _yaml_scalar(value) -> str:
144
+ """Serialize one scalar for the entry YAML. Strings are always double-quoted (JSON-style
145
+ escaping is valid YAML), so dates stay strings and tokens survive any special characters."""
146
+ if isinstance(value, bool):
147
+ return "true" if value else "false"
148
+ if isinstance(value, (int, float)):
149
+ return json.dumps(value)
150
+ return json.dumps(str(value))
151
+
152
+
153
+ def eval_results_yaml(entries) -> str:
154
+ """Render entries as a `.eval_results/*.yaml` document (block style, deterministic key
155
+ order per the HF spec example). Only the known, shallow schema is emitted — this is a
156
+ purpose-built serializer, not a general YAML writer."""
157
+ order = ("dataset", "value", "verifyToken", "date", "source", "notes")
158
+ dataset_order = ("id", "task_id", "revision")
159
+ source_order = ("url", "name", "user") # HF hub-docs spec fields (no 'org' — 'user' covers the HF org/user)
160
+ lines = []
161
+ for entry in entries:
162
+ unknown = set(entry) - set(order)
163
+ if unknown:
164
+ raise BundleFormatError(f"unknown eval_results entry field(s): {sorted(unknown)}")
165
+ first = True
166
+ for key in order:
167
+ if key not in entry:
168
+ continue
169
+ prefix = "- " if first else " "
170
+ first = False
171
+ val = entry[key]
172
+ if key in ("dataset", "source"):
173
+ sub_order = dataset_order if key == "dataset" else source_order
174
+ # fail-loud on unknown nested keys too (symmetric with the top-level check) — a dropped field
175
+ # would silently omit data from the published entry (release-review fix).
176
+ sub_unknown = set(val) - set(sub_order)
177
+ if sub_unknown:
178
+ raise BundleFormatError(f"unknown {key} field(s): {sorted(sub_unknown)}")
179
+ lines.append(f"{prefix}{key}:")
180
+ for sub in sub_order:
181
+ if sub in val:
182
+ lines.append(f" {sub}: {_yaml_scalar(val[sub])}")
183
+ else:
184
+ lines.append(f"{prefix}{key}: {_yaml_scalar(val)}")
185
+ return "\n".join(lines) + "\n"