gargoyle-receipts 0.17.0.dev0__tar.gz → 0.19.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 (92) hide show
  1. {gargoyle_receipts-0.17.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.19.0.dev0}/PKG-INFO +7 -5
  2. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/README.md +6 -4
  3. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/cli.py +151 -26
  4. gargoyle_receipts-0.19.0.dev0/gargoyle/emilia.py +438 -0
  5. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/evidence.py +51 -12
  6. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/issuer_service.py +22 -1
  7. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/mcp_server.py +131 -9
  8. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/published_record_source.py +14 -0
  9. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/static/approve.html +13 -7
  10. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/static/approve.js +46 -0
  11. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +7 -5
  12. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +4 -0
  13. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/pyproject.toml +1 -1
  14. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_cli.py +2 -2
  15. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_console_js.py +32 -0
  16. gargoyle_receipts-0.19.0.dev0/tests/test_emilia_interop.py +338 -0
  17. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_issuer_service.py +2 -1
  18. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_mcp_self_issue.py +3 -3
  19. gargoyle_receipts-0.19.0.dev0/tests/test_operability.py +126 -0
  20. gargoyle_receipts-0.19.0.dev0/tests/test_record_follow.py +92 -0
  21. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_rules.py +1 -1
  22. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/LICENSE +0 -0
  23. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/NOTICE +0 -0
  24. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/__init__.py +0 -0
  25. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/__main__.py +0 -0
  26. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/adapters.py +0 -0
  27. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/approval.py +0 -0
  28. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/approver_cli.py +0 -0
  29. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/approver_trust.py +0 -0
  30. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/approvers.py +0 -0
  31. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/consumer.py +0 -0
  32. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/entra_login.py +0 -0
  33. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/envelope.py +0 -0
  34. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/execution.py +0 -0
  35. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/fs_server.py +0 -0
  36. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/globs.py +0 -0
  37. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/holdstore.py +0 -0
  38. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/identity.py +0 -0
  39. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/issuer.py +0 -0
  40. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/jws.py +0 -0
  41. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/key_record_source.py +0 -0
  42. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/notify.py +0 -0
  43. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/opa.py +0 -0
  44. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/pending_store.py +0 -0
  45. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/plans.py +0 -0
  46. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/policy.py +0 -0
  47. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/reconcile.py +0 -0
  48. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/remote.py +0 -0
  49. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/replay_cache.py +0 -0
  50. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/revert.py +0 -0
  51. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/rules.py +0 -0
  52. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  53. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/state.py +0 -0
  54. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/verifier.py +0 -0
  55. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle/witness.py +0 -0
  56. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  57. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  58. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  59. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  60. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/setup.cfg +0 -0
  61. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_annotations.py +0 -0
  62. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_approver_cli.py +0 -0
  63. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_approvers.py +0 -0
  64. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_canonical.py +0 -0
  65. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_consumer.py +0 -0
  66. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_entra_identity.py +0 -0
  67. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_entra_login.py +0 -0
  68. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_evidence.py +0 -0
  69. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_execution.py +0 -0
  70. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_fs_server.py +0 -0
  71. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_globs.py +0 -0
  72. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_graph.py +0 -0
  73. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_historical.py +0 -0
  74. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_identity.py +0 -0
  75. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_jws.py +0 -0
  76. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_mcp_proxy.py +0 -0
  77. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_mcp_server.py +0 -0
  78. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_notify.py +0 -0
  79. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_opa.py +0 -0
  80. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_plans.py +0 -0
  81. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_policy.py +0 -0
  82. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_published_record.py +0 -0
  83. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_remote.py +0 -0
  84. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_revert.py +0 -0
  85. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_review_fixes.py +0 -0
  86. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_state.py +0 -0
  87. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_state_text.py +0 -0
  88. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_stdio_encoding.py +0 -0
  89. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_threshold.py +0 -0
  90. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_typescript_conformance.py +0 -0
  91. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.0.dev0}/tests/test_wire_spec.py +0 -0
  92. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.19.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.17.0.dev0
3
+ Version: 0.19.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.17.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.19.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
 
@@ -288,6 +288,8 @@ hold and deny rules):
288
288
 
289
289
  gargoyle init /path/the/agent/may/touch # keys, config, rules.json
290
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
292
+ gargoyle emilia --log audit.jsonl # export approved calls as EMILIA Trust Receipts (docs/interop-emilia.md)
291
293
  gargoyle issuer # the issuing service, on rules.json
292
294
  gargoyle approve --watch 2 # where the approver sits (terminal)
293
295
 
@@ -397,7 +399,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
397
399
  | `GARGOYLE_MCP_GATE_KEY_ID`, `GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX` | the pinned issuer key |
398
400
  | `GARGOYLE_MCP_TARGET_ID` | the target identity receipts bind to |
