gargoyle-receipts 0.15.1.dev0__tar.gz → 0.16.0.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.15.1.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.0.dev0}/PKG-INFO +16 -15
  2. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/README.md +14 -13
  3. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approvers.py +78 -9
  4. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/cli.py +49 -23
  5. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/identity.py +40 -4
  6. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/issuer_service.py +70 -8
  7. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +16 -15
  8. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -1
  9. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/requires.txt +1 -1
  10. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/pyproject.toml +5 -3
  11. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_approvers.py +60 -0
  12. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_cli.py +16 -18
  13. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_console_js.py +2 -2
  14. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_entra_identity.py +58 -0
  15. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_issuer_service.py +31 -0
  16. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_mcp_self_issue.py +3 -3
  17. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_remote.py +2 -2
  18. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_revert.py +2 -2
  19. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_state.py +4 -4
  20. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_witness.py +8 -5
  21. gargoyle_receipts-0.15.1.dev0/gargoyle/review.py +0 -213
  22. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/LICENSE +0 -0
  23. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/NOTICE +0 -0
  24. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/__init__.py +0 -0
  25. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/adapters.py +0 -0
  26. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approval.py +0 -0
  27. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approver_cli.py +0 -0
  28. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approver_trust.py +0 -0
  29. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/consumer.py +0 -0
  30. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/entra_login.py +0 -0
  31. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/envelope.py +0 -0
  32. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/evidence.py +0 -0
  33. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/execution.py +0 -0
  34. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/fs_server.py +0 -0
  35. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/holdstore.py +0 -0
  36. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/issuer.py +0 -0
  37. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/jws.py +0 -0
  38. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/key_record_source.py +0 -0
  39. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/mcp_server.py +0 -0
  40. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/notify.py +0 -0
  41. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/opa.py +0 -0
  42. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/pending_store.py +0 -0
  43. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/plans.py +0 -0
  44. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/policy.py +0 -0
  45. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/published_record_source.py +0 -0
  46. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/reconcile.py +0 -0
  47. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/remote.py +0 -0
  48. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/replay_cache.py +0 -0
  49. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/revert.py +0 -0
  50. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/rules.py +0 -0
  51. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  52. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/state.py +0 -0
  53. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/static/approve.html +0 -0
  54. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/static/approve.js +0 -0
  55. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/verifier.py +0 -0
  56. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/witness.py +0 -0
  57. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  58. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  59. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  60. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/setup.cfg +0 -0
  61. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_annotations.py +0 -0
  62. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_approver_cli.py +0 -0
  63. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_canonical.py +0 -0
  64. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_consumer.py +0 -0
  65. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_entra_login.py +0 -0
  66. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_evidence.py +0 -0
  67. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_execution.py +0 -0
  68. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_fs_server.py +0 -0
  69. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_graph.py +0 -0
  70. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_historical.py +0 -0
  71. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_identity.py +0 -0
  72. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_jws.py +0 -0
  73. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_mcp_proxy.py +0 -0
  74. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_mcp_server.py +0 -0
  75. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_notify.py +0 -0
  76. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_opa.py +0 -0
  77. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_plans.py +0 -0
  78. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_policy.py +0 -0
  79. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_published_record.py +0 -0
  80. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_rules.py +0 -0
  81. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_stdio_encoding.py +0 -0
  82. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_threshold.py +0 -0
  83. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_typescript_conformance.py +0 -0
  84. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_wire_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.15.1.dev0
3
+ Version: 0.16.0.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
@@ -25,7 +25,7 @@ License-File: NOTICE
25
25
  Requires-Dist: cryptography>=44.0.0
26
26
  Requires-Dist: fastapi>=0.115
27
27
  Requires-Dist: uvicorn>=0.30
28
- Requires-Dist: requests>=2.32
28
+ Requires-Dist: requests>=2.32.3
29
29
  Provides-Extra: redis
30
30
  Requires-Dist: redis; extra == "redis"
