gargoyle-receipts 0.16.4.dev0__tar.gz → 0.17.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 (88) hide show
  1. {gargoyle_receipts-0.16.4.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.17.0.dev0}/PKG-INFO +13 -6
  2. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/README.md +12 -5
  3. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/adapters.py +4 -3
  4. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/cli.py +7 -3
  5. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/envelope.py +7 -3
  6. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/evidence.py +25 -0
  7. gargoyle_receipts-0.17.0.dev0/gargoyle/globs.py +58 -0
  8. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/issuer_service.py +151 -25
  9. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/mcp_server.py +22 -5
  10. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/plans.py +22 -2
  11. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/policy.py +3 -1
  12. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/reconcile.py +8 -0
  13. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/replay_cache.py +42 -0
  14. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/rules.py +156 -1
  15. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/state.py +17 -0
  16. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/static/approve.html +41 -7
  17. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +13 -6
  18. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +4 -0
  19. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/pyproject.toml +1 -1
  20. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_approvers.py +52 -3
  21. gargoyle_receipts-0.17.0.dev0/tests/test_globs.py +46 -0
  22. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_plans.py +88 -1
  23. gargoyle_receipts-0.17.0.dev0/tests/test_review_fixes.py +236 -0
  24. gargoyle_receipts-0.17.0.dev0/tests/test_state_text.py +48 -0
  25. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_threshold.py +2 -2
  26. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/LICENSE +0 -0
  27. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/NOTICE +0 -0
  28. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/__init__.py +0 -0
  29. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/__main__.py +0 -0
  30. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approval.py +0 -0
  31. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approver_cli.py +0 -0
  32. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approver_trust.py +0 -0
  33. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approvers.py +0 -0
  34. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/consumer.py +0 -0
  35. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/entra_login.py +0 -0
  36. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/execution.py +0 -0
  37. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/fs_server.py +0 -0
  38. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/holdstore.py +0 -0
  39. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/identity.py +0 -0
  40. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/issuer.py +0 -0
  41. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/jws.py +0 -0
  42. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/key_record_source.py +0 -0
  43. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/notify.py +0 -0
  44. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/opa.py +0 -0
  45. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/pending_store.py +0 -0
  46. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/published_record_source.py +0 -0
  47. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/remote.py +0 -0
  48. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/revert.py +0 -0
  49. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  50. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/static/approve.js +0 -0
  51. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/verifier.py +0 -0
  52. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/witness.py +0 -0
  53. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  54. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  55. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  56. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  57. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/setup.cfg +0 -0
  58. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_annotations.py +0 -0
  59. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_approver_cli.py +0 -0
  60. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_canonical.py +0 -0
  61. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_cli.py +0 -0
  62. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_console_js.py +0 -0
  63. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_consumer.py +0 -0
  64. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_entra_identity.py +0 -0
  65. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_entra_login.py +0 -0
  66. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_evidence.py +0 -0
  67. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_execution.py +0 -0
  68. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_fs_server.py +0 -0
  69. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_graph.py +0 -0
  70. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_historical.py +0 -0
  71. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_identity.py +0 -0
  72. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_issuer_service.py +0 -0
  73. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_jws.py +0 -0
  74. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_mcp_proxy.py +0 -0
  75. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  76. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_mcp_server.py +0 -0
  77. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_notify.py +0 -0
  78. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_opa.py +0 -0
  79. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_policy.py +0 -0
  80. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_published_record.py +0 -0
  81. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_remote.py +0 -0
  82. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_revert.py +0 -0
  83. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_rules.py +0 -0
  84. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_state.py +0 -0
  85. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_stdio_encoding.py +0 -0
  86. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_typescript_conformance.py +0 -0
  87. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_wire_spec.py +0 -0
  88. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.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.4.dev0
3
+ Version: 0.17.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
@@ -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.4.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.17.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
@@ -161,7 +161,9 @@ two-method interface.
161
161
  **No engine.** `gargoyle.rules.RulesPolicy` decides from a JSON file:
162
162
  first matching rule wins, globs on tool name, principal, delegation,
163
163
  the caller's groups and roles, and string arguments, actions allow, hold
164
- or deny. The pin is the file's hash,
164
+ or deny. Argument globs that look like paths are matched on a normalized
165
+ form (separators, `..`, letter case), so a rule for one folder is not
166
+ slipped by traversal or spelling; `*` spans folders. The pin is the file's hash,
165
167
  so an edit invalidates outstanding receipts exactly as an engine change
166
168
  does. This is what `gargoyle init` sets up.
167
169
 
@@ -303,7 +305,11 @@ stay on file so earlier grants still audit; new ones are refused at the
303
305
  issuer and at every middleware on its next held call. Revocation retires
304
306
  a key, not a person: while the Approver role stands, the person can sign
305
307
  in and enroll a fresh key, so remove the role too; the revoked key itself
306
- is never accepted again. Every key an approver held stays in the file
308
+ is never accepted again. The role is what counts at release time: a
309
+ grant under an enrolled key is accepted only from that account, under a
310
+ token that carries the role now, so a role removed in the directory ends
311
+ the power at once. One key per account: a second browser does not enroll
312
+ until the first key consents or the operator revokes it. Every key an approver held stays in the file
307
313
  (`history`), so an audit of grants released under an earlier key still
308
314
  verifies: copy the issuer's current `approvers.json` beside the evidence
309
315
  before auditing. Approvers, and
