gargoyle-receipts 0.16.4.dev0__tar.gz → 0.18.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 (90) hide show
  1. {gargoyle_receipts-0.16.4.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.18.0.dev0}/PKG-INFO +17 -9
  2. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/README.md +16 -8
  3. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/adapters.py +4 -3
  4. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/cli.py +106 -19
  5. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/envelope.py +7 -3
  6. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/evidence.py +76 -12
  7. gargoyle_receipts-0.18.0.dev0/gargoyle/globs.py +58 -0
  8. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/issuer_service.py +165 -26
  9. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/mcp_server.py +153 -14
  10. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/plans.py +22 -2
  11. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/policy.py +3 -1
  12. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/published_record_source.py +14 -0
  13. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/reconcile.py +8 -0
  14. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/replay_cache.py +42 -0
  15. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/rules.py +156 -1
  16. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/state.py +17 -0
  17. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/static/approve.html +41 -7
  18. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +17 -9
  19. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +6 -0
  20. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/pyproject.toml +1 -1
  21. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_approvers.py +52 -3
  22. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_cli.py +2 -2
  23. gargoyle_receipts-0.18.0.dev0/tests/test_globs.py +46 -0
  24. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_issuer_service.py +2 -1
  25. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_mcp_self_issue.py +3 -3
  26. gargoyle_receipts-0.18.0.dev0/tests/test_operability.py +126 -0
  27. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_plans.py +88 -1
  28. gargoyle_receipts-0.18.0.dev0/tests/test_record_follow.py +92 -0
  29. gargoyle_receipts-0.18.0.dev0/tests/test_review_fixes.py +236 -0
  30. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_rules.py +1 -1
  31. gargoyle_receipts-0.18.0.dev0/tests/test_state_text.py +48 -0
  32. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_threshold.py +2 -2
  33. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/LICENSE +0 -0
  34. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/NOTICE +0 -0
  35. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/__init__.py +0 -0
  36. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/__main__.py +0 -0
  37. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approval.py +0 -0
  38. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approver_cli.py +0 -0
  39. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approver_trust.py +0 -0
  40. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approvers.py +0 -0
  41. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/consumer.py +0 -0
  42. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/entra_login.py +0 -0
  43. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/execution.py +0 -0
  44. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/fs_server.py +0 -0
  45. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/holdstore.py +0 -0
  46. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/identity.py +0 -0
  47. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/issuer.py +0 -0
  48. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/jws.py +0 -0
  49. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/key_record_source.py +0 -0
  50. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/notify.py +0 -0
  51. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/opa.py +0 -0
  52. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/pending_store.py +0 -0
  53. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/remote.py +0 -0
  54. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/revert.py +0 -0
  55. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  56. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/static/approve.js +0 -0
  57. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/verifier.py +0 -0
  58. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/witness.py +0 -0
  59. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  60. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  61. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  62. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  63. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/setup.cfg +0 -0
  64. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_annotations.py +0 -0
  65. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_approver_cli.py +0 -0
  66. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_canonical.py +0 -0
  67. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_console_js.py +0 -0
  68. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_consumer.py +0 -0
  69. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_entra_identity.py +0 -0
  70. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_entra_login.py +0 -0
  71. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_evidence.py +0 -0
  72. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_execution.py +0 -0
  73. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_fs_server.py +0 -0
  74. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_graph.py +0 -0
  75. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_historical.py +0 -0
  76. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_identity.py +0 -0
  77. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_jws.py +0 -0
  78. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_mcp_proxy.py +0 -0
  79. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_mcp_server.py +0 -0
  80. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_notify.py +0 -0
  81. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_opa.py +0 -0
  82. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_policy.py +0 -0
  83. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_published_record.py +0 -0
  84. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_remote.py +0 -0
  85. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_revert.py +0 -0
  86. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_state.py +0 -0
  87. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_stdio_encoding.py +0 -0
  88. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_typescript_conformance.py +0 -0
  89. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_wire_spec.py +0 -0
  90. {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.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.18.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.18.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
@@ -103,7 +103,7 @@ Python 3.12 or later. A TypeScript verifier and consumer for Node 20+
103
103
  lives in [ts/](ts/) (`cd ts && npm install && npm test`); its suite
104
104
  recomputes the same conformance vectors byte for byte, so the two
105
105
  implementations cannot drift apart silently. Redis is optional (`.[redis]`) and only needed to
106
- share the replay cache and pending-approval set across executor replicas.
106
+ share the replay cache and pending-approval set across replicas of the remote gate; a single gate keeps its replay claims in SQLite beside its evidence log.
107
107
 
108
108
  ## Issuing a receipt
109
109
 
@@ -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
 
@@ -286,6 +288,7 @@ hold and deny rules):
286
288
 