31
31
  Provides-Extra: entra
@@ -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.15.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.16.0.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
@@ -221,7 +221,9 @@ chain["accepted"], chain["steps"] # True, [("authorization", ...), ("approval"
221
221
  That is the sentence Gargoyle can say that nobody else can: a specific
222
222
  policy version authorized a specific call for a specific principal, a
223
223
  human's key released it, and exactly that call ran once, verifiable offline
224
- by anyone, in Python or TypeScript.
224
+ by anyone. The receipt, grant and execution receipt verify in Python or
225
+ TypeScript against the same vectors; the evidence log, the witness, plans
226
+ and the older formats verify in Python.
225
227
 
226
228
  A held call is approved against the state it was shown in. Before asking
227
229
  for the receipt the middleware hashes the target (a file's bytes, or its
@@ -272,15 +274,6 @@ field. A hold can also be announced to Slack or any webhook
272
274
  (`GARGOYLE_HOLD_WEBHOOK_URL`); the notice carries no key, so nothing in a
273
275
  chat channel can approve anything.
274
276
 
275
- ## Reviewing it
276
-
277
- `gargoyle review-packet --out packet.zip` builds one archive for an outside
278
- reviewer: the spec, the vectors, the verification source, a reviewer
279
- prompt, and `verify_stdlib.py`, which recomputes every conformance vector
280
- (RFC 8785, SHA-256, Ed25519) with nothing but the Python standard library,
281
- so a reviewer with no pip and no network can still check every hash and
282
- signature rather than take the README's word.
283
-
284
277
  ## Deploy it
285
278
 
286
279
  pip install gargoyle-receipts
@@ -297,7 +290,14 @@ the page shows each held call with its full arguments, checked against
297
290
  the receipt, and signs the grant locally with WebCrypto. The key never
298
291
  leaves the browser, and with Entra it is generated there, non-exportable,
299
292
  and enrolled under the approver's UPN, so grants and audits name the
300
- person. Then paste the server entry from
293
+ person. An approver retires a lost or replaced key with
294
+ `POST /approvers/revoke` from their own sign-in; the operator revokes
295
+ anyone with `gargoyle approvers revoke <upn>` on the issuer's host, and
296
+ the running service picks the change up without a restart. Revoked keys
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
299
+ only approvers, see the whole queue and the witness; everyone else sees
300
+ their own holds. Then paste the server entry from
301
301
  `gargoyle-deploy/mcp-config.json` into your MCP client.
302
302
  [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
303
303
  is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
@@ -387,7 +387,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
387
387
  | issuer `GARGOYLE_ISSUER_AUDIENCE` | the `aud` an identity token must carry; a comma-separated list accepts several, so init-minted tokens (`gargoyle-issuer`) and a tenant's tokens (the issuer app's client id) work side by side |
388
388
  | `GARGOYLE_MCP_ENTRA_TENANT`, `_CLIENT_ID`, `_SCOPE`, `_TOKEN_CACHE` | instead of a fixed client token: the user's own Entra ID token, written by `gargoyle login` (device code, MSAL cache, silent refresh); the principal is their UPN. No usable token refuses `REF_TARGET_LOGIN_REQUIRED` |
389
389
  | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
390
- | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
390
+ | 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
+ | 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` |
391
392
  | 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 |
392
393
  | 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 |
393
394
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
@@ -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.15.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.16.0.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
@@ -183,7 +183,9 @@ chain["accepted"], chain["steps"] # True, [("authorization", ...), ("approval"
183
183
  That is the sentence Gargoyle can say that nobody else can: a specific
184
184
  policy version authorized a specific call for a specific principal, a
185
185
  human's key released it, and exactly that call ran once, verifiable offline
186
- by anyone, in Python or TypeScript.
186
+ by anyone. The receipt, grant and execution receipt verify in Python or
187
+ TypeScript against the same vectors; the evidence log, the witness, plans
188
+ and the older formats verify in Python.
187
189
 
188
190
  A held call is approved against the state it was shown in. Before asking
189
191
  for the receipt the middleware hashes the target (a file's bytes, or its
@@ -234,15 +236,6 @@ field. A hold can also be announced to Slack or any webhook
234
236
  (`GARGOYLE_HOLD_WEBHOOK_URL`); the notice carries no key, so nothing in a
235
237
  chat channel can approve anything.
236
238
 
237
- ## Reviewing it
238
-
239
- `gargoyle review-packet --out packet.zip` builds one archive for an outside
240
- reviewer: the spec, the vectors, the verification source, a reviewer
241
- prompt, and `verify_stdlib.py`, which recomputes every conformance vector
242
- (RFC 8785, SHA-256, Ed25519) with nothing but the Python standard library,
243
- so a reviewer with no pip and no network can still check every hash and
244
- signature rather than take the README's word.
245
-
246
239
  ## Deploy it
247
240
 
248
241
  pip install gargoyle-receipts
@@ -259,7 +252,14 @@ the page shows each held call with its full arguments, checked against
259
252
  the receipt, and signs the grant locally with WebCrypto. The key never
260
253
  leaves the browser, and with Entra it is generated there, non-exportable,
261
254
  and enrolled under the approver's UPN, so grants and audits name the
262
- person. Then paste the server entry from
255
+ person. An approver retires a lost or replaced key with
256
+ `POST /approvers/revoke` from their own sign-in; the operator revokes
257
+ anyone with `gargoyle approvers revoke <upn>` on the issuer's host, and
258
+ the running service picks the change up without a restart. Revoked keys
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
261
+ only approvers, see the whole queue and the witness; everyone else sees
262
+ their own holds. Then paste the server entry from
263
263
  `gargoyle-deploy/mcp-config.json` into your MCP client.
264
264
  [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
265
265
  is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
@@ -349,7 +349,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
349
349
  | issuer `GARGOYLE_ISSUER_AUDIENCE` | the `aud` an identity token must carry; a comma-separated list accepts several, so init-minted tokens (`gargoyle-issuer`) and a tenant's tokens (the issuer app's client id) work side by side |
350
350
  | `GARGOYLE_MCP_ENTRA_TENANT`, `_CLIENT_ID`, `_SCOPE`, `_TOKEN_CACHE` | instead of a fixed client token: the user's own Entra ID token, written by `gargoyle login` (device code, MSAL cache, silent refresh); the principal is their UPN. No usable token refuses `REF_TARGET_LOGIN_REQUIRED` |
351
351
  | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
352
- | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
352
+ | 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
+ | 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` |
353
354
  | 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 |
354
355
  | 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 |
355
356
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
@@ -47,26 +47,57 @@ class ApproverRegistry:
47
47
  self.path = Path(path) if path else None
48
48
  self._lock = threading.Lock()
49
49
  self._approvers: Dict[str, Dict[str, Any]] = {}
50
- if self.path is not None and self.path.exists():
51
- doc = json.loads(self.path.read_text(encoding="utf-8"))
52
- for upn, entry in (doc.get("approvers") or {}).items():
53
- if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
54
- self._approvers[upn] = dict(entry)
50
+ self._revoked: Dict[str, Dict[str, Any]] = {} # former approvers, kept so old grants still verify in audits
51
+ self._mtime: Optional[float] = None
52
+ self._load()
53
+
54
+ def _load(self) -> None:
55
+ if self.path is None or not self.path.exists():
56
+ return
57
+ doc = json.loads(self.path.read_text(encoding="utf-8"))
58
+ approvers, revoked = {}, {}
59
+ for upn, entry in (doc.get("approvers") or {}).items():
60
+ if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
61
+ approvers[upn] = dict(entry)
62
+ for upn, entry in (doc.get("revoked") or {}).items():
63
+ if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
64
+ revoked[upn] = dict(entry)
65
+ self._approvers, self._revoked = approvers, revoked
66
+ self._mtime = self.path.stat().st_mtime
67
+
68
+ def _refresh(self) -> None:
69
+ """Re-read the file when something else wrote it: `gargoyle approvers
70
+ revoke` on the issuer's host takes effect without a restart."""
71
+ if self.path is None:
72
+ return
73
+ try:
74
+ mtime = self.path.stat().st_mtime
75
+ except OSError:
76
+ return
77
+ if mtime != self._mtime:
78
+ self._load()
55
79
 
56
80
  def _save(self) -> None:
57
81
  if self.path is None:
58
82
  return
59
83
  self.path.parent.mkdir(parents=True, exist_ok=True)
60
- self.path.write_text(json.dumps({"approvers": self._approvers}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
84
+ doc = {"approvers": self._approvers}
85
+ if self._revoked:
86
+ doc["revoked"] = self._revoked
87
+ self.path.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n", encoding="utf-8")
61
88
  try:
62
89
  os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
63
90
  except OSError:
64
91
  pass
92
+ self._mtime = self.path.stat().st_mtime
65
93
 
66
94
  def enroll(self, upn: str, public_key_hex: str, *, enrolled_by: Optional[str] = None,
67
- now: Optional[datetime] = None) -> Dict[str, Any]:
95
+ now: Optional[datetime] = None, oid: Optional[str] = None, tid: Optional[str] = None) -> Dict[str, Any]:
68
96
  """Bind `upn` to `public_key_hex`, replacing any earlier key for that
69
- UPN (one key per approver; a new browser re-enrolls)."""
97
+ UPN (one key per approver; a new browser re-enrolls). The directory's
98
+ immutable object id and tenant id are kept beside the UPN when the
99
+ token carried them: the UPN is the name people read, the oid is what
100
+ survives a rename."""
70
101
  if not isinstance(upn, str) or not upn:
71
102
  raise ValueError("upn must be a non-empty string")
72
103
  if not valid_public_key_hex(public_key_hex):
@@ -74,14 +105,36 @@ class ApproverRegistry:
74
105
  entry = {"public_key_hex": public_key_hex,
75
106
  "enrolled_at": (now or datetime.now(timezone.utc)).isoformat(),
76
107
  "enrolled_by": enrolled_by or upn}
108
+ if oid:
109
+ entry["oid"] = oid
110
+ if tid:
111
+ entry["tid"] = tid
77
112
  with self._lock:
113
+ self._refresh()
78
114
  replaced = upn in self._approvers
79
115
  self._approvers[upn] = entry
116
+ self._revoked.pop(upn, None) # enrolling again after a revocation is a fresh key
80
117
  self._save()
81
118
  return {"upn": upn, "replaced": replaced, **entry}
82
119
 
120
+ def revoke(self, upn: str, *, revoked_by: Optional[str] = None, now: Optional[datetime] = None) -> bool:
121
+ """Stop trusting `upn`'s key from now on. The entry moves to the
122
+ revoked list with when and by whom, so an audit of earlier evidence
123
+ can still verify the grants it signed; the issuer and the
124
+ middleware stop accepting new ones at once."""
125
+ with self._lock:
126
+ self._refresh()
127
+ entry = self._approvers.pop(upn, None)
128
+ if entry is None:
129
+ return False
130
+ self._revoked[upn] = {**entry, "revoked_at": (now or datetime.now(timezone.utc)).isoformat(),
131
+ "revoked_by": revoked_by or upn}
132
+ self._save()
133
+ return True
134
+
83
135
  def remove(self, upn: str) -> bool:
84
136
  with self._lock:
137
+ self._refresh()
85
138
  gone = self._approvers.pop(upn, None) is not None
86
139
  if gone:
87
140
  self._save()
@@ -90,15 +143,31 @@ class ApproverRegistry:
90
143
  def public_hex(self) -> Dict[str, str]:
91
144
  """{upn: public_key_hex}, what GET /approvers publishes."""
92
145
  with self._lock:
146
+ self._refresh()
93
147
  return {upn: e["public_key_hex"] for upn, e in self._approvers.items()}
94
148
 
149
+ def revoked_hex(self) -> Dict[str, str]:
150
+ """{upn: public_key_hex} of revoked approvers: for verifying the
151
+ grants they signed while trusted, never for accepting new ones."""
152
+ with self._lock:
153
+ self._refresh()
154
+ return {upn: e["public_key_hex"] for upn, e in self._revoked.items()}
155
+
156
+ def entries(self) -> Dict[str, Dict[str, Any]]:
157
+ """Every current approver's record, for `gargoyle approvers list`."""
158
+ with self._lock:
159
+ self._refresh()
160
+ return {upn: dict(e) for upn, e in self._approvers.items()}
161
+
95
162
  def keys(self) -> Dict[str, Any]:
96
163
  """{upn: Ed25519PublicKey}, the map a verifier uses."""
97
164
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
98
165
  return {upn: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h)) for upn, h in self.public_hex().items()}