@@ -402,6 +408,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
402
408
  | 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` |
403
409
  | 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 |
404
410
  | 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 |
411
+ | issuer `GARGOYLE_ALLOW_SELF_APPROVAL` | `1` lets a person release their own call; by default the approver may be neither the hold's principal nor the party acted for (`REF_ISSUER_SELF_APPROVAL`). A grant for an expired hold is `REF_ISSUER_HOLD_EXPIRED` and the hold is dropped |
405
412
  | 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 |
406
413
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
407
414
  | `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 |
@@ -410,7 +417,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
410
417
  | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
411
418
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
412
419
  | `GARGOYLE_MCP_SNAPSHOT_DIR` | where the bytes a held, state-bound write replaces are kept (default `snapshots/` beside the audit log; `none` disables undo); the middleware then offers a `gargoyle_revert` tool that restores an approved change through the gate, held and state-bound like any write |
413
- | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file |
420
+ | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file; the replay claims that make a receipt single-use live beside it in `<log stem>.replay.sqlite`, so a restarted gate still refuses a receipt it honored |
414
421
 
415
422
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
416
423
  `app_from_env()`): identity token in, receipt out, with the hold and grant
@@ -458,7 +465,7 @@ on it.
458
465
  | `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
459
466
  | `fs_server` | a small Python filesystem MCP server, for clients without Node |
460
467
  | `issuer_service` | the HTTP issuing service: identity in, receipts out, holds and grants |
461
- | `replay_cache`, `pending_store` | single use for receipts and holds; in-memory or Redis |
468
+ | `replay_cache`, `pending_store` | single use for receipts and holds; in memory, SQLite beside the audit log (the middleware's default) or Redis |
462
469
  | `approval`, `approver_cli`, `approver_trust` | grants, the human's tool, approver key trust |
463
470
  | `published_record_source` | the live policy pin as a publisher-signed record with serial and expiry |
464
471
  | `key_record_source` | root-signed issuer and approver keys with windows and revocation |
@@ -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.4.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.17.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
@@ -123,7 +123,9 @@ two-method interface.
123
123
  **No engine.** `gargoyle.rules.RulesPolicy` decides from a JSON file:
124
124
  first matching rule wins, globs on tool name, principal, delegation,
125
125
  the caller's groups and roles, and string arguments, actions allow, hold
126
- or deny. The pin is the file's hash,
126
+ or deny. Argument globs that look like paths are matched on a normalized
127
+ form (separators, `..`, letter case), so a rule for one folder is not
128
+ slipped by traversal or spelling; `*` spans folders. The pin is the file's hash,
127
129
  so an edit invalidates outstanding receipts exactly as an engine change
128
130
  does. This is what `gargoyle init` sets up.
129
131
 
@@ -265,7 +267,11 @@ stay on file so earlier grants still audit; new ones are refused at the
265
267
  issuer and at every middleware on its next held call. Revocation retires
266
268
  a key, not a person: while the Approver role stands, the person can sign
267
269
  in and enroll a fresh key, so remove the role too; the revoked key itself
268
- is never accepted again. Every key an approver held stays in the file
270
+ is never accepted again. The role is what counts at release time: a
271
+ grant under an enrolled key is accepted only from that account, under a
272
+ token that carries the role now, so a role removed in the directory ends
273
+ the power at once. One key per account: a second browser does not enroll
274
+ until the first key consents or the operator revokes it. Every key an approver held stays in the file
269
275
  (`history`), so an audit of grants released under an earlier key still
270
276
  verifies: copy the issuer's current `approvers.json` beside the evidence
271
277
  before auditing. Approvers, and
@@ -364,6 +370,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
364
370
  | 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` |
365
371
  | 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 |
366
372
  | 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 |
373
+ | issuer `GARGOYLE_ALLOW_SELF_APPROVAL` | `1` lets a person release their own call; by default the approver may be neither the hold's principal nor the party acted for (`REF_ISSUER_SELF_APPROVAL`). A grant for an expired hold is `REF_ISSUER_HOLD_EXPIRED` and the hold is dropped |
367
374
  | 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 |
368
375
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
369
376
  | `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 |
@@ -372,7 +379,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
372
379
  | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
373
380
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
374
381
  | `GARGOYLE_MCP_SNAPSHOT_DIR` | where the bytes a held, state-bound write replaces are kept (default `snapshots/` beside the audit log; `none` disables undo); the middleware then offers a `gargoyle_revert` tool that restores an approved change through the gate, held and state-bound like any write |
375
- | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file |
382
+ | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file; the replay claims that make a receipt single-use live beside it in `<log stem>.replay.sqlite`, so a restarted gate still refuses a receipt it honored |
376
383
 
377
384
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
378
385
  `app_from_env()`): identity token in, receipt out, with the hold and grant
@@ -420,7 +427,7 @@ on it.
420
427
  | `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
421
428
  | `fs_server` | a small Python filesystem MCP server, for clients without Node |
422
429
  | `issuer_service` | the HTTP issuing service: identity in, receipts out, holds and grants |
