gargoyle-receipts 0.16.0.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.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.3.dev0}/PKG-INFO +10 -3
  2. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/README.md +9 -2
  3. gargoyle_receipts-0.16.3.dev0/gargoyle/__main__.py +9 -0
  4. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approval.py +19 -0
  5. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approvers.py +66 -4
  6. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/cli.py +15 -10
  7. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/issuer_service.py +80 -6
  8. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/static/approve.html +13 -3
  9. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0/gargoyle_receipts.egg-info}/PKG-INFO +10 -3
  10. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +1 -0
  11. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/pyproject.toml +1 -1
  12. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_approvers.py +73 -2
  13. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_cli.py +7 -0
  14. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_issuer_service.py +67 -1
  15. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/LICENSE +0 -0
  16. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/NOTICE +0 -0
  17. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/__init__.py +0 -0
  18. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/adapters.py +0 -0
  19. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approver_cli.py +0 -0
  20. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approver_trust.py +0 -0
  21. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/consumer.py +0 -0
  22. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/entra_login.py +0 -0
  23. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/envelope.py +0 -0
  24. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/evidence.py +0 -0
  25. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/execution.py +0 -0
  26. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/fs_server.py +0 -0
  27. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/holdstore.py +0 -0
  28. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/identity.py +0 -0
  29. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/issuer.py +0 -0
  30. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/jws.py +0 -0
  31. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/key_record_source.py +0 -0
  32. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/mcp_server.py +0 -0
  33. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/notify.py +0 -0
  34. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/opa.py +0 -0
  35. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/pending_store.py +0 -0
  36. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/plans.py +0 -0
  37. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/policy.py +0 -0
  38. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/published_record_source.py +0 -0
  39. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/reconcile.py +0 -0
  40. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/remote.py +0 -0
  41. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/replay_cache.py +0 -0
  42. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/revert.py +0 -0
  43. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/rules.py +0 -0
  44. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/specs/graph-reference.json +0 -0
  45. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/state.py +0 -0
  46. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/static/approve.js +0 -0
  47. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/verifier.py +0 -0
  48. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/witness.py +0 -0
  49. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  50. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  51. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  52. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  53. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/setup.cfg +0 -0
  54. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_annotations.py +0 -0
  55. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_approver_cli.py +0 -0
  56. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_canonical.py +0 -0
  57. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_console_js.py +0 -0
  58. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_consumer.py +0 -0
  59. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_entra_identity.py +0 -0
  60. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_entra_login.py +0 -0
  61. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_evidence.py +0 -0
  62. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_execution.py +0 -0
  63. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_fs_server.py +0 -0
  64. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_graph.py +0 -0
  65. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_historical.py +0 -0
  66. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_identity.py +0 -0
  67. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_jws.py +0 -0
  68. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_proxy.py +0 -0
  69. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_self_issue.py +0 -0
  70. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_server.py +0 -0
  71. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_notify.py +0 -0
  72. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_opa.py +0 -0
  73. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_plans.py +0 -0
  74. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_policy.py +0 -0
  75. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_published_record.py +0 -0
  76. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_remote.py +0 -0
  77. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_revert.py +0 -0
  78. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_rules.py +0 -0
  79. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_state.py +0 -0
  80. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_stdio_encoding.py +0 -0
  81. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_threshold.py +0 -0
  82. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_typescript_conformance.py +0 -0
  83. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_wire_spec.py +0 -0
  84. {gargoyle_receipts-0.16.0.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.0.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.0.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
@@ -295,7 +295,13 @@ person. An approver retires a lost or replaced key with
295
295
  anyone with `gargoyle approvers revoke <upn>` on the issuer's host, and
296
296
  the running service picks the change up without a restart. Revoked keys
297
297
  stay on file so earlier grants still audit; new ones are refused at the
298
- issuer and at every middleware on its next held call. Approvers, and
298
+ issuer and at every middleware on its next held call. Revocation retires
299
+ a key, not a person: while the Approver role stands, the person can sign
300
+ in and enroll a fresh key, so remove the role too; the revoked key itself
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
299
305
  only approvers, see the whole queue and the witness; everyone else sees
300
306
  their own holds. Then paste the server entry from
301
307
  `gargoyle-deploy/mcp-config.json` into your MCP client.
@@ -390,6 +396,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
390
396
  | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list; `gargoyle approvers list|revoke`) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
391
397
  | issuer `GARGOYLE_ENTRA_TENANT` + `GARGOYLE_ISSUER_AUDIENCE` | trust the tenant's tokens; the audience is required with a tenant (the service will not start without it), and a directory token for any other tenant is `REF_IDENTITY_WRONG_TENANT` |
392
398
  | issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
399
+ | issuer `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
393
400
  | issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
394
401
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
395
402
  | `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
@@ -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.0.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
@@ -257,7 +257,13 @@ person. An approver retires a lost or replaced key with
257
257
  anyone with `gargoyle approvers revoke <upn>` on the issuer's host, and
258
258
  the running service picks the change up without a restart. Revoked keys
259
259
  stay on file so earlier grants still audit; new ones are refused at the
260
- issuer and at every middleware on its next held call. Approvers, and
260
+ issuer and at every middleware on its next held call. Revocation retires
261
+ a key, not a person: while the Approver role stands, the person can sign
262
+ in and enroll a fresh key, so remove the role too; the revoked key itself
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
261
267
  only approvers, see the whole queue and the witness; everyone else sees