99
166
 
100
167
  def __contains__(self, upn: str) -> bool:
101
- return upn in self._approvers
168
+ with self._lock:
169
+ self._refresh()
170
+ return upn in self._approvers
102
171
 
103
172
 
104
173
  def may_enroll(who: Dict[str, Any], *, role: str = DEFAULT_APPROVER_ROLE, groups: Optional[list] = None) -> bool:
@@ -14,8 +14,6 @@ policy engine, a container runtime, or any client changes.
14
14
  gargoyle remote --out DIR [--upstream-url URL | -- <command>] the gate as an HTTPS MCP
15
15
  server, for hosted connectors
16
16
  gargoyle remote-token --out DIR --principal UPN a capability URL for one person
17
- gargoyle review-packet --out packet.zip [--audit-excerpt FILE] everything an outside
18
- reviewer needs, verifier included
19
17
  gargoyle audit <receipt.json> <execution.json> [grant.json]
20
18
  gargoyle audit --log audit.jsonl verify the middleware's evidence log
21
19
 
@@ -45,6 +43,8 @@ import os
45
43
  import re
46
44
  import stat
47
45
  import sys
46
+
47
+ from gargoyle.approvers import DEFAULT_APPROVER_ROLE
48
48
  from datetime import datetime, timedelta, timezone
