gargoyle-receipts 0.17.0.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.17.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.18.0.dev0}/PKG-INFO +6 -5
  2. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/README.md +5 -4
  3. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/cli.py +99 -16
  4. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/evidence.py +51 -12
  5. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/issuer_service.py +14 -1
  6. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/mcp_server.py +131 -9
  7. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/published_record_source.py +14 -0
  8. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +6 -5
  9. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
  10. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/pyproject.toml +1 -1
  11. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_cli.py +2 -2
  12. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_issuer_service.py +2 -1
  13. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_mcp_self_issue.py +3 -3
  14. gargoyle_receipts-0.18.0.dev0/tests/test_operability.py +126 -0
  15. gargoyle_receipts-0.18.0.dev0/tests/test_record_follow.py +92 -0
  16. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_rules.py +1 -1
  17. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/LICENSE +0 -0
  18. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/NOTICE +0 -0
  19. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/__init__.py +0 -0
  20. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/__main__.py +0 -0
  21. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/adapters.py +0 -0
  22. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approval.py +0 -0
  23. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approver_cli.py +0 -0
  24. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approver_trust.py +0 -0
  25. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approvers.py +0 -0
  26. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/consumer.py +0 -0
  27. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/entra_login.py +0 -0
  28. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/envelope.py +0 -0
  29. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/execution.py +0 -0
  30. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/fs_server.py +0 -0
  31. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/globs.py +0 -0
  32. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/holdstore.py +0 -0
  33. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/identity.py +0 -0
  34. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/issuer.py +0 -0
  35. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/jws.py +0 -0
  36. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/key_record_source.py +0 -0
  37. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/notify.py +0 -0
  38. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/opa.py +0 -0
  39. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/pending_store.py +0 -0
  40. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/plans.py +0 -0
  41. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/policy.py +0 -0
  42. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/reconcile.py +0 -0
  43. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/remote.py +0 -0
  44. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/replay_cache.py +0 -0
  45. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/revert.py +0 -0
  46. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/rules.py +0 -0
  47. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  48. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/state.py +0 -0
  49. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/static/approve.html +0 -0
  50. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/static/approve.js +0 -0
  51. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/verifier.py +0 -0
  52. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/witness.py +0 -0
  53. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  54. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  55. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  56. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  57. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/setup.cfg +0 -0
  58. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_annotations.py +0 -0
  59. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_approver_cli.py +0 -0
  60. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_approvers.py +0 -0
  61. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_canonical.py +0 -0
  62. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_console_js.py +0 -0
  63. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_consumer.py +0 -0
  64. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_entra_identity.py +0 -0
  65. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_entra_login.py +0 -0
  66. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_evidence.py +0 -0
  67. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_execution.py +0 -0
  68. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_fs_server.py +0 -0
  69. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_globs.py +0 -0
  70. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_graph.py +0 -0
  71. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_historical.py +0 -0
  72. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_identity.py +0 -0
  73. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_jws.py +0 -0
  74. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_mcp_proxy.py +0 -0
  75. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_mcp_server.py +0 -0
  76. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_notify.py +0 -0
  77. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_opa.py +0 -0
  78. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_plans.py +0 -0
  79. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_policy.py +0 -0
  80. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_published_record.py +0 -0
  81. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_remote.py +0 -0
  82. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_revert.py +0 -0
  83. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_review_fixes.py +0 -0
  84. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_state.py +0 -0
  85. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_state_text.py +0 -0
  86. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_stdio_encoding.py +0 -0
  87. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_threshold.py +0 -0
  88. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_typescript_conformance.py +0 -0
  89. {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_wire_spec.py +0 -0
  90. {gargoyle_receipts-0.17.0.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.17.0.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.17.0.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
 
@@ -288,6 +288,7 @@ 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
291
292
  gargoyle issuer # the issuing service, on rules.json
292
293
  gargoyle approve --watch 2 # where the approver sits (terminal)
293
294
 
@@ -397,7 +398,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
397
398
  | `GARGOYLE_MCP_GATE_KEY_ID`, `GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX` | the pinned issuer key |
398
399
  | `GARGOYLE_MCP_TARGET_ID` | the target identity receipts bind to |
399
400
  | `GARGOYLE_MCP_PRINCIPAL` | the client this server serves |
400
- | `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 |
401
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) |
402
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) |
403
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 |
@@ -459,7 +460,7 @@ on it.
459
460
  | `jws` | the receipt as a JWS any JWT library verifies |
460
461
  | `envelope` | receipt construction, hash region, signature region, currency check |
461
462
  | `verifier` | the pure target-side check and every refusal code |
462
- | `consumer` | `Consumer` (pin, keys, verify, claim, attest) and the reference HTTP target |
463
+ | `consumer` | `Consumer` (pin, keys, verify, claim, attest) |
463
464
  | `execution` | execution receipts and the offline chain check |
464
465
  | `evidence` | the executor's hash-chained log of every decision, and its offline verifier |
465
466
  | `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.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
 
@@ -250,6 +250,7 @@ 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
253
254
  gargoyle issuer # the issuing service, on rules.json
254
255
  gargoyle approve --watch 2 # where the approver sits (terminal)
255
256
 
@@ -359,7 +360,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
359
360
  | `GARGOYLE_MCP_GATE_KEY_ID`, `GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX` | the pinned issuer key |
360
361
  | `GARGOYLE_MCP_TARGET_ID` | the target identity receipts bind to |
361
362
  | `GARGOYLE_MCP_PRINCIPAL` | the client this server serves |
362
- | `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 |
363
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) |
364
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) |
365
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 |
@@ -421,7 +422,7 @@ on it.
421
422
  | `jws` | the receipt as a JWS any JWT library verifies |
422
423
  | `envelope` | receipt construction, hash region, signature region, currency check |
423
424
  | `verifier` | the pure target-side check and every refusal code |
424
- | `consumer` | `Consumer` (pin, keys, verify, claim, attest) and the reference HTTP target |
425
+ | `consumer` | `Consumer` (pin, keys, verify, claim, attest) |
425
426
  | `execution` | execution receipts and the offline chain check |
426
427
  | `evidence` | the executor's hash-chained log of every decision, and its offline verifier |
427
428
  | `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,
@@ -523,7 +600,13 @@ def main(argv=None) -> int:
523
600
  help="the filesystem MCP server behind the gate: the official one via npx, or gargoyle.fs_server")
524
601
  p.add_argument("--entra-tenant", help="trust this Entra ID tenant's tokens at the issuer (users then run `gargoyle login`)")
525
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)")
526
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)
527
610
  p = sub.add_parser("login", help="sign this workstation's user in with Entra ID (device code)")
