zft 0.2.0a1__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 (57) hide show
  1. traceagent/__init__.py +7 -0
  2. traceagent/attest/__init__.py +1 -0
  3. traceagent/attest/dsse.py +378 -0
  4. traceagent/attest/errors.py +27 -0
  5. traceagent/attest/export.py +135 -0
  6. traceagent/attest/jcs.py +130 -0
  7. traceagent/attest/keys.py +151 -0
  8. traceagent/cli/main.py +566 -0
  9. traceagent/codegen/__init__.py +1 -0
  10. traceagent/codegen/gherkin_gen.py +49 -0
  11. traceagent/codegen/property_gen.py +122 -0
  12. traceagent/debug/__init__.py +1 -0
  13. traceagent/debug/ledger.py +152 -0
  14. traceagent/debug/repro.py +44 -0
  15. traceagent/dsl/__init__.py +1 -0
  16. traceagent/dsl/ears.py +112 -0
  17. traceagent/dsl/oracle.py +122 -0
  18. traceagent/dsl/predicate.py +317 -0
  19. traceagent/dsl/strategies.py +112 -0
  20. traceagent/gates/__init__.py +1 -0
  21. traceagent/gates/driver_gate.py +242 -0
  22. traceagent/gates/l0.py +91 -0
  23. traceagent/gates/l1.py +361 -0
  24. traceagent/gates/l2.py +235 -0
  25. traceagent/gates/l3.py +29 -0
  26. traceagent/gates/mutation_bar.py +371 -0
  27. traceagent/gates/runners/__init__.py +1 -0
  28. traceagent/gates/runners/mutmut_runner.py +263 -0
  29. traceagent/gates/runners/pytest_runner.py +62 -0
  30. traceagent/gates/sandbox.py +195 -0
  31. traceagent/lineage/__init__.py +4 -0
  32. traceagent/lineage/extract.py +281 -0
  33. traceagent/lineage/impact.py +79 -0
  34. traceagent/lineage/matrix.py +196 -0
  35. traceagent/negotiate/__init__.py +1 -0
  36. traceagent/negotiate/a2a_adapter.py +88 -0
  37. traceagent/negotiate/resume.py +162 -0
  38. traceagent/negotiate/sm.py +126 -0
  39. traceagent/negotiate/terms.py +96 -0
  40. traceagent/negotiate/transport.py +375 -0
  41. traceagent/registry/__init__.py +1 -0
  42. traceagent/registry/conflicts.py +21 -0
  43. traceagent/spec/__init__.py +1 -0
  44. traceagent/spec/canon.py +40 -0
  45. traceagent/spec/delta.py +33 -0
  46. traceagent/spec/findings.py +71 -0
  47. traceagent/spec/identity.py +12 -0
  48. traceagent/spec/lint.py +115 -0
  49. traceagent/spec/schema.py +67 -0
  50. traceagent/spec/store.py +44 -0
  51. traceagent/taskgate.py +161 -0
  52. zft-0.2.0a1.dist-info/METADATA +163 -0
  53. zft-0.2.0a1.dist-info/RECORD +57 -0
  54. zft-0.2.0a1.dist-info/WHEEL +5 -0
  55. zft-0.2.0a1.dist-info/entry_points.txt +3 -0
  56. zft-0.2.0a1.dist-info/licenses/LICENSE +202 -0
  57. zft-0.2.0a1.dist-info/top_level.txt +1 -0
