gargoyle-receipts 0.16.2.dev0__tar.gz → 0.16.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.16.2.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.3.dev0}/PKG-INFO +6 -3
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/README.md +5 -2
- gargoyle_receipts-0.16.3.dev0/gargoyle/__main__.py +9 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approval.py +19 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approvers.py +48 -3
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/cli.py +10 -7
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0/gargoyle_receipts.egg-info}/PKG-INFO +6 -3
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +1 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_approvers.py +36 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_cli.py +7 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/evidence.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/issuer_service.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/mcp_server.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/reconcile.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_wire_spec.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_witness.py +0 -0
{gargoyle_receipts-0.16.2.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.3.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.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.16.
|
|
62
|
+
Status: pre-release (`0.16.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
|
|
@@ -298,7 +298,10 @@ stay on file so earlier grants still audit; new ones are refused at the
|
|
|
298
298
|
issuer and at every middleware on its next held call. Revocation retires
|
|
299
299
|
a key, not a person: while the Approver role stands, the person can sign
|
|
300
300
|
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
301
|
-
is never accepted again.
|
|
301
|
+
is never accepted again. Every key an approver held stays in the file
|
|
302
|
+
(`history`), so an audit of grants released under an earlier key still
|
|
303
|
+
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
304
|
+
before auditing. Approvers, and
|
|
302
305
|
only approvers, see the whole queue and the witness; everyone else sees
|
|
303
306
|
their own holds. Then paste the server entry from
|
|
304
307
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
@@ -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.16.
|
|
24
|
+
Status: pre-release (`0.16.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
|
|
@@ -260,7 +260,10 @@ stay on file so earlier grants still audit; new ones are refused at the
|
|
|
260
260
|
issuer and at every middleware on its next held call. Revocation retires
|
|
261
261
|
a key, not a person: while the Approver role stands, the person can sign
|
|
262
262
|
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
263
|
-
is never accepted again.
|
|
263
|
+
is never accepted again. Every key an approver held stays in the file
|
|
264
|
+
(`history`), so an audit of grants released under an earlier key still
|
|
265
|
+
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
266
|
+
before auditing. Approvers, and
|
|
264
267
|
only approvers, see the whole queue and the witness; everyone else sees
|
|
265
268
|
their own holds. Then paste the server entry from
|
|
266
269
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
@@ -32,6 +32,21 @@ _SIGNATURE_EXCLUDED_KEYS = ("approver_signature",)
|
|
|
32
32
|
|
|
33
33
|
# Refusal codes.
|
|
34
34
|
REF_APPROVAL_MALFORMED = "REF_APPROVAL_MALFORMED"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _AnyOf:
|
|
38
|
+
"""A verifying key made of several: the signature is valid if any verifies it."""
|
|
39
|
+
def __init__(self, keys: list) -> None:
|
|
40
|
+
self.keys = keys
|
|
41
|
+
|
|
42
|
+
def verify(self, signature: bytes, message: bytes) -> None:
|
|
43
|
+
for key in self.keys[:-1]:
|
|
44
|
+
try:
|
|
45
|
+
key.verify(signature, message)
|
|
46
|
+
return
|
|
47
|
+
except Exception:
|
|
48
|
+
continue
|
|
49
|
+
self.keys[-1].verify(signature, message)
|
|
35
50
|
REF_APPROVAL_MISSING_GRANT_ID = "REF_APPROVAL_MISSING_GRANT_ID"
|
|
36
51
|
REF_APPROVAL_SIGNATURE_INVALID = "REF_APPROVAL_SIGNATURE_INVALID"
|
|
37
52
|
REF_APPROVAL_KEY_UNKNOWN = "REF_APPROVAL_KEY_UNKNOWN"
|
|
@@ -178,6 +193,10 @@ def verify_grant(
|
|
|
178
193
|
|
|
179
194
|
# 4. known approver key
|
|
180
195
|
public_key = approver_public_keys.get(key_id)
|
|
196
|
+
if isinstance(public_key, (list, tuple)):
|
|
197
|
+
# Several keys for one approver id: an audit of grants from before the
|
|
198
|
+
# approver's browser key was replaced or revoked. Any of them may verify.
|
|
199
|
+
public_key = _AnyOf(list(public_key)) if public_key else None
|
|
181
200
|
if public_key is None:
|
|
182
201
|
return _reject(REF_APPROVAL_KEY_UNKNOWN)
|
|
183
202
|
|
|
@@ -26,7 +26,7 @@ import stat
|
|
|
26
26
|
import threading
|
|
27
27
|
from datetime import datetime, timezone
|
|
28
28
|
from pathlib import Path
|
|
29
|
-
from typing import Any, Dict, Optional
|
|
29
|
+
from typing import List, Any, Dict, Optional
|
|
30
30
|
|
|
31
31
|
ENV_APPROVERS_PATH = "GARGOYLE_APPROVERS_PATH"
|
|
32
32
|
ENV_APPROVER_ROLE = "GARGOYLE_APPROVER_ROLE" # Entra app role an enrolling token must carry (default Approver)
|
|
@@ -53,6 +53,7 @@ class ApproverRegistry:
|
|
|
53
53
|
self._lock = threading.Lock()
|
|
54
54
|
self._approvers: Dict[str, Dict[str, Any]] = {}
|
|
55
55
|
self._revoked: Dict[str, Dict[str, Any]] = {} # former approvers, kept so old grants still verify in audits
|
|
56
|
+
self._history: Dict[str, List[Dict[str, Any]]] = {} # every key each UPN held and how it ended (replaced, revoked)
|
|
56
57
|
self._mtime: Optional[float] = None
|
|
57
58
|
self._load()
|
|
58
59
|
|
|
@@ -67,7 +68,16 @@ class ApproverRegistry:
|
|
|
67
68
|
for upn, entry in (doc.get("revoked") or {}).items():
|
|
68
69
|
if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
|
|
69
70
|
revoked[upn] = dict(entry)
|
|
70
|
-
|
|
71
|
+
history: Dict[str, List[Dict[str, Any]]] = {}
|
|
72
|
+
for upn, items in (doc.get("history") or {}).items():
|
|
73
|
+
if isinstance(items, list):
|
|
74
|
+
history[upn] = [dict(h) for h in items if isinstance(h, dict) and valid_public_key_hex(h.get("public_key_hex"))]
|
|
75
|
+
for upn, e in revoked.items():
|
|
76
|
+
# A file from before history existed: its revoked record is the one former key known.
|
|
77
|
+
if not any(h.get("public_key_hex") == e["public_key_hex"] for h in history.get(upn, [])):
|
|
78
|
+
history.setdefault(upn, []).append({**{k: v for k, v in e.items() if k not in ("revoked_at", "revoked_by")},
|
|
79
|
+
"ended_at": e.get("revoked_at"), "ended_by": e.get("revoked_by"), "reason": "revoked"})
|
|
80
|
+
self._approvers, self._revoked, self._history = approvers, revoked, history
|
|
71
81
|
self._mtime = self.path.stat().st_mtime
|
|
72
82
|
|
|
73
83
|
def _refresh(self) -> None:
|
|
@@ -89,6 +99,8 @@ class ApproverRegistry:
|
|
|
89
99
|
doc = {"approvers": self._approvers}
|
|
90
100
|
if self._revoked:
|
|
91
101
|
doc["revoked"] = self._revoked
|
|
102
|
+
if self._history:
|
|
103
|
+
doc["history"] = self._history
|
|
92
104
|
self.path.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
93
105
|
try:
|
|
94
106
|
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
|
@@ -120,13 +132,45 @@ class ApproverRegistry:
|
|
|
120
132
|
entry["tid"] = tid
|
|
121
133
|
with self._lock:
|
|
122
134
|
self._refresh()
|
|
123
|
-
if
|
|
135
|
+
if public_key_hex in self._revoked_keys():
|
|
124
136
|
raise RevokedKeyError("that key was revoked; enroll a new one")
|
|
125
137
|
replaced = upn in self._approvers
|
|
138
|
+
if replaced and self._approvers[upn].get("public_key_hex") != public_key_hex:
|
|
139
|
+
# The superseded key is kept: grants signed under it still audit.
|
|
140
|
+
self._remember(upn, self._approvers[upn], "replaced", enrolled_by or upn, now)
|
|
126
141
|
self._approvers[upn] = entry
|
|
127
142
|
self._save()
|
|
128
143
|
return {"upn": upn, "replaced": replaced, **entry}
|
|
129
144
|
|
|
145
|
+
def _remember(self, upn: str, entry: Dict[str, Any], reason: str, ended_by: str, now: Optional[datetime]) -> None:
|
|
146
|
+
hist = self._history.setdefault(upn, [])
|
|
147
|
+
if not any(h.get("public_key_hex") == entry.get("public_key_hex") for h in hist):
|
|
148
|
+
hist.append({**{k: v for k, v in entry.items() if k not in ("revoked_at", "revoked_by")},
|
|
149
|
+
"ended_at": (now or datetime.now(timezone.utc)).isoformat(), "ended_by": ended_by, "reason": reason})
|
|
150
|
+
|
|
151
|
+
def _revoked_keys(self) -> set:
|
|
152
|
+
"""Every public key ever revoked, under any UPN: none may enroll again."""
|
|
153
|
+
return ({e.get("public_key_hex") for e in self._revoked.values()}
|
|
154
|
+
| {h.get("public_key_hex") for hist in self._history.values() for h in hist if h.get("reason") == "revoked"})
|
|
155
|
+
|
|
156
|
+
def audit_keys_hex(self) -> Dict[str, List[str]]:
|
|
157
|
+
"""{upn: [public_key_hex, ...]}: the current key first, then every key
|
|
158
|
+
the UPN held before. An audit verifies a grant with any of them; the
|
|
159
|
+
live service and middleware trust only the current one."""
|
|
160
|
+
with self._lock:
|
|
161
|
+
self._refresh()
|
|
162
|
+
out: Dict[str, List[str]] = {}
|
|
163
|
+
for upn, e in self._approvers.items():
|
|
164
|
+
out[upn] = [e["public_key_hex"]]
|
|
165
|
+
for upn, hist in self._history.items():
|
|
166
|
+
for h in hist:
|
|
167
|
+
if h["public_key_hex"] not in out.setdefault(upn, []):
|
|
168
|
+
out[upn].append(h["public_key_hex"])
|
|
169
|
+
for upn, e in self._revoked.items():
|
|
170
|
+
if e["public_key_hex"] not in out.setdefault(upn, []):
|
|
171
|
+
out[upn].append(e["public_key_hex"])
|
|
172
|
+
return out
|
|
173
|
+
|
|
130
174
|
def revoke(self, upn: str, *, revoked_by: Optional[str] = None, now: Optional[datetime] = None) -> bool:
|
|
131
175
|
"""Stop trusting `upn`'s key from now on. The entry moves to the
|
|
132
176
|
revoked list with when and by whom, so an audit of earlier evidence
|
|
@@ -139,6 +183,7 @@ class ApproverRegistry:
|
|
|
139
183
|
return False
|
|
140
184
|
self._revoked[upn] = {**entry, "revoked_at": (now or datetime.now(timezone.utc)).isoformat(),
|
|
141
185
|
"revoked_by": revoked_by or upn}
|
|
186
|
+
self._remember(upn, entry, "revoked", revoked_by or upn, now)
|
|
142
187
|
self._save()
|
|
143
188
|
return True
|
|
144
189
|
|
|
@@ -39,6 +39,7 @@ the response metadata; `gargoyle audit` verifies them later, offline.
|
|
|
39
39
|
|
|
40
40
|
import argparse
|
|
41
41
|
import json
|
|
42
|
+
from typing import Any, Dict
|
|
42
43
|
import os
|
|
43
44
|
import re
|
|
44
45
|
import stat
|
|
@@ -364,15 +365,17 @@ def cmd_audit(args) -> int:
|
|
|
364
365
|
from gargoyle.execution import verify_chain
|
|
365
366
|
|
|
366
367
|
keys = json.loads((Path(args.out).resolve() / "public-keys.json").read_text())
|
|
367
|
-
|
|
368
|
-
|
|
368
|
+
one = lambda h: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h))
|
|
369
|
+
load = lambda m: {k: ([one(x) for x in v] if isinstance(v, list) else one(v)) for k, v in m.items()}
|
|
370
|
+
approvers: Dict[str, Any] = dict(keys["approver"])
|
|
369
371
|
enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
|
|
370
372
|
if enrolled.exists():
|
|
371
|
-
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
|
|
375
|
-
|
|
373
|
+
from gargoyle.approvers import ApproverRegistry
|
|
374
|
+
# Every key each approver ever held, current first: grants signed
|
|
375
|
+
# under a key since replaced in the browser or revoked still have to
|
|
376
|
+
# verify. Only the live service and middleware are limited to the
|
|
377
|
+
# current key. Copy the issuer's file beside the evidence first.
|
|
378
|
+
approvers.update(ApproverRegistry(str(enrolled)).audit_keys_hex())
|
|
376
379
|
trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
|
|
377
380
|
approver_public_keys=load(approvers))
|
|
378
381
|
if args.log:
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.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.16.
|
|
3
|
+
Version: 0.16.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.16.
|
|
62
|
+
Status: pre-release (`0.16.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
|
|
@@ -298,7 +298,10 @@ stay on file so earlier grants still audit; new ones are refused at the
|
|
|
298
298
|
issuer and at every middleware on its next held call. Revocation retires
|
|
299
299
|
a key, not a person: while the Approver role stands, the person can sign
|
|
300
300
|
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
301
|
-
is never accepted again.
|
|
301
|
+
is never accepted again. Every key an approver held stays in the file
|
|
302
|
+
(`history`), so an audit of grants released under an earlier key still
|
|
303
|
+
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
304
|
+
before auditing. Approvers, and
|
|
302
305
|
only approvers, see the whole queue and the witness; everyone else sees
|
|
303
306
|
their own holds. Then paste the server entry from
|
|
304
307
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gargoyle-receipts"
|
|
7
|
-
version = "0.16.
|
|
7
|
+
version = "0.16.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"
|
|
@@ -204,3 +204,39 @@ def test_an_operators_revocation_on_the_host_reaches_the_witness(tmp_path):
|
|
|
204
204
|
c.get("/approvers")
|
|
205
205
|
revoked = [e for e in events if e["event"] == "revoked"]
|
|
206
206
|
assert [(e["approver"], e["by"]) for e in revoked] == [("dana@contoso.com", "operator:root"), ("lee@contoso.com", "lee@contoso.com")]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_every_key_an_approver_held_stays_for_audits(tmp_path):
|
|
210
|
+
"""The client audit of 2026-10-20 failed a row released under a key that a
|
|
211
|
+
later browser enrollment had replaced: the registry had dropped it. Now
|
|
212
|
+
every key a UPN held is kept (history: replaced or revoked, when, by whom),
|
|
213
|
+
the audit map carries them all, and a grant verifies with any of them."""
|
|
214
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
215
|
+
from gargoyle.approval import ACCEPT_GRANT_VALID, build_grant, sign_grant, verify_grant
|
|
216
|
+
from gargoyle.approvers import RevokedKeyError
|
|
217
|
+
path = tmp_path / "a.json"
|
|
218
|
+
reg = ApproverRegistry(str(path))
|
|
219
|
+
k1, k2, k3 = (Ed25519PrivateKey.generate() for _ in range(3))
|
|
220
|
+
reg.enroll("dana@contoso.com", pub_hex(k1))
|
|
221
|
+
assert reg.enroll("dana@contoso.com", pub_hex(k2))["replaced"] is True # a new browser
|
|
222
|
+
assert reg.revoke("dana@contoso.com", revoked_by="operator:root")
|
|
223
|
+
reg.enroll("dana@contoso.com", pub_hex(k3))
|
|
224
|
+
assert reg.audit_keys_hex() == {"dana@contoso.com": [pub_hex(k3), pub_hex(k1), pub_hex(k2)]}
|
|
225
|
+
doc = json.loads(path.read_text())
|
|
226
|
+
assert [(h["reason"], h["ended_by"]) for h in doc["history"]["dana@contoso.com"]] == [("replaced", "dana@contoso.com"), ("revoked", "operator:root")]
|
|
227
|
+
now = datetime.now(timezone.utc)
|
|
228
|
+
keys = {u: [Ed25519PublicKey.from_public_bytes(bytes.fromhex(x)) for x in hs] for u, hs in reg.audit_keys_hex().items()}
|
|
229
|
+
for k in (k1, k2, k3):
|
|
230
|
+
g = sign_grant(build_grant(decision_sha256="a" * 64, approval_request_id="r", grant_id="g", not_after=now + timedelta(minutes=5)), k, "dana@contoso.com")
|
|
231
|
+
v = verify_grant(g, expected_decision_sha256="a" * 64, expected_approval_request_id="r", approver_public_keys=keys, gate_key_id="issuer-1", now=now)
|
|
232
|
+
assert v["accepted"] is True and v["reason"] == ACCEPT_GRANT_VALID
|
|
233
|
+
stranger = sign_grant(build_grant(decision_sha256="a" * 64, approval_request_id="r", grant_id="g", not_after=now + timedelta(minutes=5)),
|
|
234
|
+
Ed25519PrivateKey.generate(), "dana@contoso.com")
|
|
235
|
+
assert verify_grant(stranger, expected_decision_sha256="a" * 64, expected_approval_request_id="r", approver_public_keys=keys, gate_key_id="issuer-1", now=now)["accepted"] is False
|
|
236
|
+
# The revoked key k2 is refused for anyone, from history, even after Dana's revoked record is gone.
|
|
237
|
+
with pytest.raises(RevokedKeyError):
|
|
238
|
+
reg.enroll("lee@contoso.com", pub_hex(k2))
|
|
239
|
+
# A file from before history existed: its revoked record seeds the history.
|
|
240
|
+
old = tmp_path / "old.json"
|
|
241
|
+
old.write_text(json.dumps({"approvers": {}, "revoked": {"x@contoso.com": {"public_key_hex": pub_hex(k1), "revoked_at": "t", "revoked_by": "b"}}}))
|
|
242
|
+
assert ApproverRegistry(str(old)).audit_keys_hex() == {"x@contoso.com": [pub_hex(k1)]}
|
|
@@ -136,3 +136,10 @@ def test_approvers_list_and_revoke_on_the_issuer_host(tmp_path, capsys):
|
|
|
136
136
|
assert doc["approvers"] == {} and doc["revoked"]["dana@contoso.com"]["revoked_by"].startswith("operator:")
|
|
137
137
|
assert main(["approvers", "list", "--out", str(out)]) == 0
|
|
138
138
|
assert "revoked: dana@contoso.com" in capsys.readouterr().out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_the_package_runs_as_a_module():
|
|
142
|
+
"""`python -m gargoyle --help` works where gargoyle.exe is not on PATH."""
|
|
143
|
+
import subprocess, sys
|
|
144
|
+
r = subprocess.run([sys.executable, "-m", "gargoyle", "--help"], capture_output=True, text=True, timeout=60)
|
|
145
|
+
assert r.returncode == 0 and "audit" in r.stdout
|
|
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.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/key_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
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.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
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/specs/graph-reference.json
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/static/approve.html
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
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_entra_identity.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
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_issuer_service.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_self_issue.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_stdio_encoding.py
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|