262
268
  their own holds. Then paste the server entry from
263
269
  `gargoyle-deploy/mcp-config.json` into your MCP client.
@@ -352,6 +358,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
352
358
  | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list; `gargoyle approvers list|revoke`) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
353
359
  | issuer `GARGOYLE_ENTRA_TENANT` + `GARGOYLE_ISSUER_AUDIENCE` | trust the tenant's tokens; the audience is required with a tenant (the service will not start without it), and a directory token for any other tenant is `REF_IDENTITY_WRONG_TENANT` |
354
360
  | issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
361
+ | issuer `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
355
362
  | issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
356
363
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
357
364
  | `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
@@ -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)
@@ -35,6 +35,11 @@ ENV_APPROVER_GROUPS = "GARGOYLE_APPROVER_GROUPS" # or: comma-separated group
35
35
  DEFAULT_APPROVER_ROLE = "Approver"
36
36
 
37
37
 
38
+ class RevokedKeyError(ValueError):
39
+ """The public key offered for enrollment was revoked before: a revoked
40
+ key stays revoked, the browser must generate a new one."""
41
+
42
+
38
43
  def valid_public_key_hex(value: Any) -> bool:
39
44
  return isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value)
40
45
 
@@ -48,6 +53,7 @@ class ApproverRegistry:
48
53
  self._lock = threading.Lock()
49
54
  self._approvers: Dict[str, Dict[str, Any]] = {}
50
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)
51
57
  self._mtime: Optional[float] = None
52
58
  self._load()
53
59
 
@@ -62,7 +68,16 @@ class ApproverRegistry:
62
68
  for upn, entry in (doc.get("revoked") or {}).items():
63
69
  if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
64
70
  revoked[upn] = dict(entry)
65
- 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
66
81
  self._mtime = self.path.stat().st_mtime
67
82
 
68
83
  def _refresh(self) -> None:
@@ -84,6 +99,8 @@ class ApproverRegistry:
84
99
  doc = {"approvers": self._approvers}
85
100
  if self._revoked:
86
101
  doc["revoked"] = self._revoked
102
+ if self._history:
103
+ doc["history"] = self._history
87
104
  self.path.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n", encoding="utf-8")
88
105
  try:
89
106
  os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
@@ -97,7 +114,11 @@ class ApproverRegistry:
97
114
  UPN (one key per approver; a new browser re-enrolls). The directory's
98
115
  immutable object id and tenant id are kept beside the UPN when the
99
116
  token carried them: the UPN is the name people read, the oid is what
100
- survives a rename."""
117
+ survives a rename. A key on the revoked list is never enrolled again,
118
+ for anyone (RevokedKeyError): the live exercise of 2026-10-20 showed a
119
+ browser re-enrolling the very key the operator had just revoked. The
120
+ revoked record stays when a fresh key enrolls, so grants signed under
121
+ the old key still audit."""
101
122
  if not isinstance(upn, str) or not upn:
102
123
  raise ValueError("upn must be a non-empty string")
103
124
  if not valid_public_key_hex(public_key_hex):
@@ -111,12 +132,45 @@ class ApproverRegistry:
111
132
  entry["tid"] = tid
112
133
  with self._lock:
113
134
  self._refresh()
135
+ if public_key_hex in self._revoked_keys():
136
+ raise RevokedKeyError("that key was revoked; enroll a new one")
114
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)
115
141
  self._approvers[upn] = entry
116
- self._revoked.pop(upn, None) # enrolling again after a revocation is a fresh key
117
142
  self._save()
118
143
  return {"upn": upn, "replaced": replaced, **entry}
119
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
+
120
174
  def revoke(self, upn: str, *, revoked_by: Optional[str] = None, now: Optional[datetime] = None) -> bool:
121
175
  """Stop trusting `upn`'s key from now on. The entry moves to the
122
176
  revoked list with when and by whom, so an audit of earlier evidence
@@ -129,6 +183,7 @@ class ApproverRegistry:
129
183
  return False
130
184
  self._revoked[upn] = {**entry, "revoked_at": (now or datetime.now(timezone.utc)).isoformat(),
131
185
  "revoked_by": revoked_by or upn}
186
+ self._remember(upn, entry, "revoked", revoked_by or upn, now)
132
187
  self._save()
133
188
  return True
134
189
 
@@ -146,6 +201,13 @@ class ApproverRegistry:
146
201
  self._refresh()
147
202
  return {upn: e["public_key_hex"] for upn, e in self._approvers.items()}
148
203
 
204
+ def revoked_entries(self) -> Dict[str, Dict[str, Any]]:
205
+ """The revoked list with when and by whom, re-read from the file: the
206
+ service notices an operator's `gargoyle approvers revoke` here."""
207
+ with self._lock:
208
+ self._refresh()
209
+ return {upn: dict(e) for upn, e in self._revoked.items()}
210
+
149
211
  def revoked_hex(self) -> Dict[str, str]:
150
212
  """{upn: public_key_hex} of revoked approvers: for verifying the
151
213
  grants they signed while trusted, never for accepting new ones."""
@@ -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
@@ -293,7 +294,7 @@ def cmd_remote(args) -> int:
293
294
 
294
295
  def cmd_issuer(args) -> int:
295
296
  import uvicorn