49
49
  from pathlib import Path
50
50
 
@@ -109,7 +109,11 @@ def cmd_init(args) -> int:
109
109
  executor, executor_hex, executor_pub = new_key()
110
110
  exp = int((datetime.now(timezone.utc) + timedelta(days=args.token_days)).timestamp())
111
111
  client_token = encode({"sub": PRINCIPAL, "aud": "gargoyle-issuer", "exp": exp}, idp, "idp-1")
112
- approver_token = encode({"sub": "approver", "aud": "gargoyle-issuer", "exp": exp}, idp, "idp-1")
112
+ # One identity token per approver, named by the key id it travels with and
113
+ # carrying the approver role: the issuer shows the whole queue to approvers
114
+ # and only a person's own holds to anyone else.
115
+ def approver_token_for(kid: str) -> str:
116
+ return encode({"sub": kid, "roles": [DEFAULT_APPROVER_ROLE], "aud": "gargoyle-issuer", "exp": exp}, idp, "idp-1")
113
117
  issuer_url = args.issuer_url or "http://%s:%d" % (args.host, args.port)
114
118
 
115
119
  rules = out / "rules.json"
@@ -137,6 +141,7 @@ def cmd_init(args) -> int:
137
141
  _write(out / "middleware.env", _env_lines(middleware_env), private=True)
