gargoyle-receipts 0.16.0.dev0__tar.gz → 0.16.2.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 (83) hide show
  1. {gargoyle_receipts-0.16.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.2.dev0}/PKG-INFO +7 -3
  2. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/README.md +6 -2
  3. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/approvers.py +19 -2
  4. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/cli.py +5 -3
  5. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/issuer_service.py +80 -6
  6. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/static/approve.html +13 -3
  7. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0/gargoyle_receipts.egg-info}/PKG-INFO +7 -3
  8. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/pyproject.toml +1 -1
  9. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_approvers.py +37 -2
  10. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_issuer_service.py +67 -1
  11. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/LICENSE +0 -0
  12. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/NOTICE +0 -0
  13. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/__init__.py +0 -0
  14. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/adapters.py +0 -0
  15. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/approval.py +0 -0
  16. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/approver_cli.py +0 -0
  17. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/approver_trust.py +0 -0
  18. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/consumer.py +0 -0
  19. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/entra_login.py +0 -0
  20. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/envelope.py +0 -0
  21. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/evidence.py +0 -0
  22. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/execution.py +0 -0
  23. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/fs_server.py +0 -0
  24. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/holdstore.py +0 -0
  25. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/identity.py +0 -0
  26. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/issuer.py +0 -0
  27. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/jws.py +0 -0
  28. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/key_record_source.py +0 -0
  29. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/mcp_server.py +0 -0
  30. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/notify.py +0 -0
  31. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/opa.py +0 -0
  32. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/pending_store.py +0 -0
  33. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/plans.py +0 -0
  34. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/policy.py +0 -0
  35. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/published_record_source.py +0 -0
  36. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/reconcile.py +0 -0
  37. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/remote.py +0 -0
  38. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/replay_cache.py +0 -0
  39. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/revert.py +0 -0
  40. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/rules.py +0 -0
  41. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/specs/graph-reference.json +0 -0
  42. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/state.py +0 -0
  43. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/static/approve.js +0 -0
  44. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/verifier.py +0 -0
  45. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/witness.py +0 -0
  46. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
  47. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  48. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  49. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  50. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  51. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/setup.cfg +0 -0
  52. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_annotations.py +0 -0
  53. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_approver_cli.py +0 -0
  54. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_canonical.py +0 -0
  55. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_cli.py +0 -0
  56. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_console_js.py +0 -0
  57. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_consumer.py +0 -0
  58. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_entra_identity.py +0 -0
  59. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_entra_login.py +0 -0
  60. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_evidence.py +0 -0
  61. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_execution.py +0 -0
  62. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_fs_server.py +0 -0
  63. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_graph.py +0 -0
  64. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_historical.py +0 -0
  65. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_identity.py +0 -0
  66. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_jws.py +0 -0
  67. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_mcp_proxy.py +0 -0
  68. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_mcp_self_issue.py +0 -0
  69. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_mcp_server.py +0 -0
  70. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_notify.py +0 -0
  71. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_opa.py +0 -0
  72. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_plans.py +0 -0
  73. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_policy.py +0 -0
  74. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_published_record.py +0 -0
  75. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_remote.py +0 -0
  76. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_revert.py +0 -0
  77. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_rules.py +0 -0
  78. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_state.py +0 -0
  79. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_stdio_encoding.py +0 -0
  80. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_threshold.py +0 -0
  81. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_typescript_conformance.py +0 -0
  82. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_wire_spec.py +0 -0
  83. {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.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.2.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.2.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,10 @@ 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. Approvers, and
299
302
  only approvers, see the whole queue and the witness; everyone else sees
300
303
  their own holds. Then paste the server entry from
301
304
  `gargoyle-deploy/mcp-config.json` into your MCP client.
@@ -390,6 +393,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
390
393
  | 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
394
  | 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
395
  | 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 |
396
+ | 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
397
  | 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
398
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
395
399
  | `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.2.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,10 @@ 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. Approvers, and
261
264
  only approvers, see the whole queue and the witness; everyone else sees
262
265
  their own holds. Then paste the server entry from
263
266
  `gargoyle-deploy/mcp-config.json` into your MCP client.
@@ -352,6 +355,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
352
355
  | 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
356
  | 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
357
  | 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 |
358
+ | 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
359
  | 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
360
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
357
361
  | `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 |
@@ -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
 
@@ -97,7 +102,11 @@ class ApproverRegistry:
97
102
  UPN (one key per approver; a new browser re-enrolls). The directory's
98
103
  immutable object id and tenant id are kept beside the UPN when the
99
104
  token carried them: the UPN is the name people read, the oid is what
100
- survives a rename."""
105
+ survives a rename. A key on the revoked list is never enrolled again,
106
+ for anyone (RevokedKeyError): the live exercise of 2026-10-20 showed a
107
+ browser re-enrolling the very key the operator had just revoked. The
108
+ revoked record stays when a fresh key enrolls, so grants signed under
109
+ the old key still audit."""
101
110
  if not isinstance(upn, str) or not upn:
102
111
  raise ValueError("upn must be a non-empty string")
103
112
  if not valid_public_key_hex(public_key_hex):
@@ -111,9 +120,10 @@ class ApproverRegistry:
111
120
  entry["tid"] = tid
112
121
  with self._lock:
113
122
  self._refresh()
123
+ if any(e.get("public_key_hex") == public_key_hex for e in self._revoked.values()):
124
+ raise RevokedKeyError("that key was revoked; enroll a new one")
114
125
  replaced = upn in self._approvers
115
126
  self._approvers[upn] = entry
116
- self._revoked.pop(upn, None) # enrolling again after a revocation is a fresh key
117
127
  self._save()
118
128
  return {"upn": upn, "replaced": replaced, **entry}
119
129
 
@@ -146,6 +156,13 @@ class ApproverRegistry:
146
156
  self._refresh()
147
157
  return {upn: e["public_key_hex"] for upn, e in self._approvers.items()}
148
158
 
159
+ def revoked_entries(self) -> Dict[str, Dict[str, Any]]:
160
+ """The revoked list with when and by whom, re-read from the file: the
161
+ service notices an operator's `gargoyle approvers revoke` here."""
162
+ with self._lock:
163
+ self._refresh()
164
+ return {upn: dict(e) for upn, e in self._revoked.items()}
165
+
149
166
  def revoked_hex(self) -> Dict[str, str]:
150
167
  """{upn: public_key_hex} of revoked approvers: for verifying the
151
168
  grants they signed while trusted, never for accepting new ones."""
@@ -293,7 +293,7 @@ def cmd_remote(args) -> int:
293
293
 
294
294
  def cmd_issuer(args) -> int:
295
295
  import uvicorn
296
- from gargoyle.issuer_service import app_from_env
296
+ from gargoyle.issuer_service import app_from_env, redact_access_log
297
297
 
298
298
  out = Path(args.out).resolve()
299
299
  os.environ.update(_read_env(out / "issuer.env"))
@@ -302,6 +302,7 @@ def cmd_issuer(args) -> int:
302
302
  os.environ.setdefault("GARGOYLE_WITNESS_PATH", str(out / "witness.jsonl")) # the issuer's signed record, for reconciliation
303
303
  os.environ.setdefault("GARGOYLE_LOG_JSON", "1")
304
304
  app = app_from_env()
305
+ redact_access_log() # no sign-in codes in the access log
305
306
  # The middleware reads the policy pin from a file; write it next to the
306
307
  # config so the client-side entry finds it at the configured path.
307
308
  from gargoyle.rules import RulesPolicy
@@ -345,9 +346,10 @@ def cmd_approvers(args) -> int:
345
346
  for upn, e in sorted(entries.items()):
346
347
  print("%s enrolled %s key %s%s" % (upn, e.get("enrolled_at", "?"), e["public_key_hex"][:16],
347
348
  (" oid " + e["oid"]) if e.get("oid") else ""))
348
- revoked = reg.revoked_hex()
349
+ revoked = reg.revoked_entries()
349
350
  if revoked:
350
- print("revoked: " + ", ".join(sorted(revoked)))
351
+ print("revoked: " + ", ".join("%s (key %s, %s by %s)" % (u, e["public_key_hex"][:16], e.get("revoked_at", "?")[:19], e.get("revoked_by", "?"))
352
+ for u, e in sorted(revoked.items())))
351
353
  return 0
352
354
  import getpass
353
355
  if reg.revoke(args.upn, revoked_by="operator:" + getpass.getuser()):
@@ -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.2.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.2.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,10 @@ 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. Approvers, and
299
302
  only approvers, see the whole queue and the witness; everyone else sees
300
303
  their own holds. Then paste the server entry from
301
304
  `gargoyle-deploy/mcp-config.json` into your MCP client.
@@ -390,6 +393,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
390
393
  | 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
394
  | 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
395
  | 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 |
396
+ | 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
397
  | 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
398
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
395
399
  | `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 |
@@ -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.2.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,31 @@ 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")]
@@ -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"