528
611
  p.add_argument("--tenant", required=True, help="tenant id or domain")
529
612
  p.add_argument("--client-id", required=True, help="the public client app registration")
@@ -129,38 +129,77 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
129
129
 
130
130
  class EvidenceLog:
131
131
  """Appends entries to a JSON Lines file, continuing the chain found at
132
- its tail. The file is read once, when the first entry is appended; a
133
- file nobody else writes to is the operating assumption (one middleware
134
- 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."""
135
138
 
136
139
  def __init__(self, path: str) -> None:
137
140
  self.path = Path(path)
138
141
  self._seq: Optional[int] = None
139
142
  self._tail: str = GENESIS
143
+ self._size: int = -1 # the file size after our last write; another writer changes it
140
144
 
141
145
  def _load_tail(self) -> None:
142
146
  self._seq, self._tail = 0, GENESIS
143
147
  if not self.path.exists():
148
+ self._size = 0
144
149
  return
145
- 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():
146
153
  if line.strip():
147
154
  last = json.loads(line)
148
155
  if last.get("evidence_version") in EVIDENCE_VERSIONS:
149
156
  self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
150
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
+
151
187
  def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
152
188
  receipt: Any = None, grant: Any = None, execution: Any = None,
153
189
  client: Optional[str] = None, ref: Optional[str] = None) -> Dict[str, Any]:
154
190
  """Write one entry and return it. Raises OSError if the file cannot
155
191
  be written; the caller decides whether that stops the call."""
156
- if self._seq is None:
157
- self._load_tail()
158
- entry = build_entry(seq=self._seq + 1, prev_sha256=self._tail, kind=kind, tool=tool, args=args,
159
- principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution,
160
- client=client, ref=ref)
161
- with open(self.path, "a", encoding="utf-8") as f:
162
- f.write(json.dumps(entry, sort_keys=True) + "\n")
163
- 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
164
203
  return entry
