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.
- {gargoyle_receipts-0.5.2.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.5.3.dev0}/PKG-INFO +2 -2
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/README.md +1 -1
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/approval.py +8 -3
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/envelope.py +26 -1
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/evidence.py +12 -8
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/execution.py +8 -4
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/verifier.py +12 -3
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0/gargoyle_receipts.egg-info}/PKG-INFO +2 -2
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +1 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/pyproject.toml +1 -1
- gargoyle_receipts-0.5.3.dev0/tests/test_historical.py +82 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/cli.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/issuer_service.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/mcp_server.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.5.2.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.5.3.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.5.
|
|
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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.5.
|
|
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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
23
23
|
|
|
24
|
-
Status: pre-release (`0.5.
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
|
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
|
-
|
|
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")
|
|
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"]
|
|
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),
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
{gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0/gargoyle_receipts.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.5.
|
|
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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.5.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gargoyle-receipts"
|
|
7
|
-
version = "0.5.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/gargoyle/published_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.5.3.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|