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.
Files changed (84) hide show
  1. {gargoyle_receipts-0.16.2.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.3.dev0}/PKG-INFO +6 -3
  2. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/README.md +5 -2
  3. gargoyle_receipts-0.16.3.dev0/gargoyle/__main__.py +9 -0
  4. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approval.py +19 -0
  5. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approvers.py +48 -3
  6. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/cli.py +10 -7
  7. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0/gargoyle_receipts.egg-info}/PKG-INFO +6 -3
  8. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +1 -0
  9. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/pyproject.toml +1 -1
  10. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_approvers.py +36 -0
  11. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_cli.py +7 -0
  12. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/LICENSE +0 -0
  13. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/NOTICE +0 -0
  14. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/__init__.py +0 -0
  15. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/adapters.py +0 -0
  16. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approver_cli.py +0 -0
  17. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approver_trust.py +0 -0
  18. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/consumer.py +0 -0
  19. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/entra_login.py +0 -0
  20. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/envelope.py +0 -0
  21. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/evidence.py +0 -0
  22. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/execution.py +0 -0
  23. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/fs_server.py +0 -0
  24. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/holdstore.py +0 -0
  25. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/identity.py +0 -0
  26. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/issuer.py +0 -0
  27. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/issuer_service.py +0 -0
  28. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/jws.py +0 -0
  29. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/key_record_source.py +0 -0
  30. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/mcp_server.py +0 -0
  31. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/notify.py +0 -0
  32. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/opa.py +0 -0
  33. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/pending_store.py +0 -0
  34. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/plans.py +0 -0
  35. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/policy.py +0 -0
  36. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/published_record_source.py +0 -0
  37. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/reconcile.py +0 -0
  38. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/remote.py +0 -0
  39. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/replay_cache.py +0 -0
  40. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/revert.py +0 -0
  41. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/rules.py +0 -0
  42. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/specs/graph-reference.json +0 -0
  43. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/state.py +0 -0
  44. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/static/approve.html +0 -0
  45. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/static/approve.js +0 -0
  46. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/verifier.py +0 -0
  47. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/witness.py +0 -0
  48. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  49. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  50. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  51. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  52. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/setup.cfg +0 -0
  53. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_annotations.py +0 -0
  54. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_approver_cli.py +0 -0
  55. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_canonical.py +0 -0
  56. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_console_js.py +0 -0
  57. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_consumer.py +0 -0
  58. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_entra_identity.py +0 -0
  59. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_entra_login.py +0 -0
  60. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_evidence.py +0 -0
  61. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_execution.py +0 -0
  62. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_fs_server.py +0 -0
  63. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_graph.py +0 -0
  64. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_historical.py +0 -0
  65. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_identity.py +0 -0
  66. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_issuer_service.py +0 -0
  67. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_jws.py +0 -0
  68. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_proxy.py +0 -0
  69. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_self_issue.py +0 -0
  70. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_server.py +0 -0
  71. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_notify.py +0 -0
  72. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_opa.py +0 -0
  73. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_plans.py +0 -0
  74. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_policy.py +0 -0
  75. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_published_record.py +0 -0
  76. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_remote.py +0 -0
  77. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_revert.py +0 -0
  78. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_rules.py +0 -0
  79. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_state.py +0 -0
  80. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_stdio_encoding.py +0 -0
  81. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_threshold.py +0 -0
  82. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_typescript_conformance.py +0 -0
  83. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_wire_spec.py +0 -0
  84. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_witness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.16.2.dev0
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.16.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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. Approvers, and
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.16.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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. Approvers, and
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.
@@ -0,0 +1,9 @@
1
+ """`python -m gargoyle` is the CLI: on a Windows Store Python the Scripts folder
2
+ that holds gargoyle.exe is usually not on PATH."""
3
+
4
+ import sys
5
+
6
+ from gargoyle.cli import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
@@ -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
- self._approvers, self._revoked = approvers, revoked
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 any(e.get("public_key_hex") == public_key_hex for e in self._revoked.values()):
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
- load = lambda m: {k: Ed25519PublicKey.from_public_bytes(bytes.fromhex(v)) for k, v in m.items()}
368
- approvers = dict(keys["approver"])
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
- doc = json.loads(enrolled.read_text())
372
- # Revoked approvers too: the audit verifies what was signed while they
373
- # were trusted. Only the live service and middleware refuse them.
374
- approvers.update({u: e["public_key_hex"] for u, e in doc.get("revoked", {}).items()})
375
- approvers.update({u: e["public_key_hex"] for u, e in doc.get("approvers", {}).items()})
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.16.2.dev0
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.16.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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. Approvers, and
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.
@@ -3,6 +3,7 @@ NOTICE
3
3
  README.md
4
4
  pyproject.toml
5
5
  gargoyle/__init__.py
6
+ gargoyle/__main__.py
6
7
  gargoyle/adapters.py
7
8
  gargoyle/approval.py
8
9
  gargoyle/approver_cli.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.16.2.dev0"
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