165
204
 
166
205
 
@@ -63,6 +63,7 @@ from gargoyle.approvers import (DEFAULT_APPROVER_ROLE, ENV_APPROVER_GROUPS, ENV_
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
65
  from gargoyle.jws import REF_JWS_KEY_UNKNOWN
66
+ from gargoyle.published_record_source import sign_published_record
66
67
  from gargoyle.issuer import DEFAULT_TTL_SECONDS, issue
67
68
  from gargoyle.policy import Policy
68
69
 
@@ -683,9 +684,21 @@ def build_issuer_app(
683
684
  async def approve_script():
684
685
  return Response((STATIC / "approve.js").read_bytes(), media_type="text/javascript; charset=utf-8")
685
686
 
687
+ app.state.record_pin = None
688
+ app.state.record_serial = 0
689
+
686
690
  @app.get("/record")
687
691
  async def record():
688
- return policy.pin()
692
+ """The live policy pin in the signed published-record form (SPEC 9),
693
+ so a middleware on another host can follow rule changes without a
694
+ file copy: it verifies the issuer's signature with the key it
695
+ already pins. The serial rises whenever the pin changes, so a
696
+ rolled-back record is refused as stale."""
697
+ pin = policy.pin()
698
+ if pin != app.state.record_pin:
699
+ app.state.record_pin = pin
700
+ app.state.record_serial = max(int(now().timestamp()), app.state.record_serial + 1)
701
+ return sign_published_record(pin, signing_key, key_id, serial=app.state.record_serial, now=now(), ttl_seconds=600)
689
702
 
690
703
  @app.get("/keys")
691
704
  async def keys():
@@ -51,6 +51,7 @@ from gargoyle.consumer import REF_TARGET_NOT_CONFIGURED, Consumer
51
51
  from gargoyle.evidence import HONORED, REFUSED, EvidenceLog, TOOL_ERROR, UPSTREAM_ERROR, UPSTREAM_UNAVAILABLE
52
52
  from gargoyle.envelope import valid_principal
53
53
  from gargoyle.policy import valid_pin
54
+ from gargoyle.verifier import REF_VERIFY_REASSERT_RE_EVALUATE_REQUIRED
54
55
  from gargoyle.replay_cache import InMemoryReplayCache, ReplayCache, SqliteReplayCache
55
56
 
56
57
  # MCP protocol version this server speaks (a real MCP revision string).
@@ -88,6 +89,7 @@ ENV_GATE_KEY_ID = "GARGOYLE_MCP_GATE_KEY_ID"
88
89
  ENV_GATE_PUBLIC_KEY_HEX = "GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX"
89
90
  ENV_TARGET_ID = "GARGOYLE_MCP_TARGET_ID"
90
91
  ENV_RECORD_PATH = "GARGOYLE_MCP_RECORD_PATH"
92
+ ENV_RECORD_URL = "GARGOYLE_MCP_RECORD_URL" # follow the issuer's signed record instead of a file (SPEC 9)
91
93
  ENV_PRINCIPAL = "GARGOYLE_MCP_PRINCIPAL"
92
94
  ENV_APPROVER_KEY_ID = "GARGOYLE_MCP_APPROVER_KEY_ID"
93
95
  ENV_APPROVER_PUBLIC_KEY_HEX = "GARGOYLE_MCP_APPROVER_PUBLIC_KEY_HEX"
@@ -108,6 +110,7 @@ REF_TARGET_LOGIN_REQUIRED = "REF_TARGET_LOGIN_REQUIRED" # Entra login mode and
108
110
  REF_TARGET_ISSUER_UNAVAILABLE = "REF_TARGET_ISSUER_UNAVAILABLE"
109
111
  REF_TARGET_APPROVAL_TIMEOUT = "REF_TARGET_APPROVAL_TIMEOUT"
110
112
  REF_TARGET_APPROVAL_DECLINED = "REF_TARGET_APPROVAL_DECLINED"
113
+ REF_TARGET_GATE_FILES = "REF_TARGET_GATE_FILES" # the call would touch the gate's own folder (config, keys, evidence, snapshots)
111
114
 
112
115
 
113
116
  class IssuerClient:
@@ -194,6 +197,15 @@ class IssuerClient:
194
197
  except Exception:
195
198
  return {}
196
199
 
200
+ def decline(self, approval_request_id: str) -> bool:
201
+ """Withdraw a hold this gate asked for (the hold's own principal may):
202
+ when the gate stops waiting, the card must not stay approvable."""
203
+ try:
204
+ r = self._post("/decline", {"approval_request_id": approval_request_id})
205
+ return r.status_code == 200 and bool(r.json().get("declined"))
206
+ except Exception:
207
+ return False
208
+
197
209
  def wait_for_grant(self, approval_request_id: str) -> Dict[str, Any]:
198
210
  """Poll until the hold has its grant (or, for a receipt that needs
199
211
  several approvers, the whole set), a human declined it, the hold
@@ -326,6 +338,50 @@ def call_for(tool: str, args: Any, principal: str, state_sha256: Optional[str] =
326
338
  return call
327
339
 
328
340
 
341
+ class RecordFollower:
342
+ """The live policy pin, fetched as the issuer's signed record (GET
343
+ /record) and verified against the pinned issuer key, cached for a
344
+ minute. A rule change on the issuer then reaches every gate within
345
+ that minute, or at once when a receipt arrives under a pin the cache
346
+ does not know (the gate forgets the cache and asks again before
347
+ refusing). No file copy, no restart. The trust is the same as for
348
+ receipts: the issuer's key, pinned out of band. A deployment that wants
349
+ an independent copy of the pin keeps GARGOYLE_MCP_RECORD_PATH instead."""
350
+
351
+ def __init__(self, url: str, pinned_keys: Dict[str, Any], *, http: Any = None, ttl_seconds: float = 60.0,
352
+ request_timeout: float = 10.0) -> None:
353
+ import requests
354
+ self.url = url
355
+ self.pinned_keys = pinned_keys
356
+ self._http = http if http is not None else requests.Session()
357
+ self.ttl_seconds = ttl_seconds
358
+ self.request_timeout = request_timeout
359
+ self._cache: Optional[tuple] = None
360
+ self.fetches = 0
361
+
362
+ def forget(self) -> None:
363
+ self._cache = None
364
+
365
+ def __call__(self, last_seen_serial: Optional[int]) -> Dict[str, Any]:
366
+ from gargoyle.published_record_source import load_signed_record_from_bytes
367
+ from gargoyle.verifier import REF_VERIFY_PUBLISHED_RECORD_INVALID
368
+ cached = self._cache
369
+ if cached is not None and time.monotonic() - cached[0] < self.ttl_seconds:
370
+ return cached[1]
371
+ self.fetches += 1
372
+ try:
373
+ r = self._http.get(self.url, timeout=self.request_timeout)
374
+ if r.status_code != 200:
375
+ res = {"record": None, "reason": REF_VERIFY_PUBLISHED_RECORD_INVALID}
376
+ else:
377
+ res = load_signed_record_from_bytes(r.content, self.pinned_keys, last_seen_serial=last_seen_serial)
378
+ except Exception:
379
+ res = {"record": None, "reason": REF_VERIFY_PUBLISHED_RECORD_INVALID}
380
+ if res.get("record") is not None:
381
+ self._cache = (time.monotonic(), res) # a failure is not cached: the next check asks again
382
+ return res
383
+
384
+
329
385
  class ServerState:
330
386
  """The executor's out-of-band trust material (issuer keys, the policy record,
331
387
  target id, the principal it serves) as a Consumer, and the list of tool