287
289
  gargoyle init /path/the/agent/may/touch # keys, config, rules.json
288
290
  gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
291
+ gargoyle tokens --out gargoyle-deploy # later: new client and approver tokens, same keys
289
292
  gargoyle issuer # the issuing service, on rules.json
290
293
  gargoyle approve --watch 2 # where the approver sits (terminal)
291
294
 
@@ -303,7 +306,11 @@ stay on file so earlier grants still audit; new ones are refused at the
303
306
  issuer and at every middleware on its next held call. Revocation retires
304
307
  a key, not a person: while the Approver role stands, the person can sign
305
308
  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
309
+ is never accepted again. The role is what counts at release time: a
310
+ grant under an enrolled key is accepted only from that account, under a
311
+ token that carries the role now, so a role removed in the directory ends
312
+ the power at once. One key per account: a second browser does not enroll
313
+ until the first key consents or the operator revokes it. Every key an approver held stays in the file
307
314
  (`history`), so an audit of grants released under an earlier key still
308
315
  verifies: copy the issuer's current `approvers.json` beside the evidence
309
316
  before auditing. Approvers, and
@@ -391,7 +398,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
391
398
  | `GARGOYLE_MCP_GATE_KEY_ID`, `GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX` | the pinned issuer key |
392
399
  | `GARGOYLE_MCP_TARGET_ID` | the target identity receipts bind to |
393
400
  | `GARGOYLE_MCP_PRINCIPAL` | the client this server serves |
394
- | `GARGOYLE_MCP_RECORD_PATH` | the published policy record |
401
+ | `GARGOYLE_MCP_RECORD_PATH`, `GARGOYLE_MCP_RECORD_URL` | the live policy pin: a file copied out of band, or (what `init` sets) the issuer's signed record at `<issuer>/record`, followed live and cached a minute, so a rule change reaches every gate without a copy or a restart |
395
402
  | `GARGOYLE_MCP_APPROVER_KEY_ID`, `GARGOYLE_MCP_APPROVER_PUBLIC_KEY_HEX` | the approver keys whose grants release flagged calls (optional, both or neither; comma-separated lists for several) |
396
403
  | `GARGOYLE_MCP_EXECUTOR_KEY_ID`, `GARGOYLE_MCP_EXECUTOR_KEY_HEX` | this server's own signing key, so honored calls carry an execution receipt in `_meta["gargoyle/execution"]` (optional, both or neither) |