399
401
  | `GARGOYLE_MCP_PRINCIPAL` | the client this server serves |
400
- | `GARGOYLE_MCP_RECORD_PATH` | the published policy record |
402
+ | `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 |
401
403
  | `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) |
402
404
  | `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) |
403
405
  | `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 |
@@ -459,7 +461,7 @@ on it.
459
461
  | `jws` | the receipt as a JWS any JWT library verifies |
460
462
  | `envelope` | receipt construction, hash region, signature region, currency check |
461
463
  | `verifier` | the pure target-side check and every refusal code |
462
- | `consumer` | `Consumer` (pin, keys, verify, claim, attest) and the reference HTTP target |
464
+ | `consumer` | `Consumer` (pin, keys, verify, claim, attest) |
463
465
  | `execution` | execution receipts and the offline chain check |
464
466
  | `evidence` | the executor's hash-chained log of every decision, and its offline verifier |
465
467
  | `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
@@ -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.17.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.19.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
 
@@ -250,6 +250,8 @@ hold and deny rules):
250
250
 
251
251
  gargoyle init /path/the/agent/may/touch # keys, config, rules.json
252
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
254
+ gargoyle emilia --log audit.jsonl # export approved calls as EMILIA Trust Receipts (docs/interop-emilia.md)
253
255
  gargoyle issuer # the issuing service, on rules.json
254
256
  gargoyle approve --watch 2 # where the approver sits (terminal)
255
257
 
@@ -359,7 +361,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
359
361
  | `GARGOYLE_MCP_GATE_KEY_ID`, `GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX` | the pinned issuer key |
360
362
  | `GARGOYLE_MCP_TARGET_ID` | the target identity receipts bind to |
361
363
  | `GARGOYLE_MCP_PRINCIPAL` | the client this server serves |
362
- | `GARGOYLE_MCP_RECORD_PATH` | the published policy record |
364
+ | `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 |
363
365
  | `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) |
364
366
  | `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) |
365
367
  | `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 |
@@ -421,7 +423,7 @@ on it.
421
423
  | `jws` | the receipt as a JWS any JWT library verifies |
422
424
  | `envelope` | receipt construction, hash region, signature region, currency check |
423
425
  | `verifier` | the pure target-side check and every refusal code |
424
- | `consumer` | `Consumer` (pin, keys, verify, claim, attest) and the reference HTTP target |
426
+ | `consumer` | `Consumer` (pin, keys, verify, claim, attest) |
425
427
  | `execution` | execution receipts and the offline chain check |
426
428
  | `evidence` | the executor's hash-chained log of every decision, and its offline verifier |
427
429
  | `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
@@ -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,
@@ -413,22 +490,58 @@ def _audit_summary(report, raw) -> None:
413
490
  print(" clients: " + ", ".join("%s %d" % (w, n) for w, n in clients.most_common()))
414
491
 
415
492
 
493
+ def _trust_hex(out: Path) -> "tuple[Dict[str, Any], Dict[str, Any]]":
494
+ """public-keys.json plus, when present, every key each enrolled approver
495
+ ever held (approvers.json, current first): grants signed under a key
496
+ since replaced in the browser or revoked still have to verify. Only the
497
+ live service and middleware are limited to the current key. Copy the
498
+ issuer's file beside the evidence first. Returns (keys, approvers_hex)."""
499
+ keys = json.loads((out / "public-keys.json").read_text())
500
+ approvers: Dict[str, Any] = dict(keys["approver"])
501
+ enrolled = out / "approvers.json" # approvers who enrolled through Entra, by UPN
502
+ if enrolled.exists():
503
+ from gargoyle.approvers import ApproverRegistry
504
+ approvers.update(ApproverRegistry(str(enrolled)).audit_keys_hex())
505
+ return keys, approvers
506
+
507
+
508
+ def cmd_emilia(args) -> int:
509
+ """Export an evidence log's honored, approved calls as EMILIA Trust
510
+ Receipts (docs/interop-emilia.md): one file in the format EMILIA's
511
+ conformance runners read, every receipt carrying the signoff the
512
+ approver's console made, the whole set under one Merkle checkpoint
513
+ signed by this gate's executor key."""
514
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
515
+ from gargoyle.emilia import trust_receipts_from_entries, verification_for
516
+ from gargoyle.evidence import read_log
517
+ out = Path(args.out).resolve()
518
+ keys, approvers = _trust_hex(out)
519
+ env = _read_env(Path(args.env or (out / "middleware.env")))
520
+ if "GARGOYLE_MCP_EXECUTOR_KEY_HEX" not in env:
521
+ print("%s has no GARGOYLE_MCP_EXECUTOR_KEY_HEX; point --env at this gate's middleware.env" % (args.env or out / "middleware.env"), file=sys.stderr)
522
+ return 2
523
+ executor = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(env["GARGOYLE_MCP_EXECUTOR_KEY_HEX"]))
524
+ log_key_id = env.get("GARGOYLE_MCP_EXECUTOR_KEY_ID", "executor-1")
525
+ result = trust_receipts_from_entries(read_log(args.log), log_signing_key=executor, log_key_id=log_key_id)
526
+ vectors = [{"id": r["receipt_id"], "expect": {"valid": True}, "trust_receipt": r,
527
+ "verification": verification_for(r, approvers, executor.public_key())} for r in result["receipts"]]
528
+ missing = [v["id"] for v in vectors if len(v["verification"]["approver_keys"]) < len(v["trust_receipt"]["signoffs"])]
529
+ doc = {"suite": "GARGOYLE EP-TRUST-RECEIPT-v1 export", "source": "gargoyle-receipts", "log": str(Path(args.log).resolve()),
530
+ "exported_at": datetime.now(timezone.utc).isoformat(), "count": len(vectors), "vectors": vectors}
531
+ _write(Path(args.write), json.dumps(doc, indent=1) + "\n")
532
+ print("%d trust receipts under one checkpoint (tree size %d) -> %s; %d approved calls skipped (no console signoff on every grant)%s"
533
+ % (len(vectors), len(vectors), args.write, result["skipped"],
534
+ ("; %d receipts name an approver key not in %s/public-keys.json or approvers.json" % (len(missing), out)) if missing else ""))
535
+ return 0
536
+
537
+
416
538
  def cmd_audit(args) -> int:
417
539
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
418
540
  from gargoyle.execution import verify_chain
419
541
 
420
- keys = json.loads((Path(args.out).resolve() / "public-keys.json").read_text())
542
+ keys, approvers = _trust_hex(Path(args.out).resolve())
421
543
  one = lambda h: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h))
422
544
  load = lambda m: {k: ([one(x) for x in v] if isinstance(v, list) else one(v)) for k, v in m.items()}
423
- approvers: Dict[str, Any] = dict(keys["approver"])
424
- enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
425
- if enrolled.exists():
426
- from gargoyle.approvers import ApproverRegistry
427
- # Every key each approver ever held, current first: grants signed
428
- # under a key since replaced in the browser or revoked still have to
429
- # verify. Only the live service and middleware are limited to the
430
- # current key. Copy the issuer's file beside the evidence first.
431
- approvers.update(ApproverRegistry(str(enrolled)).audit_keys_hex())
432
545
  trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
433
546
  approver_public_keys=load(approvers))
434
547
  if args.log:
@@ -523,7 +636,13 @@ def main(argv=None) -> int:
523
636
  help="the filesystem MCP server behind the gate: the official one via npx, or gargoyle.fs_server")
524
637
  p.add_argument("--entra-tenant", help="trust this Entra ID tenant's tokens at the issuer (users then run `gargoyle login`)")
525
638
  p.add_argument("--entra-audience", help="the issuer API app's client id or app id URI, required in Entra tokens")
639
+ p.add_argument("--force", action="store_true",
640
+ help="regenerate keys and tokens in an existing --out (every client and approver must be re-provisioned)")
526
641
  p.set_defaults(fn=cmd_init)
642
+ p = sub.add_parser("tokens", help="mint new client and approver tokens from a deployment's existing keys")
643
+ p.add_argument("--out", default="gargoyle-deploy")
644
+ p.add_argument("--token-days", type=int, default=30)
645
+ p.set_defaults(fn=cmd_tokens)
527
646
  p = sub.add_parser("login", help="sign this workstation's user in with Entra ID (device code)")
528
647
  p.add_argument("--tenant", required=True, help="tenant id or domain")
529
648
  p.add_argument("--client-id", required=True, help="the public client app registration")
@@ -572,6 +691,12 @@ def main(argv=None) -> int:
572
691
  p.add_argument("--summary", action="store_true", help="after the rows: calls by principal and tool with outcomes, releases, clients")
573
692
  p.add_argument("--out", default="gargoyle-deploy")
574
693
  p.set_defaults(fn=cmd_audit)
694
+ p = sub.add_parser("emilia", help="export an evidence log's approved calls as EMILIA Trust Receipts")
695
+ p.add_argument("--log", required=True, help="the middleware's audit.jsonl")
696
+ p.add_argument("--write", default="emilia-trust-receipts.json", help="output file, in the format EMILIA's conformance runners read")
697
+ p.add_argument("--env", help="this gate's middleware.env (executor key signs the checkpoint); default <out>/middleware.env")
698
+ p.add_argument("--out", default="gargoyle-deploy")
699
+ p.set_defaults(fn=cmd_emilia)
575
700
  args = parser.parse_args(argv)
576
701
  return args.fn(args)
577
702