296
- from gargoyle.issuer_service import app_from_env
297
+ from gargoyle.issuer_service import app_from_env, redact_access_log
297
298
 
298
299
  out = Path(args.out).resolve()
299
300
  os.environ.update(_read_env(out / "issuer.env"))
@@ -302,6 +303,7 @@ def cmd_issuer(args) -> int:
302
303
  os.environ.setdefault("GARGOYLE_WITNESS_PATH", str(out / "witness.jsonl")) # the issuer's signed record, for reconciliation
303
304
  os.environ.setdefault("GARGOYLE_LOG_JSON", "1")
304
305
  app = app_from_env()
306
+ redact_access_log() # no sign-in codes in the access log
305
307
  # The middleware reads the policy pin from a file; write it next to the
306
308
  # config so the client-side entry finds it at the configured path.
307
309
  from gargoyle.rules import RulesPolicy
@@ -345,9 +347,10 @@ def cmd_approvers(args) -> int:
345
347
  for upn, e in sorted(entries.items()):
346
348
  print("%s enrolled %s key %s%s" % (upn, e.get("enrolled_at", "?"), e["public_key_hex"][:16],
347
349
  (" oid " + e["oid"]) if e.get("oid") else ""))
348
- revoked = reg.revoked_hex()
350
+ revoked = reg.revoked_entries()
349
351
  if revoked:
350
- print("revoked: " + ", ".join(sorted(revoked)))
352
+ print("revoked: " + ", ".join("%s (key %s, %s by %s)" % (u, e["public_key_hex"][:16], e.get("revoked_at", "?")[:19], e.get("revoked_by", "?"))
353
+ for u, e in sorted(revoked.items())))
351
354
  return 0
352
355
  import getpass
353
356
  if reg.revoke(args.upn, revoked_by="operator:" + getpass.getuser()):
@@ -362,15 +365,17 @@ def cmd_audit(args) -> int:
362
365
  from gargoyle.execution import verify_chain
363
366
 
364
367
  keys = json.loads((Path(args.out).resolve() / "public-keys.json").read_text())
365
- load = lambda m: {k: Ed25519PublicKey.from_public_bytes(bytes.fromhex(v)) for k, v in m.items()}
366
- 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"])
367
371
  enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
368
372
  if enrolled.exists():
369
- doc = json.loads(enrolled.read_text())
370
- # Revoked approvers too: the audit verifies what was signed while they
371
- # were trusted. Only the live service and middleware refuse them.
372
- approvers.update({u: e["public_key_hex"] for u, e in doc.get("revoked", {}).items()})
373
- 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())
374
379
  trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
375
380
  approver_public_keys=load(approvers))
376
381
  if args.log:
@@ -60,7 +60,7 @@ from gargoyle.plans import (REF_PLAN_MALFORMED, REF_PLAN_STEP_NOT_PLANNABLE, REF
60
60
  sign_plan, valid_step)
61
61
  from gargoyle.notify import notifier_from_env
62
62
  from gargoyle.approvers import (DEFAULT_APPROVER_ROLE, ENV_APPROVER_GROUPS, ENV_APPROVER_ROLE, ENV_APPROVERS_PATH,
63
- ApproverRegistry, may_enroll, valid_public_key_hex)
63
+ ApproverRegistry, RevokedKeyError, may_enroll, valid_public_key_hex)
64
64
  from gargoyle.identity import entra_keys, entra_tenant_id, principal_from_token
65
65
  from gargoyle.issuer import DEFAULT_TTL_SECONDS, issue
66
66
  from gargoyle.policy import Policy
@@ -79,6 +79,8 @@ ENV_APPROVER_PUBLIC_KEY_HEX = "GARGOYLE_APPROVER_PUBLIC_KEY_HEX"
79
79
  ENV_RECEIPT_TTL = "GARGOYLE_RECEIPT_TTL_SECONDS"
80
80
  ENV_CONSOLE_CLIENT_ID = "GARGOYLE_ENTRA_CLIENT_ID" # the public client app the console signs in with (User-Client-Flow)
81
81
  ENV_CONSOLE_SCOPE = "GARGOYLE_ENTRA_SCOPE" # default api://<last audience>/access
82
+ ENV_MAX_OPEN_HOLDS = "GARGOYLE_MAX_OPEN_HOLDS" # holds one principal may have waiting at once
83
+ DEFAULT_MAX_OPEN_HOLDS = 20
82
84
 
83
85
  REF_ISSUER_UNAUTHENTICATED = "REF_ISSUER_UNAUTHENTICATED"
84
86
  REF_ISSUER_BAD_REQUEST = "REF_ISSUER_BAD_REQUEST"
@@ -87,6 +89,8 @@ REF_ISSUER_UNKNOWN_HOLD = "REF_ISSUER_UNKNOWN_HOLD"
87
89
  REF_ISSUER_NO_APPROVER_TRUST = "REF_ISSUER_NO_APPROVER_TRUST"
88
90
  REF_ISSUER_NOT_AN_APPROVER = "REF_ISSUER_NOT_AN_APPROVER" # enrollment without the approver role or group
89
91
  REF_ISSUER_FORBIDDEN = "REF_ISSUER_FORBIDDEN" # authenticated, but not an approver and not the hold's own principal
92
+ REF_ISSUER_TOO_MANY_HOLDS = "REF_ISSUER_TOO_MANY_HOLDS" # the principal already has the cap's worth of holds waiting
93
+ REF_ISSUER_KEY_REVOKED = "REF_ISSUER_KEY_REVOKED" # enrollment offered a key that was revoked; a new key is needed
90
94
 