traceagent/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """zft — contract-first traceability for multiagent work.
2
+
3
+ Dependency direction (plan §2): spec <- dsl <- codegen <- gates <- (attest,
4
+ negotiate); lineage feeds gates; debug depends on nothing above it.
5
+ """
6
+
7
+ __version__ = "0.2.0a1"
@@ -0,0 +1 @@
1
+ """attest: DSSE TraceManifest attestations over the contract store."""
@@ -0,0 +1,378 @@
1
+ """DSSE attestation (plan C-28): TraceManifest predicate binding clause hashes.
2
+
3
+ Substrate: securesystemslib (in-toto 3.x's underlying DSSE library; in-toto 3.1
4
+ removed its legacy models API). Predicate URI versioned: .../TraceManifest/v1.
5
+
6
+ Payloads are canonicalized per RFC 8785 (JCS, see attest.jcs) before signing —
7
+ securesystemslib's encode_canonical is the older OLPC Canonical-JSON draft
8
+ (no float support, code-point key ordering), not JCS, so we do not sign over it.
9
+
10
+ Malformed envelopes raise the typed errors below instead of leaking
11
+ securesystemslib/binascii internals; parse_envelope collects every format
12
+ problem it finds before failing (parser-style error recovery).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import binascii
18
+ import copy
19
+ import hashlib
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ import pytest
26
+ from securesystemslib.dsse import Envelope
27
+ from securesystemslib.exceptions import FormatError, VerificationError
28
+ from securesystemslib.signer import CryptoSigner, Key, Signer
29
+
30
+ from traceagent.attest.errors import (
31
+ AttestationError,
32
+ EnvelopeFormatError,
33
+ KeyValidationError,
34
+ SignatureVerificationError,
35
+ SubjectMismatchError,
36
+ )
37
+ from traceagent.attest.jcs import canonicalize
38
+
39
+ if TYPE_CHECKING:
40
+ from traceagent.attest.keys import KeyDocument
41
+
42
+ PREDICATE_TYPE = "https://traceagent.dev/attestations/TraceManifest/v1"
43
+ PAYLOAD_TYPE = "application/vnd.in-toto+json"
44
+ STATEMENT_TYPE = "https://in-toto.io/Statement/v1"
45
+
46
+ __all__ = [
47
+ "AttestationError", "EnvelopeFormatError", "KeyValidationError",
48
+ "SignatureVerificationError", "SubjectMismatchError",
49
+ "attest_contract", "clause_subjects", "decode_payload", "parse_envelope",
50
+ "parse_payload", "verify_attestation",
51
+ ]
52
+
53
+
54
+ def _gate_source_root() -> Path:
55
+ return Path(__file__).resolve().parent.parent.parent.parent
56
+
57
+
58
+ def _gate_source_files() -> dict[str, str]:
59
+ """Relative-path -> sha256 hex for the gate source files embedded in the manifest."""
60
+ source_root = _gate_source_root()
61
+ gate_files = [
62
+ source_root / "src" / "traceagent" / "gates" / "l1.py",
63
+ source_root / "src" / "traceagent" / "gates" / "l2.py",
64
+ source_root / "src" / "traceagent" / "codegen" / "property_gen.py",
65
+ ]
66
+ gate_hashes: dict[str, str] = {}
67
+ for f in gate_files:
68
+ rel = f.relative_to(source_root)
69
+ gate_hashes[str(rel)] = hashlib.sha256(f.read_bytes()).hexdigest()
70
+ return gate_hashes
71
+
72
+
73
+ def _check_gate_manifest(payload: dict) -> None:
74
+ """Fail if the gate manifest's own file hashes are tampered (zft lineage:
75
+ gate self-verification, unioned 2026-09-12)."""
76
+ predicate = payload.get("predicate")
77
+ if not isinstance(predicate, dict):
78
+ return
79
+ manifest = predicate.get("gate_manifest")
80
+ if not isinstance(manifest, dict):
81
+ return
82
+ files = manifest.get("files")
83
+ if not isinstance(files, dict):
84
+ return
85
+ for rel, recorded in files.items():
86
+ actual_path = _gate_source_root() / rel
87
+ try:
88
+ actual = hashlib.sha256(actual_path.read_bytes()).hexdigest()
89
+ except OSError:
90
+ raise AttestationError(
91
+ f"gate manifest file {rel!r} missing or unreadable"
92
+ ) from None
93
+ if actual != recorded:
94
+ raise AttestationError(
95
+ f"gate manifest file {rel!r} hash mismatch: expected {recorded!r}, "
96
+ f"found {actual!r}")
97
+
98
+
99
+ def clause_subjects(root: Path) -> list[dict]:
100
+ subjects = []
101
+ for path in sorted((root / ".zft" / "specs").rglob("*.json")):
102
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
103
+ subjects.append({"name": f"clause:{path.name}", "digest": {"sha256": digest}})
104
+ return subjects
105
+
106
+
107
+ def _deterministic_coverage(root: Path) -> str:
108
+ """covered/due clause count for milestone v0 (bindings vs deferred targets)."""
109
+ from traceagent.lineage.extract import extract_bindings
110
+ from traceagent.spec.store import Store, load_contract
111
+
112
+ store = Store.load(root)
113
+ milestone = "v0"
114
+ try:
115
+ deferred = load_contract(root).get("meta", {}).get("target_milestone", {})
116
+ except (FileNotFoundError, json.JSONDecodeError):
117
+ deferred = {}
118
+ due = {a for a, _n in store.nodes.items() if deferred.get(a, milestone) <= milestone}
119
+ bindings = extract_bindings(root)
120
+ bound = sorted({b["alias"] for b in bindings if b["alias"] in due})
121
+ return f"{len(bound)}/{len(due)}"
122
+
123
+
124
+ def attest_contract(root: Path | str, producer_model: str | None = None,
125
+ gate_model: str | None = None, signer: Signer | None = None,
126
+ deterministic_coverage: str | None = None) -> dict:
127
+ """Sign a TraceManifest statement over the current store; returns DSSE envelope dict.
128
+
129
+ Payload is RFC 8785-canonicalized before signing. Sign with the passed
130
+ signer; the caller MUST keep it for verification. deterministic_coverage:
131
+ if None, computed from bindings vs due clauses.
132
+ """
133
+ root = Path(root)
134
+ signer = signer or CryptoSigner.generate_ed25519()
135
+ subjects = clause_subjects(root)
136
+ if not subjects:
137
+ raise AttestationError(
138
+ f"no clause specs found under {root / '.zft' / 'specs'} — nothing to attest")
139
+
140
+ from traceagent.spec.store import load_contract
141
+
142
+ try:
143
+ contract = load_contract(root)
144
+ deferred = contract.get("meta", {}).get("target_milestone", {})
145
+ except (FileNotFoundError, json.JSONDecodeError):
146
+ deferred = {}
147
+ if deterministic_coverage is None:
148
+ deterministic_coverage = _deterministic_coverage(root)
149
+
150
+
151
+ payload = {
152
+ "_type": STATEMENT_TYPE,
153
+ "subject": subjects,
154
+ "predicateType": PREDICATE_TYPE,
155
+ "predicate": {
156
+ "coverage": {"clauses_covered": deterministic_coverage or "pending-L2"},
157
+ "judge_excluded": [],
158
+ "deferred": sorted(deferred),
159
+ "models": {"producer": producer_model, "gate": gate_model},
160
+ "model_dependent": producer_model == gate_model,
161
+ "gate_manifest": {
162
+ "files": _gate_source_files(),
163
+ "python": sys.version.split()[0],
164
+ "pytest": pytest.__version__,
165
+ },
166
+ },
167
+ }
168
+ # JCS is the last gate before signing: a payload we cannot canonicalize
169
+ # (lone surrogate from surrogateescaped argv or escaped contract JSON,
170
+ # an out-of-double-range integer, ...) must be rejected typed and unsigned.
171
+ try:
172
+ payload_bytes = canonicalize(payload)
173
+ except ValueError as exc:
174
+ raise AttestationError(
175
+ f"payload is not RFC 8785 canonicalizable: {exc}") from exc
176
+ envelope = Envelope(
177
+ payload=payload_bytes,
178
+ payload_type=PAYLOAD_TYPE,
179
+ signatures={},
180
+ )
181
+ envelope.sign(signer)
182
+ return envelope.to_dict()
183
+
184
+
185
+ def _envelope_problems(envelope_dict: object) -> list[str]:
186
+ """Collect ALL structural problems in a would-be DSSE envelope (error recovery)."""
187
+ if not isinstance(envelope_dict, dict):
188
+ return [f"envelope must be a JSON object, got {type(envelope_dict).__name__}"]
189
+ problems: list[str] = []
190
+ payload = envelope_dict.get("payload")
191
+ if not isinstance(payload, str) or not payload:
192
+ problems.append(f"'payload' must be a non-empty base64 string, got {payload!r}")
193
+ payload_type = envelope_dict.get("payloadType")
194
+ if not isinstance(payload_type, str) or not payload_type:
195
+ problems.append(f"'payloadType' must be a non-empty string, got {payload_type!r}")
196
+ signatures = envelope_dict.get("signatures")
197
+ # DSSE envelopes carry signatures as an array; securesystemslib's
198
+ # from_dict iterates it, so the legacy {keyid: sig} mapping shape is
199
+ # rejected here rather than by a stray TypeError deeper in.
200
+ if not isinstance(signatures, list) or not signatures:
201
+ problems.append(f"'signatures' must be a non-empty list, got {signatures!r}")
202
+ return problems
203
+ # each entry must at least be shaped for from_dict (a bare null/str/int
204
+ # entry used to escape as AttributeError from the substrate)
205
+ for i, entry in enumerate(signatures):
206
+ if not isinstance(entry, dict) or not isinstance(entry.get("sig"), str):
207
+ problems.append(
208
+ f"'signatures[{i}]' must be an object with a string 'sig', got {entry!r}")
209
+ return problems
210
+
211
+
212
+ def _canonical_base64(text: str, what: str) -> None:
213
+ """Accept only canonical RFC 4648 standard-alphabet base64 (zero pad bits).
214
+
215
+ Two malleabilities are closed here: Python's b64decode ignores non-zero
216
+ pad bits, so 'QR==' decodes like 'QQ==' — a flipped last character yields
217
+ a byte-different envelope that still verifies; and securesystemslib also
218
+ decodes the urlsafe alphabet, so re-rendering a field urlsafe would give
219
+ the same signed content a textually different envelope (G5: envelope
220
+ equality must be byte equality — matrix CANON-2/3/6).
221
+ """
222
+ try:
223
+ data = base64.b64decode(text.encode("utf-8"), validate=True)
224
+ except (binascii.Error, ValueError) as exc: # binascii.Error ⊂ ValueError
225
+ raise EnvelopeFormatError(
226
+ f"{what} is not canonical base64: {exc}") from exc
227
+ if base64.b64encode(data).decode("ascii") != text:
228
+ raise EnvelopeFormatError(
229
+ f"{what} is not canonical base64 "
230
+ f"(non-zero pad bits or altered padding): got {text!r}")
231
+
232
+
233
+ def parse_envelope(envelope_dict: dict) -> Envelope:
234
+ """Validate + parse an envelope dict, reporting every format problem at once."""
235
+ problems = _envelope_problems(envelope_dict)
236
+ if problems:
237
+ raise EnvelopeFormatError("malformed DSSE envelope: " + "; ".join(problems))
238
+ _canonical_base64(envelope_dict["payload"], "'payload'")
239
+ for entry in envelope_dict["signatures"]:
240
+ if isinstance(entry, dict) and isinstance(entry.get("sig"), str):
241
+ _canonical_base64(entry["sig"], "'signatures[].sig'")
242
+ try:
243
+ # from_dict consumes signature entries in place — parse a copy so the
244
+ # caller's dict (and raw-envelope exports) stay pristine.
245
+ return Envelope.from_dict(copy.deepcopy(envelope_dict))
246
+ except (KeyError, TypeError, ValueError, FormatError) as exc:
247
+ # bad base64 (binascii.Error ⊂ ValueError), missing fields, bad shapes
248
+ raise EnvelopeFormatError(f"malformed DSSE envelope: {exc}") from exc
249
+
250
+
251
+ def decode_payload(envelope: Envelope) -> dict:
252
+ """UTF-8 + JSON decode a parsed envelope's payload into a dict."""
253
+ try:
254
+ text = envelope.payload.decode("utf-8")
255
+ except (UnicodeDecodeError, AttributeError) as exc:
256
+ raise EnvelopeFormatError(f"payload is not valid UTF-8: {exc}") from exc
257
+ try:
258
+ payload = json.loads(text)
259
+ except json.JSONDecodeError as exc:
260
+ raise EnvelopeFormatError(f"payload is not valid JSON: {exc}") from exc
261
+ if not isinstance(payload, dict):
262
+ raise EnvelopeFormatError(
263
+ f"payload must be a JSON object, got {type(payload).__name__}")
264
+ return payload
265
+
266
+
267
+ def _statement_problems(payload: dict) -> list[str]:
268
+ problems = []
269
+ if payload.get("_type") != STATEMENT_TYPE:
270
+ problems.append(f"'_type' must be {STATEMENT_TYPE!r}, got {payload.get('_type')!r}")
271
+ subject = payload.get("subject")
272
+ if not isinstance(subject, list) or not subject:
273
+ problems.append(f"'subject' must be a non-empty list, got {subject!r}")
274
+ predicate_type = payload.get("predicateType")
275
+ if not isinstance(predicate_type, str) or not predicate_type:
276
+ problems.append(f"'predicateType' must be a non-empty string, got {predicate_type!r}")
277
+ if not isinstance(payload.get("predicate"), dict):
278
+ problems.append(f"'predicate' must be an object, got {payload.get('predicate')!r}")
279
+ return problems
280
+
281
+
282
+ def parse_payload(envelope_dict: dict) -> dict:
283
+ """Parse an envelope dict all the way to a validated in-toto Statement dict."""
284
+ payload = decode_payload(parse_envelope(envelope_dict))
285
+ if envelope_dict["payloadType"] != PAYLOAD_TYPE:
286
+ raise EnvelopeFormatError(
287
+ f"unsupported payloadType {envelope_dict['payloadType']!r} "
288
+ f"(expected {PAYLOAD_TYPE!r})")
289
+ problems = _statement_problems(payload)
290
+ if problems:
291
+ raise EnvelopeFormatError(
292
+ "payload is not a valid in-toto statement: " + "; ".join(problems))
293
+ return payload
294
+
295
+
296
+ def _check_subjects(payload: dict, expected_subjects: dict[str, str]) -> None:
297
+ subjects = payload.get("subject")
298
+ if not isinstance(subjects, list):
299
+ raise EnvelopeFormatError(
300
+ f"payload 'subject' must be a list, got {type(subjects).__name__}")
301
+ got: dict[str, str] = {}
302
+ for entry in subjects:
303
+ try:
304
+ got[entry["name"]] = entry["digest"]["sha256"]
305
+ except (TypeError, KeyError, IndexError) as exc:
306
+ raise EnvelopeFormatError(f"malformed subject entry {entry!r}: {exc}") from exc
307
+ if got != expected_subjects:
308
+ changed = sorted(
309
+ f"{n} (attested {got[n][:12]}…, store {expected_subjects[n][:12]}…)"
310
+ for n in got.keys() & expected_subjects.keys()
311
+ if got[n] != expected_subjects[n])
312
+ missing = sorted(got.keys() - expected_subjects.keys())
313
+ added = sorted(expected_subjects.keys() - got.keys())
314
+ detail = "; ".join(
315
+ part for part in (
316
+ f"changed: {', '.join(changed)}" if changed else "",
317
+ f"attested but not in store: {', '.join(missing)}" if missing else "",
318
+ f"in store but not attested (subject-count mismatch): "
319
+ f"{', '.join(added)}" if added else "",
320
+ ) if part)
321
+ raise SubjectMismatchError(
322
+ f"subject digests do not match the contract store: {detail}")
323
+
324
+
325
+ def verify_attestation(envelope_dict: dict, expected_subjects: dict[str, str] | None = None,
326
+ public_key: Key | None = None, *,
327
+ key_document: "KeyDocument | None" = None,
328
+ now=None, expect_keyid: str | None = None) -> dict:
329
+ """Verify signature + subject digests; returns the payload dict on success.
330
+
331
+ Guards raise EnvelopeFormatError for malformed envelopes (all format
332
+ problems reported at once), KeyValidationError for missing/unusable keys
333
+ and key-policy failures, SignatureVerificationError when the signature
334
+ check fails, and SubjectMismatchError when verified digests differ from
335
+ expected_subjects.
336
+
337
+ Key policy (ATTACK_MATRIX G7): pass key_document (attest.keys.KeyDocument,
338
+ parsed from the published key file) instead of a bare public_key to have
339
+ revocation, the validity window, and an optional expect_keyid pin enforced
340
+ BEFORE any cryptographic work. now overrides the clock for the window
341
+ check (deterministic tests); naive datetimes are taken as UTC.
342
+ """
343
+ if key_document is not None and public_key is not None:
344
+ raise KeyValidationError(
345
+ "pass either public_key or key_document, not both — "
346
+ "which key governs verification must be unambiguous")
347
+ if key_document is not None:
348
+ public_key = key_document.check(now=now, expect_keyid=expect_keyid)
349
+ if public_key is None:
350
+ raise KeyValidationError(
351
+ "verify_attestation requires public_key (dev: signer.public_key)")
352
+ if not isinstance(public_key, Key):
353
+ raise KeyValidationError(
354
+ f"public_key must be a securesystemslib Key, got {type(public_key).__name__}")
355
+ if not getattr(public_key, "keyid", ""):
356
+ raise KeyValidationError("public_key carries no keyid")
357
+ envelope = parse_envelope(envelope_dict)
358
+ if envelope_dict["payloadType"] != PAYLOAD_TYPE:
359
+ raise EnvelopeFormatError(
360
+ f"unsupported payloadType {envelope_dict['payloadType']!r} "
361
+ f"(expected {PAYLOAD_TYPE!r})")
362
+ try:
363
+ envelope.verify([public_key], 1)
364
+ except VerificationError as exc:
365
+ raise SignatureVerificationError(f"signature verification failed: {exc}") from exc
366
+ except ValueError as exc: # threshold / keyid-matching rejections
367
+ raise KeyValidationError(f"key rejected by DSSE verifier: {exc}") from exc
368
+ except Exception as exc: # substrate escapes (review L-3): typed, never raw
369
+ if isinstance(exc, AttestationError):
370
+ raise
371
+ raise SignatureVerificationError(
372
+ f"signature verification layer failed: "
373
+ f"{type(exc).__name__}: {exc}") from exc
374
+ payload = decode_payload(envelope)
375
+ if expected_subjects is not None:
376
+ _check_subjects(payload, expected_subjects)
377
+ _check_gate_manifest(payload)
378
+ return payload
@@ -0,0 +1,27 @@
1
+ """Typed attestation errors, shared by dsse.py (crypto + envelope) and
2
+ keys.py (key documents + policy). Lives in its own module so the two can
3
+ import the taxonomy without a cycle; dsse re-exports everything, so
4
+ ``from traceagent.attest.dsse import …`` keeps working.
5
+ """
6
+ from __future__ import annotations
7
+
8
+
9
+ class AttestationError(ValueError):
10
+ """Base class: malformed attestations, bad keys, failed verification."""
11
+
12
+
13
+ class EnvelopeFormatError(AttestationError):
14
+ """DSSE envelope is structurally malformed (fields, base64, JSON payload)."""
15
+
16
+
17
+ class KeyValidationError(AttestationError):
18
+ """Verification key is missing, unusable, or fails key policy
19
+ (revoked, outside its validity window, keyid pin mismatch)."""
20
+
21
+
22
+ class SignatureVerificationError(AttestationError):
23
+ """Envelope signature did not verify against the provided key."""
24
+
25
+
26
+ class SubjectMismatchError(AttestationError):
27
+ """Verified payload's subject digests differ from the expected set."""
@@ -0,0 +1,135 @@
1
+ """Compliance export (plan C-30): derive formats from attested manifests ONLY.
2
+
3
+ Structured views over the attested envelope in ``.traceagent/attest.json``:
4
+ ``export_envelope`` (raw DSSE envelope, validated), ``export_matrix`` (trace
5
+ matrix dict), and ``export_summary`` (human-readable attestation summary).
6
+ Envelopes are structure-validated with the dsse guards; export is refused
7
+ without an attestation file (ATT-EXPORTS-FROM-ATTESTATIONS).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from traceagent.attest.dsse import EnvelopeFormatError, parse_envelope, parse_payload
15
+
16
+ FORMATS = ("dsse", "matrix", "summary")
17
+
18
+
19
+ def export_attestation(root: Path | str, fmt: str = "matrix") -> dict | str:
20
+ """Dispatch an export by format: 'dsse' (raw envelope), 'matrix', 'summary'."""
21
+ if fmt not in FORMATS:
22
+ raise ValueError(
23
+ f"unknown export format {fmt!r} — expected one of: {', '.join(FORMATS)}")
24
+ if fmt == "dsse":
25
+ return export_envelope(root)
26
+ if fmt == "summary":
27
+ return export_summary(root)
28
+ return export_matrix(root)
29
+
30
+
31
+ def load_envelope(root: Path | str) -> dict:
32
+ """Load the attested envelope; refuse without one (ATT-EXPORTS-FROM-ATTESTATIONS)."""
33
+ att_path = Path(root) / ".traceagent" / "attest.json"
34
+ if not att_path.exists():
35
+ raise FileNotFoundError(
36
+ "no attestation found — export refused (ATT-EXPORTS-FROM-ATTESTATIONS)") # noqa: E501
37
+ try:
38
+ envelope = json.loads(att_path.read_text())
39
+ except json.JSONDecodeError as exc:
40
+ raise EnvelopeFormatError(
41
+ f"attestation file {att_path} is not valid JSON: {exc}") from exc
42
+ if not isinstance(envelope, dict):
43
+ raise EnvelopeFormatError(
44
+ f"attestation file {att_path} must contain a JSON object, "
45
+ f"got {type(envelope).__name__}")
46
+ return envelope
47
+
48
+
49
+ def export_envelope(root: Path | str) -> dict:
50
+ """Raw DSSE envelope export: the attested envelope exactly as stored, validated."""
51
+ envelope = load_envelope(root)
52
+ parse_envelope(envelope) # refuse to hand on a structurally malformed envelope
53
+ return envelope
54
+
55
+
56
+ def _predicate_problems(predicate: dict) -> list[str]:
57
+ """Every way ``predicate`` violates the TraceManifest shape, at once."""
58
+ problems = [f"'predicate.{key}' missing" for key in ("coverage", "model_dependent")
59
+ if key not in predicate]
60
+ if "coverage" in predicate and not isinstance(predicate["coverage"], dict):
61
+ problems.append("'predicate.coverage' must be an object, got "
62
+ f"{type(predicate['coverage']).__name__}")
63
+ if ("model_dependent" in predicate
64
+ and not isinstance(predicate["model_dependent"], bool)):
65
+ problems.append("'predicate.model_dependent' must be a bool, got "
66
+ f"{type(predicate['model_dependent']).__name__}")
67
+ return problems
68
+
69
+
70
+ def export_matrix(root: Path | str) -> dict:
71
+ """Build a trace matrix from the attested envelope; refuse without one."""
72
+ payload = parse_payload(load_envelope(root))
73
+ predicate = payload["predicate"]
74
+ problems = _predicate_problems(predicate)
75
+ if problems:
76
+ raise EnvelopeFormatError("attested predicate is incomplete: " + "; ".join(problems))
77
+ clauses = []
78
+ for entry in payload["subject"]:
79
+ try:
80
+ clauses.append(entry["name"])
81
+ except (TypeError, KeyError) as exc:
82
+ raise EnvelopeFormatError(f"malformed subject entry {entry!r}: {exc}") from exc
83
+ return {
84
+ "predicateType": payload["predicateType"],
85
+ "clauses": clauses,
86
+ "coverage": predicate["coverage"],
87
+ "model_dependent": predicate["model_dependent"],
88
+ }
89
+
90
+
91
+ def export_summary(root: Path | str) -> str:
92
+ """Human-readable attestation summary derived from the attested envelope."""
93
+ envelope = load_envelope(root)
94
+ payload = parse_payload(envelope)
95
+ predicate = payload["predicate"]
96
+ problems = _predicate_problems(predicate)
97
+ if problems:
98
+ raise EnvelopeFormatError("attested predicate is incomplete: " + "; ".join(problems))
99
+ coverage = predicate["coverage"]
100
+ covered = (str(coverage["clauses_covered"])
101
+ if coverage.get("clauses_covered") is not None
102
+ else "unknown")
103
+ models = predicate.get("models") or {}
104
+ deferred = predicate.get("deferred") or []
105
+ keyids = _signature_keyids(envelope)
106
+ lines = [
107
+ "TraceAgent attestation summary",
108
+ "==============================",
109
+ f"statement type: {payload['_type']}",
110
+ f"predicate type: {payload['predicateType']}",
111
+ f"payload type: {envelope.get('payloadType', '?')}",
112
+ f"signatures: {len(keyids)}"
113
+ + (f" (keyid {keyids[0][:16]}…)" if keyids else ""),
114
+ f"coverage: clauses covered {covered}",
115
+ f"models: producer={models.get('producer') or 'unspecified'} "
116
+ f"gate={models.get('gate') or 'unspecified'} "
117
+ f"(model_dependent: {predicate.get('model_dependent', False)})",
118
+ f"deferred: {len(deferred)} milestone target(s)",
119
+ f"subjects: {len(payload['subject'])} clause digest(s)",
120
+ ]
121
+ for entry in payload["subject"]:
122
+ try:
123
+ lines.append(f" - {entry['name']}")
124
+ lines.append(f" sha256:{entry['digest']['sha256']}")
125
+ except (TypeError, KeyError):
126
+ lines.append(f" - (malformed subject entry: {entry!r})")
127
+ return "\n".join(lines)
128
+
129
+
130
+ def _signature_keyids(envelope: dict) -> list[str]:
131
+ signatures = envelope.get("signatures", [])
132
+ if isinstance(signatures, dict): # legacy {keyid: sig} form
133
+ return [str(keyid) for keyid in signatures]
134
+ return [str(sig.get("keyid", "?")) for sig in signatures if isinstance(sig, dict)]
135
+
@@ -0,0 +1,130 @@
1
+ """RFC 8785 JSON Canonicalization Scheme (JCS) — the serialization we sign over.
2
+
3
+ securesystemslib's ``encode_canonical`` implements the older OLPC "Canonical
4
+ JSON" draft, which diverges from RFC 8785: it cannot serialize floats at all,
5
+ and orders object keys by Unicode code point instead of UTF-16 code units.
6
+ DSSE payloads here are canonicalized with this module instead, so any
7
+ independent JCS implementation re-derives byte-identical bytes for a signature.
8
+
9
+ Implements RFC 8785 §3.2: ECMAScript ``JSON.stringify`` string escaping and
10
+ ``Number::toString`` number serialization, object keys sorted by UTF-16 code
11
+ units, output encoded as UTF-8.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import math
17
+ from typing import Any
18
+
19
+ # Largest integer exactly representable as an IEEE 754 double (RFC 8785 §3.1:
20
+ # values outside double precision are not interoperable JCS input).
21
+ _MAX_SAFE_INTEGER = (1 << 53) - 1
22
+
23
+
24
+ def canonical_text(value: Any) -> str:
25
+ """Serialize ``value`` to canonical JSON text per RFC 8785."""
26
+ fragments: list[str] = []
27
+ _emit(value, fragments)
28
+ return "".join(fragments)
29
+
30
+
31
+ def canonicalize(value: Any) -> bytes:
32
+ """Serialize ``value`` to canonical JSON bytes per RFC 8785 (UTF-8)."""
33
+ return canonical_text(value).encode("utf-8")
34
+
35
+
36
+ def _emit(value: Any, out: list[str]) -> None:
37
+ if value is None:
38
+ out.append("null")
39
+ elif value is True:
40
+ out.append("true")
41
+ elif value is False:
42
+ out.append("false")
43
+ elif isinstance(value, int):
44
+ # bools were handled above; Python ints are unbounded but JCS input
45
+ # must stay within IEEE 754 double precision.
46
+ if abs(value) > _MAX_SAFE_INTEGER:
47
+ raise ValueError(
48
+ f"JCS: integer {value} exceeds the IEEE 754 double-safe range")
49
+ out.append(str(value))
50
+ elif isinstance(value, float):
51
+ out.append(_number_token(value))
52
+ elif isinstance(value, str):
53
+ out.append(_string_token(value))
54
+ elif isinstance(value, (list, tuple)):
55
+ out.append("[")
56
+ for i, item in enumerate(value):
57
+ if i:
58
+ out.append(",")
59
+ _emit(item, out)
60
+ out.append("]")
61
+ elif isinstance(value, dict):
62
+ out.append("{")
63
+ for i, key in enumerate(_sorted_keys(value)):
64
+ if i:
65
+ out.append(",")
66
+ out.append(_string_token(key))
67
+ out.append(":")
68
+ _emit(value[key], out)
69
+ out.append("}")
70
+ else:
71
+ raise ValueError(
72
+ f"JCS: cannot canonicalize {type(value).__name__} value: {value!r}")
73
+
74
+
75
+ def _sorted_keys(obj: dict) -> list[str]:
76
+ keys = list(obj)
77
+ for key in keys:
78
+ if not isinstance(key, str):
79
+ raise ValueError(
80
+ f"JCS: object keys must be strings, got {type(key).__name__}")
81
+ # UTF-16 code-unit order (RFC 8785 §3.2.3): big-endian UTF-16 bytes compare
82
+ # exactly like the uint16 sequences the spec sorts by. This differs from
83
+ # code-point order whenever non-BMP characters meet U+E000..U+FFFF.
84
+ try:
85
+ keys.sort(key=lambda k: k.encode("utf-16-be"))
86
+ except UnicodeEncodeError as exc:
87
+ raise ValueError(f"JCS: invalid object key: {exc}") from exc
88
+ return keys
89
+
90
+
91
+ def _string_token(text: str) -> str:
92
+ # RFC 8785 input is parsed JSON; unpaired surrogates cannot round-trip
93
+ # through UTF-8 and would silently corrupt signatures.
94
+ try:
95
+ text.encode("utf-8")
96
+ except UnicodeEncodeError as exc:
97
+ raise ValueError(f"JCS: string is not valid Unicode: {exc}") from exc
98
+ # json.dumps(ensure_ascii=False) escapes exactly like JSON.stringify:
99
+ # \b \t \n \f \r, \" and \\, other C0 controls as lowercase \u00xx,
100
+ # everything non-ASCII literal.
101
+ return json.dumps(text, ensure_ascii=False)
102
+
103
+
104
+ def _number_token(value: float) -> str:
105
+ if math.isnan(value) or math.isinf(value):
106
+ raise ValueError(f"JCS: cannot canonicalize non-JSON number {value!r}")
107
+ if value == 0: # 0.0 and -0.0 both serialize as "0" (RFC 8785 §3.1.1)
108
+ return "0"
109
+ sign = "-" if value < 0 else ""
110
+ # repr() yields the same shortest round-trip digits as ECMAScript's
111
+ # Number::toString; re-render those digits in ECMA notation, which
112
+ # switches to exponent form only outside 1e-6 .. 1e21.
113
+ mantissa, _, exp_text = repr(abs(value)).partition("e")
114
+ exponent = int(exp_text) if exp_text else 0
115
+ int_part, _, frac_part = mantissa.partition(".")
116
+ digits = int_part + frac_part
117
+ point = len(int_part) + exponent # digits before the decimal point
118
+ stripped = digits.lstrip("0")
119
+ point -= len(digits) - len(stripped)
120
+ s = stripped.rstrip("0") or "0"
121
+ k, n = len(s), point
122
+ if k <= n <= 21:
123
+ return sign + s + "0" * (n - k)
124
+ if 0 < n <= 21:
125
+ return sign + s[:n] + "." + s[n:]
126
+ if -6 < n <= 0:
127
+ return sign + "0." + "0" * -n + s
128
+ e = n - 1
129
+ return (sign + s[:1] + ("." + s[1:] if k > 1 else "")
130
+ + "e" + ("+" if e >= 0 else "-") + str(abs(e)))