gargoyle-receipts 0.5.2.dev0__tar.gz → 0.5.3.dev0__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 (66) hide show
  1. {gargoyle_receipts-0.5.2.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.5.3.dev0}/PKG-INFO +2 -2
  2. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/README.md +1 -1
  3. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/approval.py +8 -3
  4. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/envelope.py +26 -1
  5. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/evidence.py +12 -8
  6. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/execution.py +8 -4
  7. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/verifier.py +12 -3
  8. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0/gargoyle_receipts.egg-info}/PKG-INFO +2 -2
  9. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +1 -0
  10. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/pyproject.toml +1 -1
  11. gargoyle_receipts-0.5.3.dev0/tests/test_historical.py +82 -0
  12. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/LICENSE +0 -0
  13. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/NOTICE +0 -0
  14. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/__init__.py +0 -0
  15. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/approver_cli.py +0 -0
  16. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/approver_trust.py +0 -0
  17. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/cli.py +0 -0
  18. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/consumer.py +0 -0
  19. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/entra_login.py +0 -0
  20. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/fs_server.py +0 -0
  21. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/identity.py +0 -0
  22. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/issuer.py +0 -0
  23. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/issuer_service.py +0 -0
  24. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/jws.py +0 -0
  25. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/key_record_source.py +0 -0
  26. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/mcp_server.py +0 -0
  27. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/notify.py +0 -0
  28. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/opa.py +0 -0
  29. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/pending_store.py +0 -0
  30. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/policy.py +0 -0
  31. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/published_record_source.py +0 -0
  32. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/replay_cache.py +0 -0
  33. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/rules.py +0 -0
  34. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/static/approve.html +0 -0
  35. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/static/approve.js +0 -0
  36. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  37. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  38. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  39. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  40. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/setup.cfg +0 -0
  41. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_annotations.py +0 -0
  42. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_approver_cli.py +0 -0
  43. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_canonical.py +0 -0
  44. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_cli.py +0 -0
  45. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_console_js.py +0 -0
  46. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_consumer.py +0 -0
  47. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_entra_identity.py +0 -0
  48. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_entra_login.py +0 -0
  49. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_evidence.py +0 -0
  50. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_execution.py +0 -0
  51. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_fs_server.py +0 -0
  52. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_identity.py +0 -0
  53. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_issuer_service.py +0 -0
  54. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_jws.py +0 -0
  55. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_mcp_proxy.py +0 -0
  56. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_mcp_self_issue.py +0 -0
  57. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_mcp_server.py +0 -0
  58. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_notify.py +0 -0
  59. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_opa.py +0 -0
  60. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_policy.py +0 -0
  61. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_published_record.py +0 -0
  62. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_rules.py +0 -0
  63. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_stdio_encoding.py +0 -0
  64. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_threshold.py +0 -0
  65. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_typescript_conformance.py +0 -0
  66. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_wire_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.5.2.dev0
3
+ Version: 0.5.3.dev0
4
4
  Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
5
5
  Author: Justin LaPorte
6
6
  License-Expression: Apache-2.0
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
59
59
 
60
60
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.5.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.5.3.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
63
63
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
64
64
 
65
65
  ## What a receipt gives you
@@ -21,7 +21,7 @@ your MCP server and lets through only what both agreed to.
21
21
 
22
22
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.5.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.5.3.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
25
25
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
26
26
 
27
27
  ## What a receipt gives you
@@ -19,7 +19,7 @@ grant_id is present, so that claim cannot be skipped.
19
19
  from datetime import datetime, timedelta
20
20
  from typing import Any, Dict, List, Optional
21
21
 
22
- from gargoyle.envelope import approval_terms, canonical_json
22
+ from gargoyle.envelope import approval_terms, canonical_json, serializer_for
23
23
  from gargoyle.verifier import not_after_valid
24
24
 
25
25
 
@@ -129,8 +129,11 @@ def verify_grant(
129
129
  gate_key_id: str,
130
130
  now: Optional[datetime] = None,
131
131
  clock_skew: timedelta = timedelta(0),
132
+ serializer: Any = None,
132
133
  ) -> Dict[str, Any]:
133
- """Is `grant` a valid human release of the held decision?
134
+ """Is `grant` a valid human release of the held decision? `serializer`
135
+ is the canonical form the grant was signed under (default: the current
136
+ one; an audit of a historical receipt passes its format's).
134
137
 
135
138
  Returns {"accepted", "reason"}. First failure wins:
136
139
  1. structure -> REF_APPROVAL_MALFORMED
@@ -180,7 +183,7 @@ def verify_grant(
180
183
 
181
184
  # 5. signature verifies (duck-typed; covers everything but the signature)
182
185
  region = {k: v for k, v in grant.items() if k not in _SIGNATURE_EXCLUDED_KEYS}
183
- message = canonical_json(region).encode("utf-8")
186
+ message = (serializer or canonical_json)(region).encode("utf-8")
184
187
  try:
185
188
  public_key.verify(bytes.fromhex(signature_hex), message)
186
189
  except Exception:
@@ -236,6 +239,7 @@ def verify_grants(
236
239
  with the grants that were counted, so the caller can claim each one."""
237
240
  terms = approval_terms(envelope)
238
241
  items = as_grant_list(grants)
242
+ serializer = serializer_for(envelope.get("envelope_version")) or canonical_json
239
243
  seen: List[str] = []
240
244
  for grant in items:
241
245
  g = verify_grant(
@@ -246,6 +250,7 @@ def verify_grants(
246
250
  gate_key_id=envelope.get("issuer_key_id"),
247
251
  now=now,
248
252
  clock_skew=clock_skew,
253
+ serializer=serializer,
249
254
  )
250
255
  if not g["accepted"]:
251
256
  return {"accepted": False, "reason": g["reason"], "grants": []}
@@ -88,6 +88,28 @@ def canonical_json(data: Any) -> str:
88
88
  return "".join(out)
89
89
 
90
90
 
91
+ def canonical_json_legacy(data: Any) -> str:
92
+ """The gargoyle/2 canonical form (Python json.dumps with sort_keys,
93
+ compact separators and ensure_ascii). Kept so that receipts, grants,
94
+ execution receipts and evidence entries written under gargoyle/2 can
95
+ still be verified in an audit. Nothing new is ever signed with it."""
96
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
97
+
98
+
99
+ # Formats an auditor may still verify, each with the serializer its bytes
100
+ # were signed under. Admission (Consumer.check) accepts only ENVELOPE_VERSION.
101
+ HISTORICAL_VERSIONS = {"gargoyle/2": canonical_json_legacy}
102
+
103
+
104
+ def serializer_for(version: Any):
105
+ """The canonical serializer for a receipt format version: the current
106
+ one for ENVELOPE_VERSION, the legacy one for a historical version, None
107
+ for anything else."""
108
+ if version == ENVELOPE_VERSION:
109
+ return canonical_json
110
+ return HISTORICAL_VERSIONS.get(version)
111
+
112
+
91
113
  def _jcs(value: Any, out: list) -> None:
92
114
  if value is None:
93
115
  out.append("null")
@@ -200,8 +222,11 @@ def valid_approval(approval: Any) -> bool:
200
222
 
201
223
 
202
224
  def decision_sha256_of(envelope: Dict[str, Any]) -> str:
225
+ """The hash-region digest, under the serializer of the receipt's own
226
+ format version (a historical receipt hashes as it was issued)."""
203
227
  hashable = {k: v for k, v in envelope.items() if k not in _HASH_EXCLUDED_KEYS}
204
- return _sha256_text(canonical_json(hashable))
228
+ serializer = serializer_for(envelope.get("envelope_version")) or canonical_json
229
+ return _sha256_text(serializer(hashable))
205
230
 
206
231
 
207
232
  def live_pin(
@@ -7,7 +7,7 @@ executor's record of every decision it made, honored or refused, each entry
7
7
  hashed over the one before it:
8
8
 
9
9
  entry = {
10
- "evidence_version": "gargoyle-evidence/1",
10
+ "evidence_version": "gargoyle-evidence/2",
11
11
  "seq": 1, 2, 3 ... (dense, from 1)
12
12
  "prev_sha256": <entry_sha256 of seq-1; 64 zeros for seq 1>,
13
13
  "at": <when the executor decided, ISO 8601, UTC>,
@@ -44,11 +44,12 @@ from datetime import datetime, timezone
44
44
  from pathlib import Path
45
45
  from typing import Any, Dict, Iterable, List, Optional
46
46
 
47
- from gargoyle.envelope import args_sha256, canonical_json
47
+ from gargoyle.envelope import args_sha256, canonical_json, canonical_json_legacy
48
48
  from gargoyle.execution import verify_chain
49
49
  from gargoyle.verifier import verify_provenance
50
50
 
51
- EVIDENCE_VERSION = "gargoyle-evidence/1"
51
+ EVIDENCE_VERSION = "gargoyle-evidence/2" # entry hashes over RFC 8785 canonical JSON
52
+ EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy, EVIDENCE_VERSION: canonical_json}
52
53
  GENESIS = "0" * 64
53
54
 
54
55
  HONORED = "honored"
@@ -67,9 +68,12 @@ _FIELDS = ("evidence_version", "seq", "prev_sha256", "at", "kind", "tool", "args
67
68
 
68
69
 
69
70
  def entry_sha256(entry: Dict[str, Any]) -> str:
70
- """The hash an entry carries: over its canonical JSON without the hash itself."""
71
+ """The hash an entry carries: over its canonical JSON without the hash
72
+ itself, under the serializer of the entry's own evidence_version, so a
73
+ log that spans a format change still verifies end to end."""
71
74
  body = {k: v for k, v in entry.items() if k != "entry_sha256"}
72
- return hashlib.sha256(canonical_json(body).encode("utf-8")).hexdigest()
75
+ serialize = EVIDENCE_VERSIONS.get(entry.get("evidence_version"), canonical_json)
76
+ return hashlib.sha256(serialize(body).encode("utf-8")).hexdigest()
73
77
 
74
78
 
75
79
  def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any, principal: str,
@@ -113,7 +117,7 @@ class EvidenceLog:
113
117
  for line in self.path.read_text(encoding="utf-8").splitlines():
114
118
  if line.strip():
115
119
  last = json.loads(line)
116
- if last.get("evidence_version") == EVIDENCE_VERSION:
120
+ if last.get("evidence_version") in EVIDENCE_VERSIONS:
117
121
  self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
118
122
 
119
123
  def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
@@ -132,7 +136,7 @@ class EvidenceLog:
132
136
 
133
137
  def _well_formed(entry: Any) -> bool:
134
138
  return (isinstance(entry, dict) and set(entry) == set(_FIELDS)
135
- and entry["evidence_version"] == EVIDENCE_VERSION
139
+ and entry["evidence_version"] in EVIDENCE_VERSIONS
136
140
  and isinstance(entry["seq"], int) and entry["seq"] >= 1
137
141
  and isinstance(entry["prev_sha256"], str) and len(entry["prev_sha256"]) == 64
138
142
  and isinstance(entry["at"], str) and entry["kind"] in (HONORED, REFUSED)
@@ -196,7 +200,7 @@ def verify_log(
196
200
  reason = ACCEPT_ENTRY_VERIFIED
197
201
  if e["receipt"] is not None:
198
202
  p = verify_provenance(e["receipt"], pinned_public_keys=issuer_public_keys,
199
- now=_parse_at(e["at"]))
203
+ now=_parse_at(e["at"]), historical=True)
200
204
  ok, reason = p["accepted"], (ACCEPT_ENTRY_VERIFIED if p["accepted"] else p["reason"])
201
205
  counts[e["kind"]] += 1
202
206
  report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
@@ -40,7 +40,7 @@ from datetime import datetime, timezone
40
40
  from typing import Any, Dict, Optional
41
41
 
42
42
  from gargoyle.approval import as_grant_list, verify_grants
43
- from gargoyle.envelope import args_sha256, canonical_json
43
+ from gargoyle.envelope import args_sha256, canonical_json, serializer_for
44
44
  from gargoyle.verifier import verify_provenance
45
45
 
46
46
  EXECUTION_VERSION = "gargoyle-execution/1"
@@ -145,13 +145,16 @@ def verify_execution_receipt(
145
145
  public_key = executor_public_keys.get(key_id)
146
146
  if public_key is None:
147
147
  return _reject(REF_EXEC_KEY_UNKNOWN)
148
+ # The execution receipt was signed under the canonical form of the
149
+ # receipt's format version; an audit of a historical chain uses that.
150
+ serialize = serializer_for(envelope.get("envelope_version")) or canonical_json
148
151
  region = {k: v for k, v in execution.items() if k not in _SIGNATURE_EXCLUDED_KEYS}
149
152
  try:
150
- public_key.verify(bytes.fromhex(signature_hex), canonical_json(region).encode("utf-8"))
153
+ public_key.verify(bytes.fromhex(signature_hex), serialize(region).encode("utf-8"))
151
154
  except Exception:
152
155
  return _reject(REF_EXEC_SIGNATURE_INVALID)
153
156
  for key in _BOUND_FROM_RECEIPT:
154
- if key not in envelope or canonical_json(execution[key]) != canonical_json(envelope[key]):
157
+ if key not in envelope or serialize(execution[key]) != serialize(envelope[key]):
155
158
  return _reject(REF_EXEC_BINDING_MISMATCH)
156
159
  if check_outputs:
157
160
  try:
@@ -190,7 +193,8 @@ def verify_chain(
190
193
  except (ValueError, TypeError):
191
194
  return {"accepted": False, "reason": REF_EXEC_MALFORMED, "steps": steps}
192
195
 
193
- provenance = verify_provenance(envelope, pinned_public_keys=issuer_public_keys, now=executed_at)
196
+ # An audit verifies historical formats; admission (the consumer) does not.
197
+ provenance = verify_provenance(envelope, pinned_public_keys=issuer_public_keys, now=executed_at, historical=True)
194
198
  steps.append(("authorization", provenance["reason"]))
195
199
  if not provenance["accepted"]:
196
200
  return {"accepted": False, "reason": provenance["reason"], "steps": steps}
@@ -46,6 +46,7 @@ from datetime import datetime, timedelta, timezone
46
46
 
47
47
  from gargoyle.envelope import (
48
48
  ENVELOPE_VERSION,
49
+ serializer_for,
49
50
  valid_approval,
50
51
  INVALIDATED,
51
52
  REASSERTED,
@@ -174,6 +175,7 @@ def verify_provenance(
174
175
  key_record_view: Dict[str, Any] = None,
175
176
  now: datetime = None,
176
177
  clock_skew: timedelta = timedelta(0),
178
+ historical: bool = False,
177
179
  ) -> Dict[str, Any]:
178
180
  """Steps 1 and 2 of verify_envelope on their own: the receipt is
179
181
  structurally usable, a trusted issuer key signed it, and it had not
@@ -181,7 +183,12 @@ def verify_provenance(
181
183
  offline on a stored receipt (with `now` set to when it was presented).
182
184
  Returns {"accepted": bool, "reason": str}; the accept reason is
183
185
  ACCEPT_PROVENANCE. Currency and binding are verify_envelope's steps 3
184
- and 4 and are not checked here."""
186
+ and 4 and are not checked here.
187
+
188
+ `historical` lets an audit verify a receipt of an earlier format
189
+ version (envelope.HISTORICAL_VERSIONS) under the serializer it was
190
+ signed with. Admission never sets it: a target honors only the
191
+ current format."""
185
192
  if clock_skew < timedelta(0):
186
193
  raise ValueError("clock_skew must be non-negative")
187
194
  if pinned_public_keys is None and key_record_view is None:
@@ -192,8 +199,10 @@ def verify_provenance(
192
199
  for key in _REQUIRED_ENVELOPE_KEYS:
193
200
  if key not in envelope:
194
201
  return _reject(REF_VERIFY_ENVELOPE_ABSENT)
195
- if envelope.get("envelope_version") != ENVELOPE_VERSION:
202
+ version = envelope.get("envelope_version")
203
+ if version != ENVELOPE_VERSION and not (historical and serializer_for(version) is not None):
196
204
  return _reject(REF_VERIFY_VERSION_UNSUPPORTED)
205
+ serialize = serializer_for(version)
197
206
  rc = envelope["request_context"]
198
207
  if not isinstance(rc, dict):
199
208
  return _reject(REF_VERIFY_ENVELOPE_ABSENT)
@@ -252,7 +261,7 @@ def verify_provenance(
252
261
  for k, v in envelope.items()
253
262
  if k not in ("issuer_signature", "timestamp_utc")
254
263
  }
255
- message = canonical_json(signed_region).encode("utf-8")
264
+ message = serialize(signed_region).encode("utf-8")
256
265
  try:
257
266
  public_key.verify(bytes.fromhex(signature_hex), message)
258
267
  except Exception:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.5.2.dev0
3
+ Version: 0.5.3.dev0
4
4
  Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
5
5
  Author: Justin LaPorte
6
6
  License-Expression: Apache-2.0
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
59
59
 
60
60
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.5.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.5.3.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
63
63
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
64
64
 
65
65
  ## What a receipt gives you
@@ -46,6 +46,7 @@ tests/test_entra_login.py
46
46
  tests/test_evidence.py
47
47
  tests/test_execution.py
48
48
  tests/test_fs_server.py
49
+ tests/test_historical.py
49
50
  tests/test_identity.py
50
51
  tests/test_issuer_service.py
51
52
  tests/test_jws.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.5.2.dev0"
7
+ version = "0.5.3.dev0"
8
8
  description = "Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,82 @@
1
+ """Evidence outlives format changes. A gargoyle/2 receipt (the fixture
2
+ frozen before the move to RFC 8785) is refused at admission but verifies in
3
+ an audit under the serializer it was signed with, grants and execution
4
+ receipt included; an evidence log that spans the change verifies end to end."""
5
+
6
+ import hashlib
7
+ import json
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
12
+
13
+ from gargoyle.consumer import Consumer
14
+ from gargoyle.envelope import ENVELOPE_VERSION, canonical_json_legacy, decision_sha256_of, serializer_for
15
+ from gargoyle.evidence import EVIDENCE_VERSION, HONORED, REFUSED, EvidenceLog, entry_sha256, verify_log
16
+ from gargoyle.execution import ACCEPT_CHAIN_VERIFIED, verify_chain
17
+ from gargoyle.verifier import ACCEPT_PROVENANCE, REF_VERIFY_SIGNATURE_INVALID, REF_VERIFY_VERSION_UNSUPPORTED, verify_provenance
18
+
19
+ V2 = json.loads((Path(__file__).parent / "fixtures" / "wire_vectors_v2.json").read_text(encoding="utf-8"))
20
+ NOW = datetime(2026, 10, 16, 12, 1, tzinfo=timezone.utc)
21
+
22
+
23
+ def _key(label):
24
+ return Ed25519PrivateKey.from_private_bytes(hashlib.sha256(label.encode()).digest())
25
+
26
+
27
+ def _keys():
28
+ issuer, approver, bob, executor = (_key(V2[k] if k in V2 else V2["threshold"][k])
29
+ for k in ("issuer_seed_label", "approver_seed_label", "approver_2_seed_label", "executor_seed_label"))
30
+ return (issuer, {V2["signed"]["issuer_key_id"]: issuer.public_key()},
31
+ {"approver-alice": approver.public_key(), "approver-bob": bob.public_key()},
32
+ {V2["execution"]["executor_key_id"]: executor.public_key()})
33
+
34
+
35
+ def test_the_fixture_is_the_old_format_and_hashes_under_the_old_serializer():
36
+ assert V2["signed"]["envelope_version"] == "gargoyle/2" != ENVELOPE_VERSION
37
+ assert serializer_for("gargoyle/2") is canonical_json_legacy and serializer_for("gargoyle/9") is None
38
+ assert decision_sha256_of(V2["signed"]) == V2["signed"]["decision_sha256"]
39
+
40
+
41
+ def test_admission_refuses_but_an_audit_verifies_the_whole_old_chain():
42
+ _, issuer_keys, approvers, exec_keys = _keys()
43
+ # Admission: the format is not current.
44
+ assert verify_provenance(V2["signed"], pinned_public_keys=issuer_keys, now=NOW)["reason"] == REF_VERIFY_VERSION_UNSUPPORTED
45
+ c = Consumer(target_id=V2["signed"]["target_url"], record=V2["signed"]["policy"], pinned_public_keys=issuer_keys, approver_public_keys=approvers)
46
+ assert c.check(V2["signed"], V2["call"], grant=V2["grant"], now=NOW).reason == REF_VERIFY_VERSION_UNSUPPORTED
47
+ # Audit: historical verification under the gargoyle/2 serializer.
48
+ assert verify_provenance(V2["signed"], pinned_public_keys=issuer_keys, now=NOW, historical=True)["reason"] == ACCEPT_PROVENANCE
49
+ chain = verify_chain(V2["signed"], V2["execution"], grant=V2["grant"], issuer_public_keys=issuer_keys,
50
+ executor_public_keys=exec_keys, approver_public_keys=approvers, outputs=V2["outputs"], check_outputs=True)
51
+ assert chain["reason"] == ACCEPT_CHAIN_VERIFIED
52
+ T = V2["threshold"]
53
+ chain2 = verify_chain(T["signed"], T["execution"], grant=T["grants"], issuer_public_keys=issuer_keys,
54
+ executor_public_keys=exec_keys, approver_public_keys=approvers)
55
+ assert chain2["reason"] == ACCEPT_CHAIN_VERIFIED
56
+ # Historical does not mean lenient: a tampered old receipt still fails.
57
+ tampered = dict(V2["signed"], target_url="https://elsewhere.example/")
58
+ assert verify_provenance(tampered, pinned_public_keys=issuer_keys, now=NOW, historical=True)["reason"] == REF_VERIFY_SIGNATURE_INVALID
59
+
60
+
61
+ def test_an_evidence_log_spanning_the_format_change_verifies(tmp_path):
62
+ _, issuer_keys, approvers, exec_keys = _keys()
63
+ path = tmp_path / "audit.jsonl"
64
+ # Two entries as a gargoyle-evidence/1 writer produced them: hashed with the legacy serializer.
65
+ def v1_entry(seq, prev, kind, tool, reason, receipt=None, grant=None, execution=None, principal="agent-7"):
66
+ e = {"evidence_version": "gargoyle-evidence/1", "seq": seq, "prev_sha256": prev, "at": "2026-10-16T12:01:00+00:00",
67
+ "kind": kind, "tool": tool, "args_sha256": V2["args_sha256"], "principal": principal, "reason": reason,
68
+ "receipt": receipt, "grant": grant, "execution": execution}
69
+ body = dict(e)
70
+ e["entry_sha256"] = hashlib.sha256(canonical_json_legacy(body).encode("utf-8")).hexdigest()
71
+ return e
72
+ e1 = v1_entry(1, "0" * 64, HONORED, "transfer_funds", "CHAIN_VERIFIED", V2["signed"], V2["grant"], V2["execution"])
73
+ e2 = v1_entry(2, e1["entry_sha256"], REFUSED, "delete_file", "DESTRUCTIVE_TOOL")
74
+ assert entry_sha256(e1) == e1["entry_sha256"] # recomputed under the entry's own version
75
+ path.write_text(json.dumps(e1) + "\n" + json.dumps(e2) + "\n", encoding="utf-8")
76
+ # A current writer continues the chain with a version-2 entry.
77
+ e3 = EvidenceLog(str(path)).append(REFUSED, "delete_file", {"path": "x"}, "agent-7", "DESTRUCTIVE_TOOL")
78
+ assert e3["evidence_version"] == EVIDENCE_VERSION and e3["seq"] == 3 and e3["prev_sha256"] == e2["entry_sha256"]
79
+ entries = [json.loads(l) for l in path.read_text(encoding="utf-8").splitlines()]
80
+ v = verify_log(entries, issuer_public_keys=issuer_keys, executor_public_keys=exec_keys, approver_public_keys=approvers)
81
+ assert v["accepted"], [r["reason"] for r in v["entries"]]
82
+ assert [r["reason"] for r in v["entries"]][0] == ACCEPT_CHAIN_VERIFIED