138
142
  b64url = lambda b: __import__("base64").urlsafe_b64encode(b).rstrip(b"=").decode()
139
143
  for n, (kid, _k, khex, kpub) in enumerate(approvers, 1):
144
+ approver_token = approver_token_for(kid)
140
145
  # approver.env / approver-web.json for the first; -2, -3 ... for the others.
141
146
  suffix = "" if n == 1 else "-%d" % n
142
147
  _write(out / ("approver%s.env" % suffix), _env_lines({
@@ -175,7 +180,9 @@ WantedBy=multi-user.target
175
180
  ProxyPass / http://127.0.0.1:%(port)d/
176
181
  ProxyPassReverse / http://127.0.0.1:%(port)d/
177
182
  ErrorLog ${APACHE_LOG_DIR}/gargoyle-error.log
178
- CustomLog ${APACHE_LOG_DIR}/gargoyle-access.log combined
183
+ # Remote-mode capability URLs carry a bearer token in the path; keep them out of the access log.
184
+ SetEnvIf Request_URI "^/mcp/" gargoyle_capability_url
185
+ CustomLog ${APACHE_LOG_DIR}/gargoyle-access.log combined env=!gargoyle_capability_url
179
186
  </VirtualHost>
180
187
  """ % {"host": (args.issuer_url or "gargoyle.example").replace("https://", "").replace("http://", "").split("/")[0].split(":")[0], "port": args.port})
181
188
  upstream = (["npx", "-y", "@modelcontextprotocol/server-filesystem", allowed] if args.upstream == "npx-filesystem"
@@ -284,18 +291,6 @@ def cmd_remote(args) -> int:
284
291
  return 0
285
292
 
286
293
 
287
- def cmd_review_packet(args) -> int:
288
- """One archive for an outside reviewer: spec, README, vectors, the
289
- verification source, a reviewer prompt, and verify_stdlib.py, which
290
- recomputes every vector with the standard library alone."""
291
- from gargoyle.review import build_packet
292
- root = Path(__file__).resolve().parent.parent
293
- excerpt = Path(args.audit_excerpt).read_text(encoding="utf-8") if args.audit_excerpt else None
294
- result = build_packet(str(root), args.out, audit_excerpt=excerpt, extra_files=args.extra or [])
295
- print("wrote %s (%d files); give the reviewer the archive and REVIEWER_PROMPT.txt" % (result["path"], len(result["files"])))
296
- return 0
297
-
298
-
299
294
  def cmd_issuer(args) -> int:
300
295
  import uvicorn
301
296
  from gargoyle.issuer_service import app_from_env
@@ -335,6 +330,33 @@ def cmd_approve(args) -> int:
335
330
  return approver_main(argv)
336
331
 
337
332
 
333
+ def cmd_approvers(args) -> int:
334
+ """List the enrolled approvers, or revoke one. Works on the issuer's
335
+ approvers.json; the running service re-reads the file, so a revocation
336
+ takes effect without a restart and reaches the middleware at its next
337
+ held call."""
338
+ from gargoyle.approvers import ApproverRegistry
339
+
340
+ reg = ApproverRegistry(str(Path(args.out).resolve() / "approvers.json"))
341
+ if args.action == "list":
342
+ entries = reg.entries()
343
+ if not entries:
344
+ print("no enrolled approvers (configured approver keys are in issuer.env)")
345
+ for upn, e in sorted(entries.items()):
346
+ print("%s enrolled %s key %s%s" % (upn, e.get("enrolled_at", "?"), e["public_key_hex"][:16],
347
+ (" oid " + e["oid"]) if e.get("oid") else ""))
348
+ revoked = reg.revoked_hex()
349
+ if revoked:
350
+ print("revoked: " + ", ".join(sorted(revoked)))
351
+ return 0
352
+ import getpass
353
+ if reg.revoke(args.upn, revoked_by="operator:" + getpass.getuser()):
354
+ print("revoked %s; grants under that key are refused from now on, earlier ones still audit" % args.upn)
355
+ return 0
356
+ print("%s is not an enrolled approver" % args.upn, file=sys.stderr)
357
+ return 1
358
+
359
+
338
360
  def cmd_audit(args) -> int:
339
361
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
340
362
  from gargoyle.execution import verify_chain
@@ -344,7 +366,11 @@ def cmd_audit(args) -> int:
344
366
  approvers = dict(keys["approver"])
345
367
  enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
346
368
  if enrolled.exists():
347
- approvers.update({u: e["public_key_hex"] for u, e in json.loads(enrolled.read_text()).get("approvers", {}).items()})
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()})
348
374
  trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
349
375
  approver_public_keys=load(approvers))
350
376
  if args.log:
@@ -468,14 +494,14 @@ def main(argv=None) -> int:
468
494
  p = sub.add_parser("remote-token", help="mint one person's capability URL for the remote gate")
469
495
  p.add_argument("--out", default="gargoyle-deploy")
470
496
  p.add_argument("--principal", required=True, help="the UPN or principal the receipts will name")
471
- p.add_argument("--days", type=int, default=365)
497
+ p.add_argument("--days", type=int, default=30, help="lifetime of the identity token inside the capability URL (default 30)")
472
498
  p.add_argument("--url", help="the gate's public base URL for the printed connector URL")
473
499
  p.set_defaults(fn=cmd_remote_token)
474
- p = sub.add_parser("review-packet", help="build the archive an outside reviewer needs")
475
- p.add_argument("--out", default="gargoyle-review-packet.zip")
476
- p.add_argument("--audit-excerpt", help="a text file with real audit output to include")
477
- p.add_argument("--extra", action="append", help="additional files to include (repeatable)")
478
- p.set_defaults(fn=cmd_review_packet)
500
+ p = sub.add_parser("approvers", help="list enrolled approvers, or revoke one on the issuer's host")
501
+ p.add_argument("action", choices=["list", "revoke"])
502
+ p.add_argument("upn", nargs="?", help="the approver to revoke")
503
+ p.add_argument("--out", default="gargoyle-deploy")
504
+ p.set_defaults(fn=cmd_approvers)
479
505
  p = sub.add_parser("audit", help="verify a receipt chain offline, or a whole evidence log")
480
506
  p.add_argument("receipt", nargs="?"); p.add_argument("execution", nargs="?"); p.add_argument("grant", nargs="?")
481
507
  p.add_argument("--log", help="the middleware's audit.jsonl: verify every entry")
@@ -32,15 +32,38 @@ entra_keys (fetched from the tenant's discovery endpoint; cache and
32
32
  refresh on an unknown kid, as every OIDC client does).
33
33
  """
34
34
 
35
+ import re
35
36
  from datetime import datetime, timezone
36
- from typing import Any, Dict, Optional
37
+ from typing import Any, Dict, Optional, Set
37
38
 
38
39
  from gargoyle.jws import (
39
40
  IDENTITY_ALGS, REF_JWS_AUDIENCE_MISMATCH, REF_JWS_EXPIRED, REF_JWS_MALFORMED, decode, keys_from_jwks,
40
41
  )
41
42
 
42
43
  REF_IDENTITY_NO_SUBJECT = "REF_IDENTITY_NO_SUBJECT"
44
+ REF_IDENTITY_WRONG_TENANT = "REF_IDENTITY_WRONG_TENANT" # a directory token for some other tenant, or one that names none
43
45
  ENTRA_JWKS_URL = "https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys"
46
+ ENTRA_OPENID_URL = "https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration"
47
+ _GUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
48
+
49
+
50
+ def entra_tenant_id(tenant: str, *, http: Any = None, timeout: float = 10.0) -> str:
51
+ """The tenant id (a GUID) for a tenant given by id or by domain. Tokens
52
+ name their tenant by id in `tid`, so the check in principal_from_token
53
+ needs the id; a domain is resolved once, from the tenant's OpenID
54
+ configuration, at startup. Raises on transport failure."""
55
+ if _GUID.match(tenant):
56
+ return tenant.lower()
57
+ import requests
58
+ http = http if http is not None else requests
59
+ r = http.get(ENTRA_OPENID_URL.format(tenant=tenant), timeout=timeout)
60
+ if r.status_code != 200:
61
+ raise RuntimeError("OpenID configuration fetch returned %s" % r.status_code)
62
+ issuer = r.json().get("issuer") or ""
63
+ m = re.search(r"/([0-9a-fA-F-]{36})/", issuer)
64
+ if not m or not _GUID.match(m.group(1)):
65
+ raise RuntimeError("OpenID configuration for %s names no tenant id: %r" % (tenant, issuer))
66
+ return m.group(1).lower()
44
67
 
45
68
 
46
69
  def entra_keys(tenant: str, *, http: Any = None, timeout: float = 10.0) -> Dict[str, Any]:
@@ -77,9 +100,18 @@ def _innermost_actor(act: Any) -> Optional[str]:
77
100
 
78
101
 
79
102
  def principal_from_token(token: str, public_keys: Dict[str, Any], *,
80
- audience: Any = None, now: Optional[datetime] = None) -> Dict[str, Any]:
103
+ audience: Any = None, now: Optional[datetime] = None,
104
+ tenant: Optional[str] = None, tenant_key_ids: Optional[Set[str]] = None) -> Dict[str, Any]:
81
105
  """{"principal", "on_behalf_of" (or None), "claims", "reason": None} for
82
- a trusted identity token, or {"principal": None, "reason": code}."""
106
+ a trusted identity token, or {"principal": None, "reason": code}.
107
+
108
+ `tenant` (a tenant id) makes the token's `tid` part of trust: a token
109
+ signed by one of `tenant_key_ids` (the keys fetched from the directory;
110
+ every key when None) must carry that tid, and its `iss`, when present,
111
+ must name it. Directory signing keys are shared across tenants, so a
112
+ valid signature alone says only that some tenant issued the token.
113
+ Tokens under other trusted keys (a pinned key `gargoyle init` wrote)
114
+ name no tenant and are not held to one."""
83
115
  res = decode(token, public_keys, allowed_algs=IDENTITY_ALGS)
84
116
  if res["reason"] is not None:
85
117
  return {"principal": None, "reason": res["reason"]}
@@ -97,6 +129,10 @@ def principal_from_token(token: str, public_keys: Dict[str, Any], *,
97
129
  accepted = audience if isinstance(audience, (list, tuple)) else [a.strip() for a in str(audience).split(",")]
98
130
  if not any(a in presented for a in accepted if a):
99
131
  return {"principal": None, "reason": REF_JWS_AUDIENCE_MISMATCH}
132
+ if tenant is not None and (tenant_key_ids is None or res["header"].get("kid") in tenant_key_ids):
133
+ tid = _nonempty(claims.get("tid")); iss = _nonempty(claims.get("iss"))
134
+ if tid is None or tid.lower() != tenant.lower() or (iss is not None and tenant.lower() not in iss.lower()):
135
+ return {"principal": None, "reason": REF_IDENTITY_WRONG_TENANT}
100
136
 
101
137
  # OIDC providers' `sub` is opaque; the UPN is the identity people and
102
138
  # rules recognise.
@@ -115,4 +151,4 @@ def principal_from_token(token: str, public_keys: Dict[str, Any], *,
115
151
  return {"principal": None, "reason": REF_IDENTITY_NO_SUBJECT}
116
152
  return {"principal": principal, "on_behalf_of": on_behalf_of, "claims": claims, "reason": None,
117
153
  "groups": _string_list(claims.get("groups")), "roles": _string_list(claims.get("roles")),
118
- "oid": _nonempty(claims.get("oid"))}
154
+ "oid": _nonempty(claims.get("oid")), "tid": _nonempty(claims.get("tid"))}