423
- | `replay_cache`, `pending_store` | single use for receipts and holds; in-memory or Redis |
430
+ | `replay_cache`, `pending_store` | single use for receipts and holds; in memory, SQLite beside the audit log (the middleware's default) or Redis |
424
431
  | `approval`, `approver_cli`, `approver_trust` | grants, the human's tool, approver key trust |
425
432
  | `published_record_source` | the live policy pin as a publisher-signed record with serial and expiry |
426
433
  | `key_record_source` | root-signed issuer and approver keys with windows and revocation |
@@ -135,9 +135,10 @@ class GraphAdapter:
135
135
  @staticmethod
136
136
  def _desc(ref: str, obj: Any, summary: str, irreversible: bool = False) -> Tuple[Dict[str, Any], Optional[bytes]]:
137
137
  data = canonical_json(obj).encode("utf-8") if obj is not None else None
138
- desc = describe(ref, data, with_text=False)
139
- if data is not None:
140
- desc["text"] = json.dumps(obj, indent=2, sort_keys=True)
138
+ # The text is the hashed bytes themselves (canonical JSON), so the
139
+ # console can verify it against the bound state before showing it;
140
+ # it pretty-prints JSON for display.
141
+ desc = describe(ref, data, with_text=True)
141
142
  desc["summary"] = summary
142
143
  if irreversible:
143
144
  desc["irreversible"] = True
@@ -395,15 +395,19 @@ def _audit_summary(report, raw) -> None:
395
395
  releases: Counter = Counter()
396
396
  clients: Counter = Counter()
397
397
  ats = [e.get("at") for e in raw if isinstance(e, dict) and isinstance(e.get("at"), str)]
398
+ from gargoyle.evidence import FAILED_VERDICTS
398
399
  for r in report:
399
- counts.setdefault((r["principal"] or "?", r["tool"] or "?"), Counter())[r["kind"] or "?"] += 1
400
+ kind = r["kind"] or "?"
401
+ if kind == "honored" and r.get("verdict") in FAILED_VERDICTS:
402
+ kind = "failed" # admitted, but the upstream did not do it
403
+ counts.setdefault((r["principal"] or "?", r["tool"] or "?"), Counter())[kind] += 1
400
404
  for who in r.get("released_by") or []:
401
405
  releases[who] += 1
402
406
  clients[r.get("client") or "(client unnamed)"] += 1
403
407
  print("summary: %d call(s)%s" % (len(report), (", %s to %s" % (min(ats)[:19], max(ats)[:19])) if ats else ""))
404
- print(" %-28s %-24s %7s %7s" % ("principal", "tool", "honored", "refused"))
408
+ print(" %-28s %-24s %7s %7s %7s" % ("principal", "tool", "honored", "failed", "refused"))
405
409
  for (principal, tool), c in sorted(counts.items(), key=lambda kv: (kv[0][0], -sum(kv[1].values()), kv[0][1])):
406
- print(" %-28s %-24s %7d %7d" % (principal[:28], tool[:24], c.get("honored", 0), c.get("refused", 0)))
410
+ print(" %-28s %-24s %7d %7d %7d" % (principal[:28], tool[:24], c.get("honored", 0), c.get("failed", 0), c.get("refused", 0)))
407
411
  if releases:
408
412
  print(" releases: " + ", ".join("%s %d" % (w, n) for w, n in releases.most_common()))
409
413
  print(" clients: " + ", ".join("%s %d" % (w, n) for w, n in clients.most_common()))
@@ -208,7 +208,9 @@ def valid_principal(principal: Any) -> bool:
208
208
  def valid_approval(approval: Any) -> bool:
209
209
  """True iff `approval` is a well-formed receipt `approval` member:
210
210
  {"required": int >= 1, "approvers": [non-empty unique strings] optional}."""
211
- if not isinstance(approval, dict) or not set(approval) <= {"required", "approvers"} or "required" not in approval:
211
+ if not isinstance(approval, dict) or not set(approval) <= {"required", "approvers", "plannable"} or "required" not in approval:
212
+ return False
213
+ if "plannable" in approval and not isinstance(approval["plannable"], bool):
212
214
  return False
213
215
  required = approval["required"]
214
216
  if isinstance(required, bool) or not isinstance(required, int) or required < 1:
@@ -320,6 +322,8 @@ def build_envelope(
320
322
  envelope["approval"] = {"required": approval["required"]}
321
323
  if "approvers" in approval:
322
324
  envelope["approval"]["approvers"] = sorted(approval["approvers"])
325
+ if approval.get("plannable") is False:
326
+ envelope["approval"]["plannable"] = False
323
327
  envelope["decision_sha256"] = decision_sha256_of(envelope)
324
328
  return envelope
325
329
 
@@ -329,8 +333,8 @@ def approval_terms(envelope: Dict[str, Any]) -> Dict[str, Any]:
329
333
  the receipt's `approval` member, or the default of one from anyone."""
330
334
  a = envelope.get("approval")
331
335
  if isinstance(a, dict):
332
- return {"required": a["required"], "approvers": a.get("approvers")}
333
- return {"required": 1, "approvers": None}
336
+ return {"required": a["required"], "approvers": a.get("approvers"), "plannable": a.get("plannable", True)}
337
+ return {"required": 1, "approvers": None, "plannable": True}
334
338
 
335
339
 
336
340
  def reassert(
@@ -63,6 +63,17 @@ REF_EVIDENCE_HASH_MISMATCH = "REF_EVIDENCE_HASH_MISMATCH" # entry_sha256 does n
63
63
  REF_EVIDENCE_CHAIN_BROKEN = "REF_EVIDENCE_CHAIN_BROKEN" # seq or prev_sha256 does not follow
64
64
  REF_EVIDENCE_RECEIPT_MISSING = "REF_EVIDENCE_RECEIPT_MISSING" # honored without receipt or execution
65
65
  REF_EVIDENCE_UNCHAINED = "REF_EVIDENCE_UNCHAINED" # a pre-chain {receipt, grant, execution} line
66
+ REF_EVIDENCE_REPLAY = "REF_EVIDENCE_REPLAY" # a decision id (or plan step) honored twice in one log
67
+
68
+ # Verdicts an honored entry carries when the admitted call did not succeed
69
+ # downstream: the receipt was claimed, so the entry must exist, and the
70
+ # verdict says the change most likely did not happen (the execution receipt's
71
+ # outputs carry the error). Any other verdict on an honored entry is the
72
+ # verifier's accept reason.
73
+ TOOL_ERROR = "TOOL_ERROR" # the upstream answered with isError: true
74
+ UPSTREAM_ERROR = "UPSTREAM_ERROR" # the upstream answered with a JSON-RPC error
75
+ UPSTREAM_UNAVAILABLE = "UPSTREAM_UNAVAILABLE" # the upstream could not be reached after the claim
76
+ FAILED_VERDICTS = frozenset((TOOL_ERROR, UPSTREAM_ERROR, UPSTREAM_UNAVAILABLE))
66
77
 
67
78
  _FIELDS = ("evidence_version", "seq", "prev_sha256", "at", "kind", "tool", "args_sha256",
68
79
  "principal", "reason", "receipt", "grant", "execution", "entry_sha256")
@@ -191,6 +202,7 @@ def verify_log(
191
202
  expect_seq, prev, tail = 1, GENESIS, GENESIS
192
203
  counts = {HONORED: 0, REFUSED: 0}
193
204
  all_ok = True
205
+ claimed: Dict[str, int] = {} # decision_id / plan step -> seq of the honored entry that spent it
194
206
 
195
207
  for raw in entries:
196
208
  if not _well_formed(raw):
@@ -220,6 +232,19 @@ def verify_log(
220
232
  if ok and (rc.get("tool") != e["tool"] or rc.get("args_sha256") != e["args_sha256"]
221
233
  or rc.get("principal") != e["principal"]):
222
234
  ok, reason = False, REF_EVIDENCE_RECEIPT_MISSING
235
+ if ok:
236
+ # Single use, checked after the fact: a decision id, or a
237
+ # plan step, honored twice in one log is a replay the
238
+ # executor's cache missed (a second process, a lost cache).
239
+ keys = [str(e["receipt"].get("decision_id"))]
240
+ gs = e["grant"]
241
+ if isinstance(gs, dict) and "plan" in gs and "step" in gs:
242
+ keys.append("plan:%s:%s" % ((gs["plan"] or {}).get("plan_id"), gs["step"]))
243
+ if any(k in claimed for k in keys):
244
+ ok, reason = False, REF_EVIDENCE_REPLAY
245
+ else:
246
+ for k in keys:
247
+ claimed[k] = e["seq"]
223
248
  else:
224
249
  reason = ACCEPT_ENTRY_VERIFIED
225
250
  if e["receipt"] is not None:
@@ -0,0 +1,58 @@
1
+ """Argument globs, as rules (gargoyle.rules) and plan steps (gargoyle.plans)
2
+ use them: `fnmatch` patterns over one argument value, with paths made safe
3
+ to match.
4
+
5
+ A raw `fnmatch` on a path is a hole: `C:\\proj\\*` is slipped by
6
+ `C:\\proj\\..\\secrets\\x`, by `c:\\PROJ\\x` on a case-insensitive disk and
7
+ by `C:/proj/x`. So when either side looks like a path (a separator, or a
8
+ drive letter), both are normalized first: separators become `/`, `.` and
9
+ `..` segments are resolved lexically, repeated separators collapse, and
10
+ the whole string is lower-cased, since the gate cannot know the target
11
+ filesystem's case rules and a rule meant for one folder must not be
12
+ slipped by a letter. Values that look like nothing of the kind (a mail
13
+ address, an account id) are matched exactly as written, case included.
14
+
15
+ `*` spans folders: a rule for `C:\\proj\\*` covers the whole tree under it.
16
+ There is no segment-bounded wildcard; a rule that wants one folder level
17
+ names it.
18
+ """
19
+
20
+ import fnmatch
21
+ import posixpath
22
+ import re
23
+
24
+ _PATHLIKE = re.compile(r"[\\/]|^[A-Za-z]:")
25
+ _DRIVE = re.compile(r"^([A-Za-z]:)(.*)$", re.S)
26
+
27
+
28
+ def is_pathlike(value: str) -> bool:
29
+ return bool(_PATHLIKE.search(value))
30
+
31
+
32
+ def normalize_path(value: str) -> str:
33
+ """The comparable form of a path or path glob (see the module note)."""
34
+ text = value.replace("\\", "/")
35
+ drive = ""
36
+ m = _DRIVE.match(text)
37
+ if m:
38
+ drive, text = m.group(1), m.group(2) or "/"
39
+ if text.startswith("/"):
40
+ text = "/" + text.lstrip("/") # after a drive, repeated separators are just separators
41
+ unc = not drive and text.startswith("//") and not text.startswith("///")
42
+ text = posixpath.normpath(text) if text else ""
43
+ if unc and not text.startswith("//"):
44
+ text = "/" + text
45
+ return (drive + text).lower()
46
+
47
+
48
+ def comparable(glob: str, value: str):
49
+ """Both sides as they are compared: normalized when either is a path."""
50
+ if is_pathlike(value) or is_pathlike(glob):
51
+ return normalize_path(glob), normalize_path(value)
52
+ return glob, value
53
+
54
+
55
+ def glob_match(glob: str, value: str) -> bool:
56
+ """Does the argument value match the glob? Paths normalized first."""
57
+ g, v = comparable(glob, value)
58
+ return fnmatch.fnmatchcase(v, g)
@@ -53,7 +53,7 @@ STATIC = Path(__file__).resolve().parent / "static"
53
53
  from gargoyle.approval import (REF_APPROVAL_APPROVER_NOT_ELIGIBLE, REF_APPROVAL_DUPLICATE_APPROVER,
54
54
  verify_grant)
55
55
  from gargoyle.envelope import approval_terms
56
- from gargoyle.state import state_sha256, valid_description
56
+ from gargoyle.state import state_sha256, text_consistent, valid_description
57
57
  from gargoyle.holdstore import ENV_HOLDS_PATH, HoldStore
58
58
  from gargoyle.witness import WitnessLog
59
59
  from gargoyle.plans import (REF_PLAN_MALFORMED, REF_PLAN_STEP_NOT_PLANNABLE, REF_PLAN_STEP_REFUSED, build_plan,
@@ -62,6 +62,7 @@ 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
63
  ApproverRegistry, RevokedKeyError, may_enroll, valid_public_key_hex)
64
64
  from gargoyle.identity import entra_keys, entra_tenant_id, principal_from_token
65
+ from gargoyle.jws import REF_JWS_KEY_UNKNOWN
65
66
  from gargoyle.issuer import DEFAULT_TTL_SECONDS, issue
66
67
  from gargoyle.policy import Policy
67
68
 
@@ -80,6 +81,7 @@ ENV_RECEIPT_TTL = "GARGOYLE_RECEIPT_TTL_SECONDS"
80
81
  ENV_CONSOLE_CLIENT_ID = "GARGOYLE_ENTRA_CLIENT_ID" # the public client app the console signs in with (User-Client-Flow)
81
82
  ENV_CONSOLE_SCOPE = "GARGOYLE_ENTRA_SCOPE" # default api://<last audience>/access
82
83
  ENV_MAX_OPEN_HOLDS = "GARGOYLE_MAX_OPEN_HOLDS" # holds one principal may have waiting at once
84
+ ENV_ALLOW_SELF_APPROVAL = "GARGOYLE_ALLOW_SELF_APPROVAL" # "1": a person may release their own call (default: refused)
83
85
  DEFAULT_MAX_OPEN_HOLDS = 20
84
86
 
85
87
  REF_ISSUER_UNAUTHENTICATED = "REF_ISSUER_UNAUTHENTICATED"
@@ -91,6 +93,31 @@ REF_ISSUER_NOT_AN_APPROVER = "REF_ISSUER_NOT_AN_APPROVER" # enrollment without
91
93
  REF_ISSUER_FORBIDDEN = "REF_ISSUER_FORBIDDEN" # authenticated, but not an approver and not the hold's own principal
92
94
  REF_ISSUER_TOO_MANY_HOLDS = "REF_ISSUER_TOO_MANY_HOLDS" # the principal already has the cap's worth of holds waiting
93
95
  REF_ISSUER_KEY_REVOKED = "REF_ISSUER_KEY_REVOKED" # enrollment offered a key that was revoked; a new key is needed
96
+ REF_ISSUER_KEY_ENROLLED = "REF_ISSUER_KEY_ENROLLED" # another key is enrolled for this account; it must consent, or the operator revoke it
97
+ REF_ISSUER_HOLD_EXPIRED = "REF_ISSUER_HOLD_EXPIRED" # the hold's receipt or plan expired; nothing can release it now
98
+ REF_ISSUER_SELF_APPROVAL = "REF_ISSUER_SELF_APPROVAL" # the approver is the hold's requester (or the party acted for)
99
+ REF_ISSUER_STATE_TEXT_MISMATCH = "REF_ISSUER_STATE_TEXT_MISMATCH" # the state text is not the bytes the state hash names
100
+
101
+
102
+ SUPERSEDE_PREFIX = b"gargoyle-enroll/1\n"
103
+
104
+
105
+ def supersede_message(new_public_key_hex: str) -> bytes:
106
+ """What an enrolled key signs to hand its account to a new key."""
107
+ return SUPERSEDE_PREFIX + new_public_key_hex.encode("ascii")
108
+
109
+
110
+ def _supersedes(current_hex: str, signature_hex: Any, new_hex: str) -> bool:
111
+ """Does `signature_hex` (Ed25519, by the key `current_hex`) consent to `new_hex`?"""
112
+ from cryptography.exceptions import InvalidSignature
113
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
114
+ if not isinstance(signature_hex, str) or len(signature_hex) != 128:
115
+ return False
116
+ try:
117
+ Ed25519PublicKey.from_public_bytes(bytes.fromhex(current_hex)).verify(bytes.fromhex(signature_hex), supersede_message(new_hex))
118
+ return True
119
+ except (InvalidSignature, ValueError):
120
+ return False
94
121
 
95
122
 
96
123
  def build_issuer_app(
@@ -114,10 +141,19 @@ def build_issuer_app(
114
141
  tenant: Optional[str] = None,
115
142
  tenant_key_ids: Optional[set] = None,
116
143
  max_open_holds: int = DEFAULT_MAX_OPEN_HOLDS,
144
+ idp_key_refresh: Optional[Callable[[], Dict[str, Any]]] = None,
145
+ allow_self_approval: bool = False,
117
146
  ) -> FastAPI:
118
147
  """`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
119
148
  posts it to a webhook); it is notice, not admission, and its failure
120
- never affects the hold."""
149
+ never affects the hold. `idp_key_refresh()` returns the identity
150
+ provider's current signing keys {kid: key}; it is called, at most once a
151
+ minute, when a token names a kid the service does not know, so a key the
152
+ directory rolled after startup is picked up without a restart."""
153
+ # The trusted identity keys are held in a dict this app owns: a refresh adds to them.
154
+ idp_public_keys = dict(idp_public_keys)
155
+ tenant_key_ids = set(tenant_key_ids) if tenant_key_ids is not None else None
156
+ key_refreshed_at: list = [None]
121
157
  app = FastAPI(title="Gargoyle issuing service")
122
158
  # Everything a human may be in the middle of acting on lives in the hold
123
159
  # store (gargoyle.holdstore): SQLite on disk in a deployment, memory in tests.
@@ -170,9 +206,21 @@ def build_issuer_app(
170
206
 
171
207
  def is_approver(who: Dict[str, Any]) -> bool:
172
208
  """May this identity see every hold and the witness: the approver role
173
- or group, a configured approver key id, or an enrolled approver."""
209
+ or group in today's token, or a configured approver key id. An
210
+ enrolled key alone does not count: the directory decides who is an
211
+ approver now, the registry only remembers which key is theirs."""
174
212
  upn = who["principal"]
175
- return may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers or upn in registry
213
+ return may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers
214
+
215
+ def _enrolled_speaks_for(who: Dict[str, Any], approver_key_id: str) -> bool:
216
+ """May a grant under this key id be posted by `who`? A configured
217
+ (static) key is trusted by configuration. An enrolled key releases a
218
+ hold only when posted by its own account, under a token that still
219
+ carries the approver role or group: losing the role ends the power
220
+ at once, whatever key record remains (second review, finding 2)."""
221
+ if approver_key_id in static_approvers or approver_key_id not in registry:
222
+ return True
223
+ return who["principal"] == approver_key_id and may_enroll(who, role=approver_role, groups=approver_groups)
176
224
 
177
225
  def hold_owner(arid: str) -> Optional[str]:
178
226
  """The principal whose call is held under `arid` (a hold or a plan), or None."""
@@ -200,11 +248,35 @@ def build_issuer_app(
200
248
  def _refuse(status: int, reason: str) -> HTTPException:
201
249
  return HTTPException(status_code=status, detail={"reason": reason})
202
250
 
251
+ def _refresh_idp_keys() -> bool:
252
+ """Fetch the identity provider's keys again (an unknown kid is how a
253
+ key roll shows up), at most once a minute. True when new keys came."""
254
+ if idp_key_refresh is None:
255
+ return False
256
+ current = now()
257
+ last = key_refreshed_at[0]
258
+ if last is not None and (current - last).total_seconds() < 60:
259
+ return False
260
+ key_refreshed_at[0] = current
261
+ try:
262
+ fresh = idp_key_refresh()
263
+ except Exception as exc:
264
+ import sys
265
+ sys.stderr.write("gargoyle: identity key refresh failed: %s\n" % exc)
266
+ return False
267
+ new = {k: v for k, v in fresh.items() if k not in idp_public_keys}
268
+ idp_public_keys.update(fresh)
269
+ if tenant_key_ids is not None:
270
+ tenant_key_ids.update(fresh)
271
+ return bool(new)
272
+
203
273
  def authenticate(authorization: Optional[str]) -> Dict[str, Any]:
204
274
  if not authorization or not authorization.startswith("Bearer "):
205
275
  raise _refuse(401, REF_ISSUER_UNAUTHENTICATED)
206
- who = principal_from_token(authorization[7:].strip(), idp_public_keys, audience=audience, now=now(),
207
- tenant=tenant, tenant_key_ids=tenant_key_ids)
276
+ token = authorization[7:].strip()
277
+ who = principal_from_token(token, idp_public_keys, audience=audience, now=now(), tenant=tenant, tenant_key_ids=tenant_key_ids)
278
+ if who["reason"] == REF_JWS_KEY_UNKNOWN and _refresh_idp_keys():
279
+ who = principal_from_token(token, idp_public_keys, audience=audience, now=now(), tenant=tenant, tenant_key_ids=tenant_key_ids)
208
280
  if who["reason"] is not None:
209
281
  raise _refuse(401, who["reason"])
210
282
  return who
@@ -227,11 +299,17 @@ def build_issuer_app(
227
299
  json.dumps(body["args"], ensure_ascii=False).encode("utf-8")
228
300
  except UnicodeEncodeError:
229
301
  raise _refuse(400, REF_ISSUER_ARGS_NOT_TEXT)
230
- # The policy sees the identity provider's groups and roles as claims;
231
- # the receipt binds only the call and the principal.
232
- interaction = {**body.get("claims", {}), "tool": body["tool"], "args": body["args"],
302
+ # The policy sees the identity provider's groups and roles; the receipt
303
+ # binds only the call and the principal (and the token's delegation).
304
+ # Anything the caller asserts in `claims` is kept apart under
305
+ # interaction["claims"]: it never sets principal, on_behalf_of,
306
+ # groups, roles or the state hash, which come from the token and the
307
+ # described state alone (second review, finding 3).
308
+ interaction = {"tool": body["tool"], "args": body["args"],
233
309
  "principal": who["principal"], "groups": who.get("groups", []),
234
310
  "roles": who.get("roles", [])}
311
+ if body.get("claims"):
312
+ interaction["claims"] = dict(body["claims"])
235
313
  state = body.get("state")
236
314
  if state is not None:
237
315
  # The executor's description of the target before the call: its
@@ -239,6 +317,9 @@ def build_issuer_app(
239
317
  # the approver and seen by the policy.
240
318
  if not valid_description(state):
241
319
  raise _refuse(400, REF_ISSUER_BAD_REQUEST)
320
+ if not text_consistent(state):
321
+ # The text shown to the approver must hash to the bound state.
322
+ raise _refuse(400, REF_ISSUER_STATE_TEXT_MISMATCH)
242
323
  interaction["state_sha256"] = state_sha256(state)
243
324
  interaction["state"] = {k: v for k, v in state.items() if k != "text"}
244
325
  if who["on_behalf_of"]:
@@ -250,15 +331,18 @@ def build_issuer_app(
250
331
  return {"issued": False, "reason": result.decision.reason}
251
332
  covered = False
252
333
  plan_id = body.get("plan_id")
253
- if result.envelope["requires_approval"] and isinstance(plan_id, str):
254
- # The executor says an approved plan covers this call. No card then:
255
- # the executor enforces the plan (step single use, grants, expiry)
256
- # and the console should not show a hold nobody needs to act on.
257
- # A false claim costs nothing here: without a valid release the
258
- # executor refuses the call.
259
- from gargoyle.plans import match_step
334
+ if result.envelope["requires_approval"] and isinstance(plan_id, str) and result.decision.plannable:
335
+ # The executor says an approved plan covers this call. No card then,
336
+ # provided the plan's grants meet this receipt's own terms (as many
337
+ # signatures, from its named approvers): the executor enforces the
338
+ # same, so a hold here would be one nobody needs to act on. A call
339
+ # the policy keeps individual is never covered. A false claim costs
340
+ # nothing: without a valid release the executor refuses the call.
341
+ from gargoyle.plans import grants_satisfy, match_step
342
+ env_terms = approval_terms(result.envelope)
260
343
  for arid_p, plan in app.state.pending_plans.items():
261
- if plan["plan_id"] == plan_id and _complete(arid_p) and match_step(plan, body["tool"], body["args"]) is not None:
344
+ if (plan["plan_id"] == plan_id and _complete(arid_p) and match_step(plan, body["tool"], body["args"]) is not None
345
+ and grants_satisfy(env_terms, [g["approver_key_id"] for g in app.state.grants.get(arid_p, [])])):
262
346
  covered = True
263
347
  break
264
348
  if result.envelope["requires_approval"] and not covered and _open_holds(who["principal"]) >= max_open_holds:
@@ -312,7 +396,16 @@ def build_issuer_app(
312
396
  interaction = {"tool": s["tool"], "args": judged_args, "principal": who["principal"],
313
397
  "groups": who.get("groups", []), "roles": who.get("roles", [])}
314
398
  try:
315
- d = policy.decide(body["target_url"], interaction)
399
+ if "match" in step:
400
+ # A pattern step is judged by the strictest rule any call
401
+ # matching it could reach (gargoyle.rules.decide_rules_pattern).
402
+ # A policy that cannot judge patterns keeps them out of plans.
403
+ decide_pattern = getattr(policy, "decide_pattern", None)
404
+ if decide_pattern is None:
405
+ return {"issued": False, "reason": REF_PLAN_STEP_NOT_PLANNABLE, "step": len(norm)}
406
+ d = decide_pattern(body["target_url"], interaction)
407
+ else:
408
+ d = policy.decide(body["target_url"], interaction)
316
409
  except Exception:
317
410
  return {"issued": False, "reason": REF_PLAN_STEP_REFUSED}
318
411
  if not d.eligible:
@@ -355,8 +448,7 @@ def build_issuer_app(
355
448
  live = {}
356
449
  for arid, plan in list(app.state.pending_plans.items()):
357
450
  if datetime.fromisoformat(plan["not_after"]) <= current:
358
- del app.state.pending_plans[arid]
359
- app.state.grants.pop(arid, None)
451
+ _forget(arid)
360
452
  elif len(app.state.grants.get(arid, [])) < _plan_terms(plan)["required"]:
361
453
  if not sees_all and plan.get("principal") != who["principal"]:
362
454
  continue
@@ -365,10 +457,7 @@ def build_issuer_app(
365
457
  "approvers": [g["approver_key_id"] for g in have]}
366
458
  for arid, env in list(app.state.pending.items()):
367
459
  if datetime.fromisoformat(env["not_after"]) <= current:
368
- del app.state.pending[arid] # an expired hold can never be released
369
- app.state.pending_args.pop(arid, None)
370
- app.state.pending_state.pop(arid, None)
371
- app.state.grants.pop(arid, None)
460
+ _forget(arid) # an expired hold can never be released
372
461
  elif not _complete(arid):
373
462
  if not sees_all and env.get("request_context", {}).get("principal") != who["principal"]:
374
463
  continue
@@ -379,6 +468,14 @@ def build_issuer_app(
379
468
  "approvers": [g["approver_key_id"] for g in have]}
380
469
  return {"pending": list(live.values())}
381
470
 
471
+ def _forget(arid: str) -> None:
472
+ """Drop a hold or plan and everything kept for it."""
473
+ app.state.pending.pop(arid, None)
474
+ app.state.pending_args.pop(arid, None)
475
+ app.state.pending_state.pop(arid, None)
476
+ app.state.pending_plans.pop(arid, None)
477
+ app.state.grants.pop(arid, None)
478
+
382
479
  def _complete(arid: str) -> bool:
383
480
  env = app.state.pending.get(arid)
384
481
  if env is None:
@@ -388,7 +485,7 @@ def build_issuer_app(
388
485
 
389
486
  @app.post("/grants")
390
487
  async def post_grant(request: Request, authorization: Optional[str] = Header(default=None)):
391
- authenticate(authorization)
488
+ who = authenticate(authorization)
392
489
  try:
393
490
  grant = await request.json()
394
491
  except Exception:
@@ -404,6 +501,13 @@ def build_issuer_app(
404
501
  kid = grant.get("approver_key_id")
405
502
  event("grant_refused", approval_request_id=arid, reason=reason, **({"approver": kid} if isinstance(kid, str) else {}))
406
503
  return {"accepted": False, "reason": reason}
504
+ if datetime.fromisoformat((env or plan)["not_after"]) <= now():
505
+ # An expired hold can never be released: the executor refuses the
506
+ # receipt whatever the grant says, so accepting one here would
507
+ # only tell the approver that something ran. Drop it now, not on
508
+ # the next listing (second review, finding 10).
509
+ _forget(arid)
510
+ return refused(REF_ISSUER_HOLD_EXPIRED)
407
511
  trusted = approver_keys()
408
512
  if not trusted:
409
513
  return refused(REF_ISSUER_NO_APPROVER_TRUST)
@@ -412,6 +516,17 @@ def build_issuer_app(
412
516
  gate_key_id=key_id, now=now())
413
517
  if not g["accepted"]:
414
518
  return refused(g["reason"])
519
+ if not _enrolled_speaks_for(who, grant["approver_key_id"]):
520
+ return refused(REF_ISSUER_NOT_AN_APPROVER)
521
+ if not allow_self_approval:
522
+ # Two people: the one asking and the one releasing. The approver
523
+ # key id (a UPN for an enrolled key) may be neither the hold's
524
+ # principal nor the party it acts for (second review, finding 8).
525
+ parties = {hold_owner(arid)}
526
+ if env is not None:
527
+ parties.add((env.get("request_context") or {}).get("on_behalf_of"))
528
+ if grant["approver_key_id"] in parties:
529
+ return refused(REF_ISSUER_SELF_APPROVAL)
415
530
  # The receipt's (or plan's) own terms: distinct approvers, from the named set if any.
416
531
  terms = approval_terms(env) if env else _plan_terms(plan)
417
532
  have = list(app.state.grants.get(arid, []))
@@ -483,6 +598,15 @@ def build_issuer_app(
483
598
  raise _refuse(400, REF_ISSUER_BAD_REQUEST)
484
599
  if not isinstance(body, dict) or not valid_public_key_hex(body.get("public_key_hex")):
485
600
  raise _refuse(400, REF_ISSUER_BAD_REQUEST)
601
+ current = registry.public_hex().get(who["principal"])
602
+ if current is not None and current != body["public_key_hex"]:
603
+ # A bearer token alone must not swap the key holds are released
604
+ # with: a second browser's key enrolls only with the first key's
605
+ # signature over it (`supersede_signature`), or after the operator
606
+ # revoked the first (`gargoyle approvers revoke <upn>`).
607
+ if not _supersedes(current, body.get("supersede_signature"), body["public_key_hex"]):
608
+ event("enroll_refused", approver=who["principal"], reason=REF_ISSUER_KEY_ENROLLED, oid=who.get("oid"))
609
+ raise _refuse(409, REF_ISSUER_KEY_ENROLLED)
486
610
  try:
487
611
  result = registry.enroll(who["principal"], body["public_key_hex"], now=now(), oid=who.get("oid"), tid=who.get("tid"))
488
612
  except RevokedKeyError:
@@ -644,6 +768,7 @@ def app_from_env() -> FastAPI:
644
768
  on_hold=notifier_from_env(os.environ),
645
769
  hold_store=HoldStore(os.environ.get(ENV_HOLDS_PATH) or None),
646
770
  max_open_holds=int(os.environ.get(ENV_MAX_OPEN_HOLDS, DEFAULT_MAX_OPEN_HOLDS)),
771
+ allow_self_approval=os.environ.get(ENV_ALLOW_SELF_APPROVAL, "").strip().lower() in ("1", "true", "yes"),
647
772
  witness=(WitnessLog(os.environ["GARGOYLE_WITNESS_PATH"], Ed25519PrivateKey.from_private_bytes(bytes.fromhex(required[ENV_ISSUER_KEY_HEX])),
648
773
  required[ENV_ISSUER_KEY_ID]) if os.environ.get("GARGOYLE_WITNESS_PATH") else None),
649
774
  log_events=(lambda e: sys.stderr.write(json.dumps(e, sort_keys=True) + "\n")) if os.environ.get("GARGOYLE_LOG_JSON") else None,
@@ -652,6 +777,7 @@ def app_from_env() -> FastAPI:
652
777
  approver_groups=[g.strip() for g in os.environ.get(ENV_APPROVER_GROUPS, "").split(",") if g.strip()] or None,
653
778
  tenant=tenant_id,
654
779
  tenant_key_ids=set(tenant_keys) if tenant else None,
780
+ idp_key_refresh=(lambda: entra_keys(tenant_id)) if tenant else None,
655
781
  console_entra=({"tenant": tenant, "client_id": os.environ[ENV_CONSOLE_CLIENT_ID],
656
782
  "scope": os.environ.get(ENV_CONSOLE_SCOPE) or "api://%s/access" % (os.environ.get(ENV_AUDIENCE) or "").split(",")[-1].strip()}
657
783
  if tenant and os.environ.get(ENV_CONSOLE_CLIENT_ID) else None),