@@ -357,6 +413,13 @@ class ServerState:
357
413
  self.upstream = upstream
358
414
  self.issuer_client = issuer_client
359
415
  self.evidence: Optional[EvidenceLog] = EvidenceLog(audit_log) if audit_log else None
416
+ # The gate's own folder (config, keys, evidence, snapshots, replay
417
+ # claims) is never a target: a call whose path arguments reach into
418
+ # it is refused before any receipt is asked for.
419
+ self.gate_dir: Optional[str] = None
420
+ if audit_log:
421
+ from gargoyle.globs import normalize_path
422
+ self.gate_dir = normalize_path(str(Path(audit_log).resolve().parent)).rstrip("/")
360
423
  if replay_cache is None and audit_log:
361
424
  # Single use has to outlive this process: the claims sit in a
362
425
  # SQLite file beside the evidence log (audit.replay.sqlite), so a
@@ -364,11 +427,16 @@ class ServerState:
364
427
  # receipt that was already honored (second review, finding 4).
365
428
  replay_cache = SqliteReplayCache(str(Path(audit_log).with_name(Path(audit_log).stem + ".replay.sqlite")))
366
429
  self.consumer: Optional[Consumer] = None
430
+ self.record_follower: Optional[RecordFollower] = None
367
431
  if config is not None and valid_principal(config.get("principal")):