91
95
 
92
96
  def build_issuer_app(
@@ -109,6 +113,7 @@ def build_issuer_app(
109
113
  witness: Optional[WitnessLog] = None,
110
114
  tenant: Optional[str] = None,
111
115
  tenant_key_ids: Optional[set] = None,
116
+ max_open_holds: int = DEFAULT_MAX_OPEN_HOLDS,
112
117
  ) -> FastAPI:
113
118
  """`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
114
119
  posts it to a webhook); it is notice, not admission, and its failure
@@ -146,7 +151,20 @@ def build_issuer_app(
146
151
  registry = approver_registry if approver_registry is not None else ApproverRegistry(None)
147
152
  static_approvers = dict(approver_public_keys or {})
148
153
 
154
+ app.state.revoked_seen = set(registry.revoked_entries())
155
+
156
+ def _notice_revocations() -> None:
157
+ """An operator's `gargoyle approvers revoke` edits the file on the
158
+ host; the first read after it puts a `revoked` entry in the witness,
159
+ so the record shows who was trusted when. Revocations the service
160
+ made itself were witnessed as they happened."""
161
+ current = registry.revoked_entries()
162
+ for upn in sorted(set(current) - app.state.revoked_seen):
163
+ event("revoked", approver=upn, by=current[upn].get("revoked_by"), revoked_at=current[upn].get("revoked_at"))
164
+ app.state.revoked_seen = set(current)
165
+
149
166
  def approver_keys() -> Dict[str, Any]:
167
+ _notice_revocations()
150
168
  """Static keys from configuration plus every enrolled approver, by UPN."""
151
169
  return {**static_approvers, **registry.keys()}
152
170
 
@@ -164,6 +182,21 @@ def build_issuer_app(
164
182
  plan = app.state.pending_plans.get(arid)
165
183
  return plan.get("principal") if plan is not None else None
166
184
 
185
+ def _open_holds(principal: str) -> int:
186
+ """Held receipts and plans of `principal` still waiting on a release:
187
+ not expired, not complete. A declined hold is already gone. One runaway
188
+ client must not fill every approver's queue and the hold store."""
189
+ current = now()
190
+ n = 0
191
+ for arid, env in app.state.pending.items():
192
+ if (env.get("request_context", {}).get("principal") == principal
193
+ and datetime.fromisoformat(env["not_after"]) > current and not _complete(arid)):
194
+ n += 1
195
+ for arid, plan in app.state.pending_plans.items():
196
+ if plan.get("principal") == principal and datetime.fromisoformat(plan["not_after"]) > current and not _complete(arid):
197
+ n += 1
198
+ return n
199
+
167
200
  def _refuse(status: int, reason: str) -> HTTPException:
168
201
  return HTTPException(status_code=status, detail={"reason": reason})
169
202
 
@@ -228,6 +261,11 @@ def build_issuer_app(
228
261
  if plan["plan_id"] == plan_id and _complete(arid_p) and match_step(plan, body["tool"], body["args"]) is not None:
229
262
  covered = True
230
263
  break
264
+ if result.envelope["requires_approval"] and not covered and _open_holds(who["principal"]) >= max_open_holds:
265
+ # A service limit, not a policy decision: nothing is stored, the
266
+ # signed envelope is dropped, the client reports the reason.
267
+ event("refused", principal=who["principal"], tool=body["tool"], reason=REF_ISSUER_TOO_MANY_HOLDS)
268
+ return {"issued": False, "reason": REF_ISSUER_TOO_MANY_HOLDS}
231
269
  event("issued", principal=who["principal"], tool=body["tool"], held=bool(result.envelope["requires_approval"]),
232
270
  **({"oid": who["oid"]} if who.get("oid") else {}),
233
271
  covered=covered, decision_id=result.envelope.get("decision_id"), decision_sha256=result.envelope["decision_sha256"],
@@ -287,6 +325,9 @@ def build_issuer_app(
287
325
  if d.approvers:
288
326
  approvers.update(d.approvers)
289
327
  norm.append(step)
328
+ if held and _open_holds(who["principal"]) >= max_open_holds:
329
+ event("refused", principal=who["principal"], tool="plan", reason=REF_ISSUER_TOO_MANY_HOLDS)
330
+ return {"issued": False, "reason": REF_ISSUER_TOO_MANY_HOLDS}
290
331
  import uuid
291
332
  approval = None
292
333
  if required > 1 or approvers:
@@ -357,21 +398,27 @@ def build_issuer_app(
357
398
  plan = app.state.pending_plans.get(arid) if isinstance(arid, str) else None
358
399
  if env is None and plan is None:
359
400
  return {"accepted": False, "reason": REF_ISSUER_UNKNOWN_HOLD}
401
+ def refused(reason: str) -> Dict[str, Any]:
402
+ # A release that did not happen is a decision too: a revoked or
403
+ # unknown key trying to release a hold belongs in the witness.
404
+ kid = grant.get("approver_key_id")
405
+ event("grant_refused", approval_request_id=arid, reason=reason, **({"approver": kid} if isinstance(kid, str) else {}))
406
+ return {"accepted": False, "reason": reason}
360
407
  trusted = approver_keys()
361
408
  if not trusted:
362
- return {"accepted": False, "reason": REF_ISSUER_NO_APPROVER_TRUST}
409
+ return refused(REF_ISSUER_NO_APPROVER_TRUST)
363
410
  g = verify_grant(grant, expected_decision_sha256=env["decision_sha256"] if env else plan["plan_sha256"],
364
411
  expected_approval_request_id=arid, approver_public_keys=trusted,
365
412
  gate_key_id=key_id, now=now())
366
413
  if not g["accepted"]:
367
- return {"accepted": False, "reason": g["reason"]}
414
+ return refused(g["reason"])
368
415
  # The receipt's (or plan's) own terms: distinct approvers, from the named set if any.
369
416
  terms = approval_terms(env) if env else _plan_terms(plan)
370
417
  have = list(app.state.grants.get(arid, []))
371
418
  if any(h["approver_key_id"] == grant["approver_key_id"] for h in have):
372
- return {"accepted": False, "reason": REF_APPROVAL_DUPLICATE_APPROVER}
419
+ return refused(REF_APPROVAL_DUPLICATE_APPROVER)
373
420
  if terms["approvers"] is not None and grant["approver_key_id"] not in terms["approvers"]:
374
- return {"accepted": False, "reason": REF_APPROVAL_APPROVER_NOT_ELIGIBLE}
421
+ return refused(REF_APPROVAL_APPROVER_NOT_ELIGIBLE)
375
422
  have.append(grant)
376
423
  app.state.grants[arid] = have # written back: the store persists on assignment
377
424
  event("granted", approval_request_id=arid, approver=grant["approver_key_id"], collected=len(have), required=terms["required"],
@@ -419,6 +466,7 @@ def build_issuer_app(
419
466
  """Public, like /record: the enrolled approvers' public keys by UPN,
420
467
  for the middleware and for audits. Static configured keys are not
421
468
  listed; they travel in config."""
469
+ _notice_revocations()
422
470
  return {"approvers": registry.public_hex()}
423
471
 
424
472
  @app.post("/approvers/enroll")
@@ -435,7 +483,13 @@ def build_issuer_app(
435
483
  raise _refuse(400, REF_ISSUER_BAD_REQUEST)
436
484
  if not isinstance(body, dict) or not valid_public_key_hex(body.get("public_key_hex")):
437
485
  raise _refuse(400, REF_ISSUER_BAD_REQUEST)
438
- result = registry.enroll(who["principal"], body["public_key_hex"], now=now(), oid=who.get("oid"), tid=who.get("tid"))
486
+ try:
487
+ result = registry.enroll(who["principal"], body["public_key_hex"], now=now(), oid=who.get("oid"), tid=who.get("tid"))
488
+ except RevokedKeyError:
489
+ # A revoked key stays revoked, whoever offers it; the console
490
+ # generates a fresh one on this answer.
491
+ event("enroll_refused", approver=who["principal"], reason=REF_ISSUER_KEY_REVOKED, oid=who.get("oid"))
492
+ raise _refuse(409, REF_ISSUER_KEY_REVOKED)
439
493
  event("enrolled", approver=who["principal"], replaced=result["replaced"], oid=who.get("oid"))
440
494
  return {"enrolled": True, "key_id": who["principal"], "replaced": result["replaced"]}
441
495
 
@@ -459,6 +513,7 @@ def build_issuer_app(
459
513
  gone = registry.revoke(upn, revoked_by=who["principal"], now=now())
460
514
  if gone:
461
515
  event("revoked", approver=upn, by=who["principal"])
516
+ app.state.revoked_seen.add(upn)
462
517
  return {"revoked": gone}
463
518
 
464
519
  @app.get("/me")
@@ -468,6 +523,7 @@ def build_issuer_app(
468
523
  its own key is the one on record)."""
469
524
  who = authenticate(authorization)
470
525
  upn = who["principal"]
526
+ _notice_revocations()
471
527
  return {"principal": upn, "may_approve": may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers,
472
528
  "enrolled_public_key_hex": registry.public_hex().get(upn)}
473
529
 
@@ -485,6 +541,7 @@ def build_issuer_app(
485
541
  how much is waiting, and since when. No secrets."""
486
542
  return {"ok": True, "policy": policy.pin(), "issuer_key_id": key_id,
487
543
  "pending_receipts": len(app.state.pending), "pending_plans": len(app.state.pending_plans),
544
+ "max_open_holds": max_open_holds,
488
545
  "enrolled_approvers": len(registry.public_hex()), "static_approvers": len(static_approvers),
489
546
  "entra": bool(console_entra), "persistent": store.path != ":memory:", "started_at": app.state.started_at}
490
547
 
@@ -514,6 +571,22 @@ def build_issuer_app(
514
571
  return app
515
572
 
516
573
 
574
+ def redact_access_log(logger_name: str = "uvicorn.access") -> None:
575
+ """The console returns from Microsoft as GET /approve?code=...; the code
576
+ is single-use and bound to the browser's PKCE verifier, and it still has
577
+ no place in an access log. Replace query strings on logged paths."""
578
+ import logging
579
+ import re
580
+
581
+ class _Redact(logging.Filter):
582
+ def filter(self, record: logging.LogRecord) -> bool:
583
+ if isinstance(record.args, tuple):
584
+ record.args = tuple(re.sub(r"\?.*", "?<redacted>", a) if isinstance(a, str) and a.startswith("/") and "?" in a else a
585
+ for a in record.args)
586
+ return True
587
+ logging.getLogger(logger_name).addFilter(_Redact())
588
+
589
+
517
590
  def app_from_env() -> FastAPI:
518
591
  """The deployable app: OPA policy, issuer key, trusted identity-provider
519
592
  key and optional approver key from the environment. Missing required
@@ -570,6 +643,7 @@ def app_from_env() -> FastAPI:
570
643
  ttl_seconds=int(os.environ.get(ENV_RECEIPT_TTL, DEFAULT_TTL_SECONDS)),
571
644
  on_hold=notifier_from_env(os.environ),
572
645
  hold_store=HoldStore(os.environ.get(ENV_HOLDS_PATH) or None),
646
+ max_open_holds=int(os.environ.get(ENV_MAX_OPEN_HOLDS, DEFAULT_MAX_OPEN_HOLDS)),
573
647
  witness=(WitnessLog(os.environ["GARGOYLE_WITNESS_PATH"], Ed25519PrivateKey.from_private_bytes(bytes.fromhex(required[ENV_ISSUER_KEY_HEX])),
574
648
  required[ENV_ISSUER_KEY_ID]) if os.environ.get("GARGOYLE_WITNESS_PATH") else None),
575
649
  log_events=(lambda e: sys.stderr.write(json.dumps(e, sort_keys=True) + "\n")) if os.environ.get("GARGOYLE_LOG_JSON") else None,
@@ -94,8 +94,18 @@ async function entraReady() {
94
94
  const mine = await publicKeyHex(keyPair);
95
95
  if (me.enrolled_public_key_hex !== mine) {
96
96
  // First time in this browser (or a new browser): enroll this key under the UPN.
97
- const res = await api("approvers/enroll", { method: "POST", body: JSON.stringify({ public_key_hex: mine }) });
98
- log(res.replaced ? "this browser's key replaced your previous one" : "this browser's key enrolled");
97
+ let res;
98
+ try {
99
+ res = await api("approvers/enroll", { method: "POST", body: JSON.stringify({ public_key_hex: mine }) });
100
+ } catch (err) {
101
+ if (err.status !== 409) throw err;
102
+ // This browser's key was revoked. A revoked key stays revoked: make a new one and enroll that.
103
+ keyPair = await generateApproverKey(); await storeKey(me.principal, keyPair);
104
+ res = await api("approvers/enroll", { method: "POST", body: JSON.stringify({ public_key_hex: await publicKeyHex(keyPair) }) });
105
+ log("this browser's previous key was revoked; a new key enrolled");
106
+ res = null;
107
+ }
108
+ if (res) log(res.replaced ? "this browser's key replaced your previous one" : "this browser's key enrolled");
99
109
  }
100
110
  state.key = keyPair.privateKey; state.keyId = me.principal;
101
111
  $("entra").style.display = "none"; $("setup").style.display = "none";
@@ -117,7 +127,7 @@ $("file").addEventListener("change", async (e) => {
117
127
 
118
128
  async function api(path, opts = {}) {
119
129
  const r = await fetch(path, { ...opts, headers: { "Authorization": "Bearer " + state.token, "content-type": "application/json", ...(opts.headers || {}) } });
120
- if (!r.ok) throw new Error(path + " -> " + r.status);
130
+ if (!r.ok) { const e = new Error(path + " -> " + r.status); e.status = r.status; throw e; }
121
131
  return r.json();
122
132
  }
123
133
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.16.0.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.0.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
@@ -295,7 +295,13 @@ person. An approver retires a lost or replaced key with
295
295
  anyone with `gargoyle approvers revoke <upn>` on the issuer's host, and
296
296
  the running service picks the change up without a restart. Revoked keys
297
297
  stay on file so earlier grants still audit; new ones are refused at the
298
- issuer and at every middleware on its next held call. Approvers, and
298
+ issuer and at every middleware on its next held call. Revocation retires
299
+ a key, not a person: while the Approver role stands, the person can sign
300
+ in and enroll a fresh key, so remove the role too; the revoked key itself
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
299
305
  only approvers, see the whole queue and the witness; everyone else sees
300
306
  their own holds. Then paste the server entry from
301
307
  `gargoyle-deploy/mcp-config.json` into your MCP client.
@@ -390,6 +396,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
390
396
  | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list; `gargoyle approvers list|revoke`) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
391
397
  | issuer `GARGOYLE_ENTRA_TENANT` + `GARGOYLE_ISSUER_AUDIENCE` | trust the tenant's tokens; the audience is required with a tenant (the service will not start without it), and a directory token for any other tenant is `REF_IDENTITY_WRONG_TENANT` |
392
398
  | issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
399
+ | issuer `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
393
400
  | issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
394
401
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
395
402
  | `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
@@ -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.0.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"
@@ -151,10 +151,17 @@ def test_a_revoked_approver_is_refused_at_once_and_still_verifies_in_the_past(tm
151
151
  doc = json.loads((tmp_path / "approvers.json").read_text())
152
152
  assert "dana@contoso.com" not in doc["approvers"] and doc["revoked"]["dana@contoso.com"]["public_key_hex"] == pub_hex(dana)
153
153
  assert doc["revoked"]["dana@contoso.com"]["revoked_by"] == "dana@contoso.com"
154
- # Enrolling again is a fresh key, and the revoked record goes.
154
+ # The revoked key is never enrolled again, by Dana or anyone (a browser
155
+ # that kept its key pair would otherwise re-enroll it on the next sign-in).
156
+ from gargoyle.issuer_service import REF_ISSUER_KEY_REVOKED
157
+ r = c.post("/approvers/enroll", json={"public_key_hex": pub_hex(dana)}, headers=h(token("dana@contoso.com", roles=["Approver"])))
158
+ assert r.status_code == 409 and r.json()["detail"]["reason"] == REF_ISSUER_KEY_REVOKED
159
+ r = c.post("/approvers/enroll", json={"public_key_hex": pub_hex(dana)}, headers=h(token("lee@contoso.com", roles=["Approver"])))
160
+ assert r.status_code == 409
161
+ # A fresh key enrolls, and the revoked record stays so Dana's earlier grants still audit.
155
162
  dana2 = Ed25519PrivateKey.generate()
156
163
  assert c.post("/approvers/enroll", json={"public_key_hex": pub_hex(dana2)}, headers=h(token("dana@contoso.com", roles=["Approver"]))).json()["enrolled"]
157
- assert reg.revoked_hex() == {} and reg.public_hex() == {"dana@contoso.com": pub_hex(dana2)}
164
+ assert reg.revoked_hex() == {"dana@contoso.com": pub_hex(dana)} and reg.public_hex() == {"dana@contoso.com": pub_hex(dana2)}
158
165
 
159
166
 
160
167
  def test_the_registry_picks_up_a_file_another_process_wrote(tmp_path):
@@ -169,3 +176,67 @@ def test_the_registry_picks_up_a_file_another_process_wrote(tmp_path):
169
176
  assert operator.revoke("dana@contoso.com", revoked_by="operator:root")
170
177
  os.utime(path, (time.time() + 2, time.time() + 2)) # make the change visible even on a coarse filesystem clock
171
178
  assert "dana@contoso.com" not in live and live.public_hex() == {} and live.revoked_hex() == {"dana@contoso.com": pub_hex(key)}
179
+
180
+
181
+ def test_an_operators_revocation_on_the_host_reaches_the_witness(tmp_path):
182
+ """`gargoyle approvers revoke` edits the file; the service witnesses the
183
+ revocation (who, when) the first time it reads the file afterwards, and
184
+ only once. Its own revocations are witnessed as they happen, not again."""
185
+ import os
186
+ events = []
187
+ reg = ApproverRegistry(str(tmp_path / "approvers.json"))
188
+ app = build_issuer_app(policy=Hold(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()},
189
+ approver_registry=reg, log_events=events.append)
190
+ c = TestClient(app)
191
+ h = lambda t: {"Authorization": "Bearer " + t}
192
+ for who in ("dana@contoso.com", "lee@contoso.com"):
193
+ assert c.post("/approvers/enroll", json={"public_key_hex": pub_hex(Ed25519PrivateKey.generate())}, headers=h(token(who, roles=["Approver"]))).json()["enrolled"]
194
+ # The operator, in another process.
195
+ other = ApproverRegistry(str(tmp_path / "approvers.json"))
196
+ assert other.revoke("dana@contoso.com", revoked_by="operator:root")
197
+ st = os.stat(tmp_path / "approvers.json"); os.utime(tmp_path / "approvers.json", (st.st_atime, st.st_mtime + 2))
198
+ assert c.get("/approvers").json()["approvers"].keys() == {"lee@contoso.com"}
199
+ c.get("/approvers")
200
+ revoked = [e for e in events if e["event"] == "revoked"]
201
+ assert len(revoked) == 1 and revoked[0]["approver"] == "dana@contoso.com" and revoked[0]["by"] == "operator:root" and revoked[0]["revoked_at"]
202
+ # Lee revokes their own key through the service: one entry, by Lee.
203
+ assert c.post("/approvers/revoke", json={"upn": "lee@contoso.com"}, headers=h(token("lee@contoso.com", roles=["Approver"]))).json() == {"revoked": True}
204
+ c.get("/approvers")
205
+ revoked = [e for e in events if e["event"] == "revoked"]
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
@@ -9,7 +9,8 @@ from fastapi.testclient import TestClient
9
9
  from gargoyle.approval import REF_APPROVAL_SOD, build_grant, sign_grant
10
10
  from gargoyle.consumer import Consumer
11
11
  from gargoyle.issuer_service import (
12
- REF_ISSUER_BAD_REQUEST, REF_ISSUER_NO_APPROVER_TRUST, REF_ISSUER_UNAUTHENTICATED, REF_ISSUER_UNKNOWN_HOLD,
12
+ REF_ISSUER_BAD_REQUEST, REF_ISSUER_NO_APPROVER_TRUST, REF_ISSUER_TOO_MANY_HOLDS, REF_ISSUER_UNAUTHENTICATED,
13
+ REF_ISSUER_UNKNOWN_HOLD,
13
14
  build_issuer_app,
14
15
  )
15
16
  from gargoyle.jws import REF_JWS_EXPIRED, encode
@@ -227,3 +228,68 @@ def test_the_approver_role_in_an_identity_token_also_sees_every_hold():
227
228
  c.post("/issue", json={"target_url": TARGET, "tool": "write_file", "args": {"path": "x"}}, headers=auth(token(sub="carol@contoso.com", aud="gargoyle-issuer")))
228
229
  assert c.get("/pending", headers=auth(token(sub="dana@contoso.com", aud="gargoyle-issuer", roles=["Approver"]))).json()["pending"][0]["args"] == {"path": "x"}
229
230
  assert c.get("/pending", headers=auth(token(sub="erin@contoso.com", aud="gargoyle-issuer", roles=["Reader"]))).json()["pending"] == []
231
+
232
+
233
+ def test_open_holds_per_principal_are_capped():
234
+ """One runaway client cannot fill the approvers' queue: the cap counts a
235
+ principal's held receipts and plans that are neither expired nor released;
236
+ reads, other principals, declines and expiry are unaffected or free a slot."""
237
+ clock = {"now": NOW}
238
+ app = build_issuer_app(policy=ToolPolicy(), signing_key=issuer, key_id="issuer-1",
239
+ idp_public_keys={"idp-1": idp.public_key()},
240
+ approver_public_keys={"approver-alice": approver.public_key()},
241
+ audience="gargoyle-issuer", now=lambda: clock["now"], max_open_holds=2)
242
+ c = TestClient(app)
243
+
244
+ def write(i, tok=None):
245
+ return c.post("/issue", json={"target_url": TARGET, "tool": "write_file", "args": {"path": "%d.txt" % i, "content": "x"}},
246
+ headers=auth(tok)).json()
247
+
248
+ assert write(1)["issued"] is True and write(2)["issued"] is True
249
+ assert write(3) == {"issued": False, "reason": REF_ISSUER_TOO_MANY_HOLDS}
250
+ assert c.get("/health").json()["pending_receipts"] == 2 # the refused call stored nothing
251
+ assert c.post("/issue", json={"target_url": TARGET, "tool": "read_file", "args": {"path": "a"}}, headers=auth()).json()["issued"] is True
252
+ assert write(4, token(sub="spiffe://example.org/agents/other", aud="gargoyle-issuer"))["issued"] is True
253
+ plan = c.post("/plans", json={"target_url": TARGET, "steps": [{"tool": "write_file", "args": {"path": "p.txt", "content": "y"}}], "note": "n"},
254
+ headers=auth()).json()
255
+ assert plan == {"issued": False, "reason": REF_ISSUER_TOO_MANY_HOLDS}
256
+ mine = [h for h in c.get("/pending", headers=auth()).json()["pending"]]
257
+ assert len(mine) == 2 # the requester sees only their own
258
+ arid = mine[0]["envelope"]["approval_request_id"]
259
+ assert c.post("/decline", json={"approval_request_id": arid}, headers=auth()).json()["declined"] is True
260
+ assert write(5)["issued"] is True # the decline freed a slot
261
+ assert write(6)["issued"] is False
262
+ clock["now"] = NOW + timedelta(minutes=10) # expiry frees the rest
263
+ assert write(7, token(exp=NOW + timedelta(minutes=20), aud="gargoyle-issuer"))["issued"] is True
264
+ assert c.get("/health").json()["max_open_holds"] == 2
265
+
266
+
267
+ def test_a_refused_release_is_an_event_and_sign_in_codes_leave_the_access_log():
268
+ """A grant under an unknown or revoked key is refused and the refusal is
269
+ recorded (log and witness) with the key id and the reason; the live
270
+ revocation exercise of 2026-10-20 found it left no trace. And the Entra
271
+ redirect's one-time code is cut from uvicorn's access log."""
272
+ import logging
273
+ from gargoyle.approval import REF_APPROVAL_KEY_UNKNOWN
274
+ from gargoyle.issuer_service import redact_access_log
275
+ events = []
276
+ app = build_issuer_app(policy=ToolPolicy(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()},
277
+ approver_public_keys={"approver-alice": approver.public_key()}, audience="gargoyle-issuer",
278
+ log_events=events.append, now=lambda: NOW)
279
+ c = TestClient(app)
280
+ env = c.post("/issue", json={"target_url": TARGET, "tool": "write_file", "args": {"path": "a.txt", "content": "x"}}, headers=auth()).json()["envelope"]
281
+ arid = env["approval_request_id"]
282
+ g = sign_grant(build_grant(decision_sha256=env["decision_sha256"], approval_request_id=arid, grant_id="g-9", not_after=NOW + timedelta(minutes=5)),
283
+ Ed25519PrivateKey.generate(), "mallory@example.org")
284
+ assert c.post("/grants", json=g, headers=auth()).json() == {"accepted": False, "reason": REF_APPROVAL_KEY_UNKNOWN}
285
+ refused = [e for e in events if e["event"] == "grant_refused"]
286
+ assert refused == [{"event": "grant_refused", "at": NOW.isoformat(), "approval_request_id": arid,
287
+ "approver": "mallory@example.org", "reason": REF_APPROVAL_KEY_UNKNOWN}]
288
+ assert c.get("/pending", headers=auth()).json()["pending"][0]["envelope"]["approval_request_id"] == arid # the hold stands
289
+
290
+ redact_access_log("test.access")
291
+ rec = logging.LogRecord("test.access", logging.INFO, "", 0, '%s - "%s %s HTTP/%s" %d',
292
+ ("1.2.3.4:1", "GET", "/approve?code=SECRET&state=s", "1.1", 200), None)
293
+ for f in logging.getLogger("test.access").filters:
294
+ f.filter(rec)
295
+ assert rec.args[2] == "/approve?<redacted>" and rec.args[1] == "GET"