397
404
  | `GARGOYLE_MCP_ISSUER_URL`, `GARGOYLE_MCP_CLIENT_TOKEN` | self-issue mode: the issuing service and the identity token (whose subject is this server's principal) to obtain receipts with; `GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS` and `GARGOYLE_MCP_POLL_SECONDS` tune the wait for a grant |
@@ -402,6 +409,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
402
409
  | 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
410
  | 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
411
  | 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 |
412
+ | 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
413
  | 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
414
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
407
415
  | `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 +418,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
410
418
  | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
411
419
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
412
420
  | `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 |
421
+ | `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
422
 
415
423
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
416
424
  `app_from_env()`): identity token in, receipt out, with the hold and grant
@@ -452,13 +460,13 @@ on it.
452
460
  | `jws` | the receipt as a JWS any JWT library verifies |
453
461
  | `envelope` | receipt construction, hash region, signature region, currency check |
454
462
  | `verifier` | the pure target-side check and every refusal code |
455
- | `consumer` | `Consumer` (pin, keys, verify, claim, attest) and the reference HTTP target |
463
+ | `consumer` | `Consumer` (pin, keys, verify, claim, attest) |
456
464
  | `execution` | execution receipts and the offline chain check |
457
465
  | `evidence` | the executor's hash-chained log of every decision, and its offline verifier |
458
466
  | `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
459
467
  | `fs_server` | a small Python filesystem MCP server, for clients without Node |
460
468
  | `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 |
469
+ | `replay_cache`, `pending_store` | single use for receipts and holds; in memory, SQLite beside the audit log (the middleware's default) or Redis |
462
470
  | `approval`, `approver_cli`, `approver_trust` | grants, the human's tool, approver key trust |
463
471
  | `published_record_source` | the live policy pin as a publisher-signed record with serial and expiry |
464
472
  | `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.18.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
@@ -65,7 +65,7 @@ Python 3.12 or later. A TypeScript verifier and consumer for Node 20+
65
65
  lives in [ts/](ts/) (`cd ts && npm install && npm test`); its suite
66
66
  recomputes the same conformance vectors byte for byte, so the two
67
67
  implementations cannot drift apart silently. Redis is optional (`.[redis]`) and only needed to
68
- share the replay cache and pending-approval set across executor replicas.
68
+ share the replay cache and pending-approval set across replicas of the remote gate; a single gate keeps its replay claims in SQLite beside its evidence log.
69
69
 
70
70
  ## Issuing a receipt
71
71
 
@@ -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
 
@@ -248,6 +250,7 @@ hold and deny rules):
248
250
 
249
251
  gargoyle init /path/the/agent/may/touch # keys, config, rules.json
250
252
  gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
253
+ gargoyle tokens --out gargoyle-deploy # later: new client and approver tokens, same keys
251
254
  gargoyle issuer # the issuing service, on rules.json
252
255
  gargoyle approve --watch 2 # where the approver sits (terminal)
253
256
 
@@ -265,7 +268,11 @@ stay on file so earlier grants still audit; new ones are refused at the
265
268
  issuer and at every middleware on its next held call. Revocation retires
266
269
  a key, not a person: while the Approver role stands, the person can sign
267
270
  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
271
+ is never accepted again. The role is what counts at release time: a
272
+ grant under an enrolled key is accepted only from that account, under a
273
+ token that carries the role now, so a role removed in the directory ends
274
+ the power at once. One key per account: a second browser does not enroll
275
+ until the first key consents or the operator revokes it. Every key an approver held stays in the file
269
276
  (`history`), so an audit of grants released under an earlier key still
270
277
  verifies: copy the issuer's current `approvers.json` beside the evidence
271
278
  before auditing. Approvers, and
@@ -353,7 +360,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
353
360
  | `GARGOYLE_MCP_GATE_KEY_ID`, `GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX` | the pinned issuer key |
354
361
  | `GARGOYLE_MCP_TARGET_ID` | the target identity receipts bind to |
355
362
  | `GARGOYLE_MCP_PRINCIPAL` | the client this server serves |
356
- | `GARGOYLE_MCP_RECORD_PATH` | the published policy record |
363
+ | `GARGOYLE_MCP_RECORD_PATH`, `GARGOYLE_MCP_RECORD_URL` | the live policy pin: a file copied out of band, or (what `init` sets) the issuer's signed record at `<issuer>/record`, followed live and cached a minute, so a rule change reaches every gate without a copy or a restart |
357
364
  | `GARGOYLE_MCP_APPROVER_KEY_ID`, `GARGOYLE_MCP_APPROVER_PUBLIC_KEY_HEX` | the approver keys whose grants release flagged calls (optional, both or neither; comma-separated lists for several) |
358
365
  | `GARGOYLE_MCP_EXECUTOR_KEY_ID`, `GARGOYLE_MCP_EXECUTOR_KEY_HEX` | this server's own signing key, so honored calls carry an execution receipt in `_meta["gargoyle/execution"]` (optional, both or neither) |