432
+ if config.get("record_url"):
433
+ self.record_follower = RecordFollower(config["record_url"], config["pinned_public_keys"],
434
+ http=getattr(issuer_client, "_http", None))
368
435
  self.consumer = Consumer(
369
436
  target_id=config["target_id"],
370
437
  pinned_public_keys=config["pinned_public_keys"],
371
- record=config["record"],
438
+ record=None if self.record_follower is not None else config["record"],
439
+ record_source=self.record_follower,
372
440
  replay_cache=replay_cache,
373
441
  approver_public_keys=config.get("approver_public_keys"),
374
442
  approver_key_source=(issuer_client.approver_keys if issuer_client is not None else None),
@@ -410,6 +478,8 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
410
478
  envelope = meta.get(ENVELOPE_META_KEY)
411
479
  grant = meta.get(GRANT_META_KEY)
412
480
  self_issued = False
481
+ if state.gate_dir is not None and _touches_gate_files(args, state.gate_dir):
482
+ return refused(REF_TARGET_GATE_FILES)
413
483
 
414
484
  if envelope is None and state.issuer_client is not None:
415
485
  # Self-issue: ask the issuing service, and wait for the human if it
@@ -432,8 +502,14 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
432
502
  # A scoped grant the human already signed may cover this call;
433
503
  # otherwise wait for a card of its own.
434
504
  if release is None:
435
- waited = state.issuer_client.wait_for_grant(str(envelope.get("approval_request_id")))
505
+ arid = str(envelope.get("approval_request_id"))
506
+ waited = state.issuer_client.wait_for_grant(arid)
436
507
  if waited["grant"] is None:
508
+ if waited["reason"] == REF_TARGET_APPROVAL_TIMEOUT:
509
+ # Nobody is waiting for this release any more: take
510
+ # the card down, or an approval minutes later would
511
+ # read as a change that happened.
512
+ state.issuer_client.decline(arid)
437
513
  return refused(waited["reason"], envelope)
438
514
  grant = waited["grant"]
439
515
  else:
@@ -454,6 +530,14 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
454
530
  else:
455
531
  verdict = state.consumer.check(envelope, call_for(name, args, state.config["principal"], live_state),
456
532
  grant=grant)
533
+ if (not verdict.honored and verdict.reason == REF_VERIFY_REASSERT_RE_EVALUATE_REQUIRED
534
+ and state.record_follower is not None):
535
+ # The receipt names a pin the cached record does not: the rules may
536
+ # have just changed on the issuer. Ask once more before refusing.
537
+ state.record_follower.forget()
538
+ call = call_for(name, args, state.config["principal"], live_state)
539
+ verdict = (state.consumer.check(envelope, call, plan_release=grant) if is_plan_release(grant)
540
+ else state.consumer.check(envelope, call, grant=grant))
457
541
  if not verdict.honored:
458
542
  return refused(verdict.reason, envelope)
459
543
  if is_plan_release(grant):
@@ -477,6 +561,38 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
477
561
  "self_issued": self_issued, "ref": gate_ref["ref"]}
478
562
 
479
563
 