359
366
  | `GARGOYLE_MCP_ISSUER_URL`, `GARGOYLE_MCP_CLIENT_TOKEN` | self-issue mode: the issuing service and the identity token (whose subject is this server's principal) to obtain receipts with; `GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS` and `GARGOYLE_MCP_POLL_SECONDS` tune the wait for a grant |
@@ -364,6 +371,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
364
371
  | 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
372
  | 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
373
  | 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 |
374
+ | 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
375
  | 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
376
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
369
377
  | `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 +380,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
372
380
  | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
373
381
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
374
382
  | `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 |
383
+ | `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
384
 
377
385
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
378
386
  `app_from_env()`): identity token in, receipt out, with the hold and grant
@@ -414,13 +422,13 @@ on it.
414
422
  | `jws` | the receipt as a JWS any JWT library verifies |
415
423
  | `envelope` | receipt construction, hash region, signature region, currency check |
416
424
  | `verifier` | the pure target-side check and every refusal code |
417
- | `consumer` | `Consumer` (pin, keys, verify, claim, attest) and the reference HTTP target |
425
+ | `consumer` | `Consumer` (pin, keys, verify, claim, attest) |
418
426
  | `execution` | execution receipts and the offline chain check |
419
427
  | `evidence` | the executor's hash-chained log of every decision, and its offline verifier |
420
428
  | `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
421
429
  | `fs_server` | a small Python filesystem MCP server, for clients without Node |
422
430
  | `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 |
431
+ | `replay_cache`, `pending_store` | single use for receipts and holds; in memory, SQLite beside the audit log (the middleware's default) or Redis |
424
432
  | `approval`, `approver_cli`, `approver_trust` | grants, the human's tool, approver key trust |
425
433
  | `published_record_source` | the live policy pin as a publisher-signed record with serial and expiry |
426
434
  | `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
@@ -55,8 +55,8 @@ OBSERVE_RULES = {
55
55
  # Observe mode: every call allowed (deletes too), every call recorded, none held. The
56
56
  # first week of a deployment, so the hold and deny rules are written
57
57
  # from the audit rather than guessed. The policy pin changes when the
58
- # rules do, so switching modes means restarting the issuer and copying
59
- # the new record.json to each client.
58
+ # rules do; the issuer applies the edit at once and clients that follow
59
+ # its signed record pick the new pin up within a minute.
60
60
  "version": "1",
61
61
  "default": "allow",
62
62
  "rules": [],
@@ -93,6 +93,27 @@ def _write(path: Path, text: str, private: bool = False) -> None:
93
93
  print("wrote", path)
94
94
 
95
95
 
96
+ def _observe_rules(rules: Path) -> int:
97
+ """Switch a deployment's rules file to observe mode (allow and record
98
+ everything), leaving a hand-edited file alone."""
99
+ current = None
100
+ if rules.exists():
101
+ try:
102
+ current = json.loads(rules.read_text())
103
+ except ValueError as exc:
104
+ print("%s is not valid JSON (%s); fix or remove it before --observe" % (rules, exc), file=sys.stderr)
105
+ return 2
106
+ if current is None or current == DEFAULT_RULES:
107
+ _write(rules, json.dumps(OBSERVE_RULES, indent=2) + "\n")
108
+ elif current != OBSERVE_RULES:
109
+ print("rules.json was edited by hand and is left as it is; --observe changed nothing", file=sys.stderr)
110
+ print("observe mode: rules.json allows every call, destructive ones included; the gate records all of "
111
+ "them and holds none. Tighten rules.json from `gargoyle audit --summary`; the issuer applies the "
112
+ "change at once and clients that follow its record (GARGOYLE_MCP_RECORD_URL) pick the new pin up "
113
+ "within a minute. A client on a static record.json needs the new file.")
114
+ return 0
115
+
116
+
96
117
  def cmd_init(args) -> int:
97
118
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
98
119
  from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
@@ -100,12 +121,26 @@ def cmd_init(args) -> int:
100
121
 
101
122
  out = Path(args.out).resolve()
102
123
  out.mkdir(parents=True, exist_ok=True)
124
+ if (out / "issuer.env").exists() and not args.force:
125
+ # A deployment lives here. Its keys and tokens are what every client
126
+ # and approver holds; regenerating them by accident strands them all.
127
+ if args.observe:
128
+ return _observe_rules(out / "rules.json")
129
+ print("%s already holds a deployment; its keys and tokens are kept. New tokens: `gargoyle tokens --out %s`. "
130
+ "Fresh keys: `gargoyle init --force` (every client and approver must then be re-provisioned), or a new --out."
131
+ % (out, out), file=sys.stderr)
132
+ return 2
103
133
  # A Windows path given on a Linux issuer host is the client's and is kept as typed.
104
134
  windows = re.match(r"^[A-Za-z]:[\\/]", args.directory) is not None
105
135
  allowed = args.directory if windows else str(Path(args.directory).resolve())
106
136
  sep = "\\" if windows else "/"
107
137
  client_dir = args.client_config_dir or str(out)
108
138
  client_python = args.client_python or sys.executable
139
+ from gargoyle.globs import normalize_path
140
+ if normalize_path(client_dir) == normalize_path(allowed) or normalize_path(client_dir).startswith(normalize_path(allowed).rstrip("/") + "/"):
141
+ print("note: the client config folder %s is inside the folder the gate serves (%s). The gate refuses calls into its "
142
+ "own files (REF_TARGET_GATE_FILES), but secrets are better kept out of the served tree: --client-config-dir."
143
+ % (client_dir, allowed), file=sys.stderr)
109
144
 
110
145
  def new_key():
111
146
  k = Ed25519PrivateKey.generate()
@@ -130,20 +165,9 @@ def cmd_init(args) -> int:
130
165
 
131
166
  rules = out / "rules.json"
132
167
  if args.observe:
133
- current = None
134
- if rules.exists():
135
- try:
136
- current = json.loads(rules.read_text())
137
- except ValueError as exc:
138
- print("%s is not valid JSON (%s); fix or remove it before --observe" % (rules, exc), file=sys.stderr)
139
- return 2
140
- if current is None or current == DEFAULT_RULES:
141
- _write(rules, json.dumps(OBSERVE_RULES, indent=2) + "\n")
142
- elif current != OBSERVE_RULES:
143
- print("rules.json was edited by hand and is left as it is; --observe changed nothing", file=sys.stderr)
144
- print("observe mode: rules.json allows every call, destructive ones included; the gate records all of "
145
- "them and holds none. Tighten rules.json from `gargoyle audit --summary`, then restart the issuer "
146
- "and copy the new record.json to the clients.")
168
+ rc = _observe_rules(rules)
169
+ if rc:
170
+ return rc
147
171
  elif not rules.exists():
148
172
  _write(rules, json.dumps(DEFAULT_RULES, indent=2) + "\n")
149
173
  _write(out / "issuer.env", _env_lines({
@@ -159,6 +183,7 @@ def cmd_init(args) -> int:
159
183
  "GARGOYLE_MCP_GATE_KEY_ID": "issuer-1", "GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX": issuer_pub,
160
184
  "GARGOYLE_MCP_TARGET_ID": "mcp://filesystem/" + allowed, "GARGOYLE_MCP_PRINCIPAL": PRINCIPAL,
161
185
  "GARGOYLE_MCP_RECORD_PATH": client_dir + sep + "record.json",
186
+ "GARGOYLE_MCP_RECORD_URL": issuer_url + "/record", # follow the issuer's signed pin; the file is the offline fallback
162
187
  "GARGOYLE_MCP_APPROVER_KEY_ID": approver_ids, "GARGOYLE_MCP_APPROVER_PUBLIC_KEY_HEX": approver_pubs,
163
188
  "GARGOYLE_MCP_EXECUTOR_KEY_ID": "executor-1", "GARGOYLE_MCP_EXECUTOR_KEY_HEX": executor_hex,
164
189
  "GARGOYLE_MCP_ISSUER_URL": issuer_url, "GARGOYLE_MCP_CLIENT_TOKEN": client_token,
@@ -226,6 +251,58 @@ WantedBy=multi-user.target
226
251
  return 0
227
252
 
228
253
 
254
+ def cmd_tokens(args) -> int:
255
+ """New identity tokens from the deployment's existing identity key: the
256
+ client's shared token (unless the client signs in with Entra, whose
257
+ token renews itself) and every file-key approver's. Nothing else
258
+ changes, so clients keep their keys and the audit its trust."""
259
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
260
+ from gargoyle.jws import encode
261
+ out = Path(args.out).resolve()
262
+ issuer_env = out / "issuer.env"
263
+ if not issuer_env.exists():
264
+ print("%s holds no deployment (no issuer.env)" % out, file=sys.stderr)
265
+ return 2
266
+ env = _read_env(issuer_env)
267
+ idp_hex = env.get("GARGOYLE_IDP_KEY_HEX")
268
+ if not idp_hex:
269
+ print("issuer.env keeps no identity key (a deployment older than 0.9); make a fresh one with `gargoyle init` to a new --out", file=sys.stderr)
270
+ return 2
271
+ idp = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(idp_hex))
272
+ kid = env.get("GARGOYLE_IDP_KEY_ID", "idp-1")
273
+ exp = int((datetime.now(timezone.utc) + timedelta(days=args.token_days)).timestamp())
274
+ mw = out / "middleware.env"
275
+ if mw.exists():
276
+ menv = _read_env(mw)
277
+ if "GARGOYLE_MCP_CLIENT_TOKEN" in menv:
278
+ menv["GARGOYLE_MCP_CLIENT_TOKEN"] = encode({"sub": menv.get("GARGOYLE_MCP_PRINCIPAL", PRINCIPAL), "aud": "gargoyle-issuer", "exp": exp}, idp, kid)
279
+ _write(mw, _env_lines(menv), private=True)
280
+ mcp = out / "mcp-config.json"
281
+ if mcp.exists():
282
+ doc = json.loads(mcp.read_text())
283
+ for server in doc.get("mcpServers", {}).values():
284
+ if "GARGOYLE_MCP_CLIENT_TOKEN" in server.get("env", {}):
285
+ server["env"]["GARGOYLE_MCP_CLIENT_TOKEN"] = menv["GARGOYLE_MCP_CLIENT_TOKEN"]
286
+ _write(mcp, json.dumps(doc, indent=2) + "\n", private=True)
287
+ else:
288
+ print("middleware.env signs in with Entra; that token renews itself and was left alone")
289
+ for env_path in sorted(out.glob("approver*.env")):
290
+ aenv = _read_env(env_path)
291
+ akid = aenv.get("GARGOYLE_APPROVER_KEY_ID")
292
+ if not akid:
293
+ continue
294
+ tok = encode({"sub": akid, "roles": [DEFAULT_APPROVER_ROLE], "aud": "gargoyle-issuer", "exp": exp}, idp, kid)
295
+ aenv["GARGOYLE_APPROVER_TOKEN"] = tok
296
+ _write(env_path, _env_lines(aenv), private=True)
297
+ web = out / env_path.name.replace("approver", "approver-web", 1).replace(".env", ".json")
298
+ if web.exists():
299
+ doc = json.loads(web.read_text())
300
+ doc["token"] = tok
301
+ _write(web, json.dumps(doc, indent=2) + "\n", private=True)
302
+ print("new tokens, valid %d days; copy middleware.env (and approver-web*.json) to the machines that use them and restart their MCP clients" % args.token_days)
303
+ return 0
304
+
305
+
229
306
  def cmd_login(args) -> int:
230
307
  """Device-code sign-in; then middleware.env (and mcp-config.json when
231
308
  present) switch from the shared init token to this user's Entra token,
@@ -395,15 +472,19 @@ def _audit_summary(report, raw) -> None:
395
472
  releases: Counter = Counter()
396
473
  clients: Counter = Counter()
397
474
  ats = [e.get("at") for e in raw if isinstance(e, dict) and isinstance(e.get("at"), str)]
475
+ from gargoyle.evidence import FAILED_VERDICTS
398
476
  for r in report:
399
- counts.setdefault((r["principal"] or "?", r["tool"] or "?"), Counter())[r["kind"] or "?"] += 1
477
+ kind = r["kind"] or "?"
478
+ if kind == "honored" and r.get("verdict") in FAILED_VERDICTS:
479
+ kind = "failed" # admitted, but the upstream did not do it
480
+ counts.setdefault((r["principal"] or "?", r["tool"] or "?"), Counter())[kind] += 1
400
481
  for who in r.get("released_by") or []:
401
482
  releases[who] += 1
402
483
  clients[r.get("client") or "(client unnamed)"] += 1
403
484
  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"))
485
+ print(" %-28s %-24s %7s %7s %7s" % ("principal", "tool", "honored", "failed", "refused"))
405
486
  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)))
487
+ print(" %-28s %-24s %7d %7d %7d" % (principal[:28], tool[:24], c.get("honored", 0), c.get("failed", 0), c.get("refused", 0)))
407
488
  if releases:
408
489
  print(" releases: " + ", ".join("%s %d" % (w, n) for w, n in releases.most_common()))
409
490
  print(" clients: " + ", ".join("%s %d" % (w, n) for w, n in clients.most_common()))
@@ -519,7 +600,13 @@ def main(argv=None) -> int:
519
600
  help="the filesystem MCP server behind the gate: the official one via npx, or gargoyle.fs_server")
520
601
  p.add_argument("--entra-tenant", help="trust this Entra ID tenant's tokens at the issuer (users then run `gargoyle login`)")
521
602
  p.add_argument("--entra-audience", help="the issuer API app's client id or app id URI, required in Entra tokens")
603
+ p.add_argument("--force", action="store_true",
604
+ help="regenerate keys and tokens in an existing --out (every client and approver must be re-provisioned)")
522
605
  p.set_defaults(fn=cmd_init)
606
+ p = sub.add_parser("tokens", help="mint new client and approver tokens from a deployment's existing keys")
607
+ p.add_argument("--out", default="gargoyle-deploy")
608
+ p.add_argument("--token-days", type=int, default=30)
609
+ p.set_defaults(fn=cmd_tokens)
523
610
  p = sub.add_parser("login", help="sign this workstation's user in with Entra ID (device code)")
524
611
  p.add_argument("--tenant", required=True, help="tenant id or domain")
525
612
  p.add_argument("--client-id", required=True, help="the public client app registration")
@@ -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")
@@ -118,38 +129,77 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
118
129
 
119
130
  class EvidenceLog:
120
131
  """Appends entries to a JSON Lines file, continuing the chain found at
121
- its tail. The file is read once, when the first entry is appended; a
122
- file nobody else writes to is the operating assumption (one middleware
123
- process, one log), as for any append-only log."""
132
+ its tail. Several gate processes may share one log (a Claude Code and
133
+ a Claude Desktop session on the same folder): each append takes an
134
+ exclusive lock on `<log>.lock`, re-reads the tail when the file grew
135
+ since this process last wrote, then writes, so the chain never forks.
136
+ The lock is advisory and per host, as for any append-only log on a
137
+ local disk."""
124
138
 
125
139
  def __init__(self, path: str) -> None:
126
140
  self.path = Path(path)
127
141
  self._seq: Optional[int] = None
128
142
  self._tail: str = GENESIS
143
+ self._size: int = -1 # the file size after our last write; another writer changes it
129
144
 
130
145
  def _load_tail(self) -> None:
131
146
  self._seq, self._tail = 0, GENESIS
132
147
  if not self.path.exists():
148
+ self._size = 0
133
149
  return
134
- for line in self.path.read_text(encoding="utf-8").splitlines():
150
+ text = self.path.read_text(encoding="utf-8")
151
+ self._size = self.path.stat().st_size
152
+ for line in text.splitlines():
135
153
  if line.strip():
136
154
  last = json.loads(line)
137
155
  if last.get("evidence_version") in EVIDENCE_VERSIONS:
138
156
  self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
139
157
 
158
+ def _locked(self):
159
+ """An exclusive, blocking lock on the log's lock file (POSIX flock,
160
+ Windows byte-range lock), released on exit."""
161
+ import contextlib
162
+
163
+ @contextlib.contextmanager
164
+ def hold():
165
+ lock_path = self.path.with_name(self.path.name + ".lock")
166
+ with open(lock_path, "a+b") as lock:
167
+ try:
168
+ import fcntl
169
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
170
+ unlock = lambda: fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
171
+ except ImportError: # Windows
172
+ import msvcrt
173
+ lock.seek(0)
174
+ while True:
175
+ try:
176
+ msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) # retries about ten seconds, then raises
177
+ break
178
+ except OSError:
179
+ continue
180
+ unlock = lambda: (lock.seek(0), msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1))
181
+ try:
182
+ yield
183
+ finally:
184
+ unlock()
185
+ return hold()
186
+
140
187
  def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
141
188
  receipt: Any = None, grant: Any = None, execution: Any = None,
142
189
  client: Optional[str] = None, ref: Optional[str] = None) -> Dict[str, Any]:
143
190
  """Write one entry and return it. Raises OSError if the file cannot
144
191
  be written; the caller decides whether that stops the call."""
145
- if self._seq is None:
146
- self._load_tail()
147
- entry = build_entry(seq=self._seq + 1, prev_sha256=self._tail, kind=kind, tool=tool, args=args,
148
- principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution,
149
- client=client, ref=ref)
150
- with open(self.path, "a", encoding="utf-8") as f:
151
- f.write(json.dumps(entry, sort_keys=True) + "\n")
152
- self._seq, self._tail = entry["seq"], entry["entry_sha256"]
192
+ with self._locked():
193
+ current = self.path.stat().st_size if self.path.exists() else 0
194
+ if self._seq is None or current != self._size:
195
+ self._load_tail() # first write, or another process wrote since: continue its chain
196
+ entry = build_entry(seq=self._seq + 1, prev_sha256=self._tail, kind=kind, tool=tool, args=args,
197
+ principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution,
198
+ client=client, ref=ref)
199
+ with open(self.path, "a", encoding="utf-8") as f:
200
+ f.write(json.dumps(entry, sort_keys=True) + "\n")
201
+ self._seq, self._tail = entry["seq"], entry["entry_sha256"]
202
+ self._size = self.path.stat().st_size
153
203
  return entry
154
204
 
155
205
 
@@ -191,6 +241,7 @@ def verify_log(
191
241
  expect_seq, prev, tail = 1, GENESIS, GENESIS
192
242
  counts = {HONORED: 0, REFUSED: 0}
193
243
  all_ok = True
244
+ claimed: Dict[str, int] = {} # decision_id / plan step -> seq of the honored entry that spent it
194
245
 
195
246
  for raw in entries:
196
247
  if not _well_formed(raw):
@@ -220,6 +271,19 @@ def verify_log(
220
271
  if ok and (rc.get("tool") != e["tool"] or rc.get("args_sha256") != e["args_sha256"]
221
272
  or rc.get("principal") != e["principal"]):
222
273
  ok, reason = False, REF_EVIDENCE_RECEIPT_MISSING
274
+ if ok:
275
+ # Single use, checked after the fact: a decision id, or a
276
+ # plan step, honored twice in one log is a replay the
277
+ # executor's cache missed (a second process, a lost cache).
278
+ keys = [str(e["receipt"].get("decision_id"))]
279
+ gs = e["grant"]
280
+ if isinstance(gs, dict) and "plan" in gs and "step" in gs:
281
+ keys.append("plan:%s:%s" % ((gs["plan"] or {}).get("plan_id"), gs["step"]))
282
+ if any(k in claimed for k in keys):
283
+ ok, reason = False, REF_EVIDENCE_REPLAY
284
+ else:
285
+ for k in keys:
286
+ claimed[k] = e["seq"]
223
287
  else:
224
288
  reason = ACCEPT_ENTRY_VERIFIED
225
289
  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)