564
+ _GATE_FILE_GLOBS = ("*.env", "*.json", "*.jsonl", "*.jsonl.lock", "*.sqlite", "*.sqlite-*", "snapshots")
565
+
566
+
567
+ def _touches_gate_files(args: Any, gate_dir: str) -> bool:
568
+ """Does any path-like string argument (at the top level or in a list)
569
+ name the gate's own folder, one of its files (env, json, the evidence
570
+ log and its lock, the replay claims) or anything under its snapshots?
571
+ Deeper subfolders are left alone: a deployment folder may hold the
572
+ project it serves."""
573
+ import fnmatch
574
+ from gargoyle.globs import is_pathlike, normalize_path
575
+ values = []
576
+ if isinstance(args, dict):
577
+ for v in args.values():
578
+ if isinstance(v, str):
579
+ values.append(v)
580
+ elif isinstance(v, list):
581
+ values.extend(x for x in v if isinstance(x, str))
582
+ for v in values:
583
+ if not is_pathlike(v):
584
+ continue
585
+ n = normalize_path(v)
586
+ if n == gate_dir:
587
+ return True
588
+ if n.startswith(gate_dir + "/"):
589
+ first = n[len(gate_dir) + 1:].split("/")[0]
590
+ direct = "/" not in n[len(gate_dir) + 1:]
591
+ if first == "snapshots" or (direct and any(fnmatch.fnmatchcase(first, g) for g in _GATE_FILE_GLOBS)):
592
+ return True
593
+ return False
594
+
595
+
480
596
  def _plan_release_for(state: ServerState, tool: str, args: Any) -> Optional[Dict[str, Any]]:
481
597
  """The release record for the first active plan with an unconsumed step
482
598
  matching this call, or None."""
@@ -745,8 +861,10 @@ def config_from_env() -> Optional[Dict[str, Any]]:
745
861
  GARGOYLE_MCP_TARGET_ID is the identity receipts bind to;
746
862
  GARGOYLE_MCP_PRINCIPAL is the client this server serves (see the module
747
863
  docstring); the published record (the live policy pin, {policy_id,
748
- policy_sha256}) is read from GARGOYLE_MCP_RECORD_PATH. All five are
749
- required; there is no default record. GARGOYLE_MCP_APPROVER_KEY_ID and
864
+ policy_sha256}) is read from GARGOYLE_MCP_RECORD_PATH, or followed live
865
+ from GARGOYLE_MCP_RECORD_URL (the issuer's signed record; a rule change
866
+ then needs no file copy). One of the two is required with the other
867
+ four settings; there is no default record. GARGOYLE_MCP_APPROVER_KEY_ID and
750
868
  GARGOYLE_MCP_APPROVER_PUBLIC_KEY_HEX, both or neither, pin the approver
751
869
  keys whose grants release flagged receipts (comma-separated lists for
752
870
  several)."""
@@ -757,7 +875,8 @@ def config_from_env() -> Optional[Dict[str, Any]]:
757
875
  target_id = os.environ.get(ENV_TARGET_ID)
758
876
  principal = os.environ.get(ENV_PRINCIPAL)
759
877
  record_path = os.environ.get(ENV_RECORD_PATH)
760
- if not (key_id and key_hex and target_id and principal and record_path):
878
+ record_url = os.environ.get(ENV_RECORD_URL)
879
+ if not (key_id and key_hex and target_id and principal and (record_path or record_url)):
761
880
  return None
762
881
  ap_key_id = os.environ.get(ENV_APPROVER_KEY_ID)
763
882
  ap_key_hex = os.environ.get(ENV_APPROVER_PUBLIC_KEY_HEX)
@@ -780,15 +899,18 @@ def config_from_env() -> Optional[Dict[str, Any]]:
780
899
  if len(ids) != len(hexes) or not all(ids):
781
900
  return None
782
901
  approver_keys = {i: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h)) for i, h in zip(ids, hexes)}
783
- with open(record_path, "rb") as f:
784
- record = json.loads(f.read().decode("utf-8"))
785
- if not valid_pin(record):
786
- return None
902
+ record = None
903
+ if not record_url:
904
+ with open(record_path, "rb") as f:
905
+ record = json.loads(f.read().decode("utf-8"))
906
+ if not valid_pin(record):
907
+ return None
787
908
  except Exception:
788
909
  return None
789
910
  return {
790
911
  "pinned_public_keys": {key_id: public_key},
791
912
  "record": record,
913
+ "record_url": record_url or None,
792
914
  "target_id": target_id,
793
915
  "principal": principal,
794
916
  "approver_public_keys": approver_keys,