gargoyle-receipts 0.8.1.dev0__tar.gz → 0.10.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 (74) hide show
  1. {gargoyle_receipts-0.8.1.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.10.0.dev0}/PKG-INFO +26 -3
  2. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/README.md +25 -2
  3. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/cli.py +84 -1
  4. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/evidence.py +21 -8
  5. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/mcp_server.py +15 -1
  6. gargoyle_receipts-0.10.0.dev0/gargoyle/remote.py +254 -0
  7. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +26 -3
  8. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
  9. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/pyproject.toml +1 -1
  10. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_evidence.py +19 -0
  11. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_mcp_self_issue.py +3 -1
  12. gargoyle_receipts-0.10.0.dev0/tests/test_remote.py +183 -0
  13. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/LICENSE +0 -0
  14. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/NOTICE +0 -0
  15. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/__init__.py +0 -0
  16. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/approval.py +0 -0
  17. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/approver_cli.py +0 -0
  18. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/approver_trust.py +0 -0
  19. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/approvers.py +0 -0
  20. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/consumer.py +0 -0
  21. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/entra_login.py +0 -0
  22. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/envelope.py +0 -0
  23. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/execution.py +0 -0
  24. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/fs_server.py +0 -0
  25. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/identity.py +0 -0
  26. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/issuer.py +0 -0
  27. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/issuer_service.py +0 -0
  28. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/jws.py +0 -0
  29. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/key_record_source.py +0 -0
  30. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/notify.py +0 -0
  31. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/opa.py +0 -0
  32. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/pending_store.py +0 -0
  33. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/policy.py +0 -0
  34. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/published_record_source.py +0 -0
  35. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/replay_cache.py +0 -0
  36. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/revert.py +0 -0
  37. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/rules.py +0 -0
  38. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/state.py +0 -0
  39. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/static/approve.html +0 -0
  40. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/static/approve.js +0 -0
  41. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/verifier.py +0 -0
  42. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  43. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  44. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  45. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  46. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/setup.cfg +0 -0
  47. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_annotations.py +0 -0
  48. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_approver_cli.py +0 -0
  49. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_approvers.py +0 -0
  50. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_canonical.py +0 -0
  51. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_cli.py +0 -0
  52. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_console_js.py +0 -0
  53. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_consumer.py +0 -0
  54. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_entra_identity.py +0 -0
  55. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_entra_login.py +0 -0
  56. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_execution.py +0 -0
  57. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_fs_server.py +0 -0
  58. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_historical.py +0 -0
  59. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_identity.py +0 -0
  60. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_issuer_service.py +0 -0
  61. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_jws.py +0 -0
  62. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_mcp_proxy.py +0 -0
  63. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_mcp_server.py +0 -0
  64. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_notify.py +0 -0
  65. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_opa.py +0 -0
  66. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_policy.py +0 -0
  67. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_published_record.py +0 -0
  68. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_revert.py +0 -0
  69. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_rules.py +0 -0
  70. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_state.py +0 -0
  71. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_stdio_encoding.py +0 -0
  72. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_threshold.py +0 -0
  73. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_typescript_conformance.py +0 -0
  74. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_wire_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.8.1.dev0
3
+ Version: 0.10.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.8.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.10.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
@@ -235,7 +235,10 @@ undone the same way: the middleware keeps what a held write replaced and
235
235
  offers `gargoyle_revert`, which composes the restoring write and sends it
236
236
  through the gate, held, shown as a diff, bound to the current state, and
237
237
  refused if anything changed since. The audit marks a revert from the
238
- state hashes alone.
238
+ state hashes alone. Every entry also names the MCP client that asked, from
239
+ its initialize handshake, so a fleet with Claude on some desks and ChatGPT
240
+ on others still has one trail that says who, through what, did what. The
241
+ receipt itself stays model-agnostic.
239
242
 
240
243
  The MCP middleware also keeps an evidence log: one hash-chained line per
241
244
  decision, refusals and declined holds included, so the trail answers "what
@@ -289,6 +292,25 @@ and `gargoyle audit` verifies them later offline.
289
292
  and through the alternative with OPA in Docker for sites that run a policy
290
293
  engine.
291
294
 
295
+ ## Remote mode
296
+
297
+ Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
298
+ custom connectors) and Microsoft 365 servers are reached over HTTPS, not
299
+ through a local process. `gargoyle remote` runs the same gate as a
300
+ Streamable HTTP MCP server in front of a stdio command or a remote MCP
301
+ server (`--upstream-url`), so those paths go through it too:
302
+
303
+ gargoyle remote-token --out gargoyle-deploy --principal dana@contoso.com --url https://gargoyle.lan:8444
304
+ gargoyle remote --out gargoyle-deploy --port 8444 --ssl-certfile ... --ssl-keyfile ... -- python -m gargoyle.fs_server /srv/shared
305
+
306
+ Each person gets a capability URL (`/mcp/<token>`) that names them on
307
+ every receipt; the operator vouches for that mapping, as with an API key,
308
+ because a hosted connector cannot carry the user's own Entra token.
309
+ Treat the URL as a credential. All sessions share one upstream, one replay
310
+ cache, one evidence log and one snapshot store, so single use and undo
311
+ hold across users. Device-bound identity remains the stdio mode's
312
+ property.
313
+
292
314
  ## The shipped consumer
293
315
 
294
316
  **MCP middleware** (`python -m gargoyle.mcp_server -- <upstream command>`):
@@ -319,6 +341,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
319
341
  | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
320
342
  | `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 |
321
343
  | `GARGOYLE_MCP_STATE_PROBE` | `filesystem` (default) binds a held file write to the file's current state: the approver sees the change as a diff, the call refuses if the file moved before the run (`REF_VERIFY_STATE_MISMATCH`), and the execution receipt records pre and post state; `none` disables |
344
+ | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
322
345
  | `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 |
323
346
  | `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 |
324
347
 
@@ -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.8.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.10.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
@@ -197,7 +197,10 @@ undone the same way: the middleware keeps what a held write replaced and
197
197
  offers `gargoyle_revert`, which composes the restoring write and sends it
198
198
  through the gate, held, shown as a diff, bound to the current state, and
199
199
  refused if anything changed since. The audit marks a revert from the
200
- state hashes alone.
200
+ state hashes alone. Every entry also names the MCP client that asked, from
201
+ its initialize handshake, so a fleet with Claude on some desks and ChatGPT
202
+ on others still has one trail that says who, through what, did what. The
203
+ receipt itself stays model-agnostic.
201
204
 
202
205
  The MCP middleware also keeps an evidence log: one hash-chained line per
203
206
  decision, refusals and declined holds included, so the trail answers "what
@@ -251,6 +254,25 @@ and `gargoyle audit` verifies them later offline.
251
254
  and through the alternative with OPA in Docker for sites that run a policy
252
255
  engine.
253
256
 
257
+ ## Remote mode
258
+
259
+ Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
260
+ custom connectors) and Microsoft 365 servers are reached over HTTPS, not
261
+ through a local process. `gargoyle remote` runs the same gate as a
262
+ Streamable HTTP MCP server in front of a stdio command or a remote MCP
263
+ server (`--upstream-url`), so those paths go through it too:
264
+
265
+ gargoyle remote-token --out gargoyle-deploy --principal dana@contoso.com --url https://gargoyle.lan:8444
266
+ gargoyle remote --out gargoyle-deploy --port 8444 --ssl-certfile ... --ssl-keyfile ... -- python -m gargoyle.fs_server /srv/shared
267
+
268
+ Each person gets a capability URL (`/mcp/<token>`) that names them on
269
+ every receipt; the operator vouches for that mapping, as with an API key,
270
+ because a hosted connector cannot carry the user's own Entra token.
271
+ Treat the URL as a credential. All sessions share one upstream, one replay
272
+ cache, one evidence log and one snapshot store, so single use and undo
273
+ hold across users. Device-bound identity remains the stdio mode's
274
+ property.
275
+
254
276
  ## The shipped consumer
255
277
 
256
278
  **MCP middleware** (`python -m gargoyle.mcp_server -- <upstream command>`):
@@ -281,6 +303,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
281
303
  | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
282
304
  | `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 |
283
305
  | `GARGOYLE_MCP_STATE_PROBE` | `filesystem` (default) binds a held file write to the file's current state: the approver sees the change as a diff, the call refuses if the file moved before the run (`REF_VERIFY_STATE_MISMATCH`), and the execution receipt records pre and post state; `none` disables |
306
+ | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
284
307
  | `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 |
285
308
  | `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 |
286
309
 
@@ -11,6 +11,9 @@ policy engine, a container runtime, or any client changes.
11
11
  gargoyle approve [--out DIR] [--watch SECONDS] sit and approve held calls
12
12
  gargoyle login --tenant T --client-id C --scope S sign this workstation's user in with
13
13
  Entra ID; the middleware then acts as them
14
+ gargoyle remote --out DIR [--upstream-url URL | -- <command>] the gate as an HTTPS MCP
15
+ server, for hosted connectors
16
+ gargoyle remote-token --out DIR --principal UPN a capability URL for one person
14
17
  gargoyle audit <receipt.json> <execution.json> [grant.json]
15
18
  gargoyle audit --log audit.jsonl verify the middleware's evidence log
16
19
 
@@ -110,7 +113,7 @@ def cmd_init(args) -> int:
110
113
  _write(rules, json.dumps(DEFAULT_RULES, indent=2) + "\n")
111
114
  _write(out / "issuer.env", _env_lines({
112
115
  "GARGOYLE_ISSUER_KEY_ID": "issuer-1", "GARGOYLE_ISSUER_KEY_HEX": issuer_hex,
113
- "GARGOYLE_IDP_KEY_ID": "idp-1", "GARGOYLE_IDP_PUBLIC_KEY_HEX": idp_pub,
116
+ "GARGOYLE_IDP_KEY_ID": "idp-1", "GARGOYLE_IDP_PUBLIC_KEY_HEX": idp_pub, "GARGOYLE_IDP_KEY_HEX": idp_hex,
114
117
  "GARGOYLE_ISSUER_AUDIENCE": "gargoyle-issuer",
115
118
  "GARGOYLE_APPROVER_KEY_ID": approver_ids, "GARGOYLE_APPROVER_PUBLIC_KEY_HEX": approver_pubs,
116
119
  "GARGOYLE_RULES_PATH": str(rules), "GARGOYLE_RECEIPT_TTL_SECONDS": "600",
@@ -188,6 +191,67 @@ def cmd_login(args) -> int:
188
191
  return 0
189
192
 
190
193
 
194
+ def cmd_remote_token(args) -> int:
195
+ """Mint one person's capability token for the remote gate: an identity
196
+ token the issuer trusts for that principal (signed by the pinned IdP key
197
+ from init), mapped to a random URL segment in remote-tokens.json."""
198
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
199
+ from gargoyle.remote import TokenMap, mint_identity_token
200
+ out = Path(args.out).resolve()
201
+ ienv = _read_env(out / "issuer.env")
202
+ idp = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(ienv["GARGOYLE_IDP_KEY_HEX"])) if "GARGOYLE_IDP_KEY_HEX" in ienv else None
203
+ if idp is None:
204
+ # init keeps the IdP private key only in the approver/client tokens' lineage; derive from the stored seed if present
205
+ raise SystemExit("issuer.env has no GARGOYLE_IDP_KEY_HEX; run `gargoyle init` from this version first")
206
+ audience = (ienv.get("GARGOYLE_ISSUER_AUDIENCE") or "gargoyle-issuer").split(",")[0].strip()
207
+ token = mint_identity_token(args.principal, idp, ienv.get("GARGOYLE_IDP_KEY_ID", "idp-1"), audience, days=args.days)
208
+ tokens = TokenMap(str(out / "remote-tokens.json"))
209
+ cap = tokens.mint(args.principal, token)
210
+ base = args.url or _read_env(out / "middleware.env").get("GARGOYLE_MCP_ISSUER_URL", "https://<gate host>")
211
+ print("remote-tokens.json updated for %s" % args.principal)
212
+ print("connector URL (treat as a credential): %s/mcp/%s" % (base.rstrip("/"), cap))
213
+ return 0
214
+
215
+
216
+ def cmd_remote(args) -> int:
217
+ """Run the gate as a Streamable HTTP MCP server on the deployment's
218
+ middleware settings, fronting a stdio command or a remote MCP server."""
219
+ import uvicorn
220
+ from gargoyle.mcp_server import config_from_env, upstream_from_argv
221
+ from gargoyle.remote import HttpUpstream, TokenMap, build_remote_app
222
+ out = Path(args.out).resolve()
223
+ env = _read_env(out / "middleware.env")
224
+ env.setdefault("GARGOYLE_MCP_PRINCIPAL", "remote") # each token supplies the real one
225
+ env.setdefault("GARGOYLE_MCP_RECORD_PATH", str(out / "record.json"))
226
+ os.environ.update(env)
227
+ config = config_from_env()
228
+ if config is None:
229
+ raise SystemExit("middleware.env in %s is incomplete" % out)
230
+ config.pop("principal", None)
231
+ if args.upstream_url:
232
+ upstream = HttpUpstream(args.upstream_url, args.upstream_token or os.environ.get("GARGOYLE_MCP_UPSTREAM_TOKEN"))
233
+ else:
234
+ upstream = upstream_from_argv(args.rest)
235
+ if upstream is None:
236
+ raise SystemExit("give --upstream-url URL or `-- <command>` for the server to front")
237
+ tokens = TokenMap(str(out / "remote-tokens.json"))
238
+ app = build_remote_app(config, tokens, upstream=upstream, issuer_url=env.get("GARGOYLE_MCP_ISSUER_URL"),
239
+ audit_log=env.get("GARGOYLE_MCP_AUDIT_LOG") or str(out / "remote-audit.jsonl"),
240
+ state_probe=args.state_probe, snapshot_dir=env.get("GARGOYLE_MCP_SNAPSHOT_DIR"),
241
+ approval_timeout=float(env.get("GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS", "300")))
242
+ tls = {}
243
+ if args.ssl_certfile or args.ssl_keyfile:
244
+ if not (args.ssl_certfile and args.ssl_keyfile):
245
+ raise SystemExit("--ssl-certfile and --ssl-keyfile go together")
246
+ tls = {"ssl_certfile": args.ssl_certfile, "ssl_keyfile": args.ssl_keyfile}
247
+ print("remote gate on %s://%s:%d/mcp/<token>" % ("https" if tls else "http", args.host, args.port), file=sys.stderr)
248
+ try:
249
+ uvicorn.run(app, host=args.host, port=args.port, log_level="info" if args.verbose else "warning", **tls)
250
+ finally:
251
+ upstream.close()
252
+ return 0
253
+
254
+
191
255
  def cmd_issuer(args) -> int:
192
256
  import uvicorn
193
257
  from gargoyle.issuer_service import app_from_env
@@ -252,6 +316,8 @@ def cmd_audit(args) -> int:
252
316
  why += " state %s -> %s" % (pre or "?", post or "?") if post else " state %s" % pre
253
317
  if r.get("reverts"):
254
318
  why += " reverts #%d" % r["reverts"]
319
+ if r.get("client"):
320
+ why += " via " + r["client"]
255
321
  print("%4s %-5s %-8s %-24s %-28s %s%s" % (r["seq"] if r["seq"] is not None else "?",
256
322
  "ok" if r["accepted"] else "FAIL", r["kind"] or "?",
257
323
  (r["tool"] or "?")[:24], (r["principal"] or "?")[:28], r["reason"], why))
@@ -303,6 +369,23 @@ def main(argv=None) -> int:
303
369
  p.add_argument("--out", default="gargoyle-deploy")
304
370
  p.add_argument("--watch", type=float, metavar="SECONDS"); p.add_argument("--yes", action="store_true")
305
371
  p.set_defaults(fn=cmd_approve)
372
+ p = sub.add_parser("remote", help="run the gate as an HTTPS MCP server for hosted connectors")
373
+ p.add_argument("--out", default="gargoyle-deploy")
374
+ p.add_argument("--host", default="127.0.0.1"); p.add_argument("--port", type=int, default=8444)
375
+ p.add_argument("--ssl-certfile"); p.add_argument("--ssl-keyfile")
376
+ p.add_argument("--upstream-url", help="front a remote MCP server (Streamable HTTP)")
377
+ p.add_argument("--upstream-token", help="bearer for that server (or GARGOYLE_MCP_UPSTREAM_TOKEN)")
378
+ p.add_argument("--state-probe", default="none", choices=["none", "filesystem"],
379
+ help="filesystem only when the upstream is a local filesystem server on this host")
380
+ p.add_argument("--verbose", action="store_true")
381
+ p.add_argument("rest", nargs=argparse.REMAINDER, help="-- <command> for a stdio server to front")
382
+ p.set_defaults(fn=cmd_remote)
383
+ p = sub.add_parser("remote-token", help="mint one person's capability URL for the remote gate")
384
+ p.add_argument("--out", default="gargoyle-deploy")
385
+ p.add_argument("--principal", required=True, help="the UPN or principal the receipts will name")
386
+ p.add_argument("--days", type=int, default=365)
387
+ p.add_argument("--url", help="the gate's public base URL for the printed connector URL")
388
+ p.set_defaults(fn=cmd_remote_token)
306
389
  p = sub.add_parser("audit", help="verify a receipt chain offline, or a whole evidence log")
307
390
  p.add_argument("receipt", nargs="?"); p.add_argument("execution", nargs="?"); p.add_argument("grant", nargs="?")
308
391
  p.add_argument("--log", help="the middleware's audit.jsonl: verify every entry")
@@ -48,8 +48,9 @@ from gargoyle.envelope import args_sha256, canonical_json, canonical_json_legacy
48
48
  from gargoyle.execution import verify_chain
49
49
  from gargoyle.verifier import verify_provenance
50
50
 
51
- EVIDENCE_VERSION = "gargoyle-evidence/2" # entry hashes over RFC 8785 canonical JSON
52
- EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy, EVIDENCE_VERSION: canonical_json}
51
+ EVIDENCE_VERSION = "gargoyle-evidence/3" # version 3 adds `client`; hashes over RFC 8785 canonical JSON
52
+ EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy, "gargoyle-evidence/2": canonical_json,
53
+ EVIDENCE_VERSION: canonical_json}
53
54
  GENESIS = "0" * 64
54
55
 
55
56
  HONORED = "honored"
@@ -65,6 +66,11 @@ REF_EVIDENCE_UNCHAINED = "REF_EVIDENCE_UNCHAINED" # a pre-chain {receipt,
65
66
 
66
67
  _FIELDS = ("evidence_version", "seq", "prev_sha256", "at", "kind", "tool", "args_sha256",
67
68
  "principal", "reason", "receipt", "grant", "execution", "entry_sha256")
69
+ # Version 3 names the MCP client that made the call ("Claude Desktop 1.2", "ChatGPT"),
70
+ # from the initialize handshake; null when the client did not say. The
71
+ # receipt stays model-agnostic: this is evidence about the session, not a
72
+ # bound field of the decision.
73
+ _FIELDS_V3 = _FIELDS + ("client",)
68
74
 
69
75
 
70
76
  def entry_sha256(entry: Dict[str, Any]) -> str:
@@ -78,9 +84,11 @@ def entry_sha256(entry: Dict[str, Any]) -> str:
78
84
 
79
85
  def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any, principal: str,
80
86
  reason: str, receipt: Any = None, grant: Any = None, execution: Any = None,
81
- at: Optional[datetime] = None) -> Dict[str, Any]:
87
+ at: Optional[datetime] = None, client: Optional[str] = None) -> Dict[str, Any]:
82
88
  if kind not in (HONORED, REFUSED):
83
89
  raise ValueError("kind must be honored or refused")
90
+ if client is not None and not isinstance(client, str):
91
+ raise ValueError("client must be a string or None")
84
92
  entry = {
85
93
  "evidence_version": EVIDENCE_VERSION,
86
94
  "seq": seq,
@@ -94,6 +102,7 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
94
102
  "receipt": receipt,
95
103
  "grant": grant,
96
104
  "execution": execution,
105
+ "client": client,
97
106
  }
98
107
  entry["entry_sha256"] = entry_sha256(entry)
99
108
  return entry
@@ -121,13 +130,15 @@ class EvidenceLog:
121
130
  self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
122
131
 
123
132
  def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
124
- receipt: Any = None, grant: Any = None, execution: Any = None) -> Dict[str, Any]:
133
+ receipt: Any = None, grant: Any = None, execution: Any = None,
134
+ client: Optional[str] = None) -> Dict[str, Any]:
125
135
  """Write one entry and return it. Raises OSError if the file cannot
126
136
  be written; the caller decides whether that stops the call."""
127
137
  if self._seq is None:
128
138
  self._load_tail()
129
139
  entry = build_entry(seq=self._seq + 1, prev_sha256=self._tail, kind=kind, tool=tool, args=args,
130
- principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution)
140
+ principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution,
141
+ client=client)
131
142
  with open(self.path, "a", encoding="utf-8") as f:
132
143
  f.write(json.dumps(entry, sort_keys=True) + "\n")
133
144
  self._seq, self._tail = entry["seq"], entry["entry_sha256"]
@@ -135,8 +146,10 @@ class EvidenceLog:
135
146
 
136
147
 
137
148
  def _well_formed(entry: Any) -> bool:
138
- return (isinstance(entry, dict) and set(entry) == set(_FIELDS)
149
+ expected = _FIELDS_V3 if isinstance(entry, dict) and entry.get("evidence_version") == EVIDENCE_VERSION else _FIELDS
150
+ return (isinstance(entry, dict) and set(entry) == set(expected)
139
151
  and entry["evidence_version"] in EVIDENCE_VERSIONS
152
+ and (entry.get("client") is None or isinstance(entry.get("client"), str))
140
153
  and isinstance(entry["seq"], int) and entry["seq"] >= 1
141
154
  and isinstance(entry["prev_sha256"], str) and len(entry["prev_sha256"]) == 64
142
155
  and isinstance(entry["at"], str) and entry["kind"] in (HONORED, REFUSED)
@@ -179,7 +192,7 @@ def verify_log(
179
192
  rc = rc if isinstance(rc, dict) else {}
180
193
  report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
181
194
  "accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
182
- "verdict": None, "released_by": [], "state": None})
195
+ "verdict": None, "released_by": [], "state": None, "client": None})
183
196
  all_ok = False
184
197
  continue
185
198
  e = raw
@@ -212,7 +225,7 @@ def verify_log(
212
225
  if ex.get("pre_state_sha256") or ex.get("post_state_sha256") else None)
213
226
  report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
214
227
  "accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by,
215
- "state": state})
228
+ "state": state, "client": e.get("client")})
216
229
  all_ok = all_ok and ok
217
230
  # The chain continues from what the file says, so one bad entry is
218
231
  # reported once rather than cascading into every later one.
@@ -267,6 +267,15 @@ def _without_gargoyle_meta(params: Dict[str, Any]) -> Dict[str, Any]:
267
267
  return forwarded
268
268
 
269
269
 
270
+ def client_label(params: Any) -> Optional[str]:
271
+ """"name version" from an initialize request's clientInfo, or None."""
272
+ info = params.get("clientInfo") if isinstance(params, dict) else None
273
+ if not isinstance(info, dict) or not isinstance(info.get("name"), str) or not info["name"]:
274
+ return None
275
+ version = info.get("version")
276
+ return info["name"] + (" " + version if isinstance(version, str) and version else "")
277
+
278
+
270
279
  def call_for(tool: str, args: Any, principal: str, state_sha256: Optional[str] = None) -> Dict[str, Any]:
271
280
  """The call a receipt must bind to execute `tool` with `args` through a
272
281
  server serving `principal`: the same three fields the issuer bound, plus
@@ -319,6 +328,7 @@ class ServerState:
319
328
  )
320
329
  self.executed: List[Dict[str, Any]] = []
321
330
  self.initialized = False
331
+ self.client: Optional[str] = None # "name version" from the MCP initialize handshake
322
332
 
323
333
 
324
334
  def gate_tool_call(state: ServerState, params: Dict[str, Any]) -> (bool, str):
@@ -422,7 +432,8 @@ def _record(state: ServerState, kind: str, gate: Dict[str, Any], params: Dict[st
422
432
  principal = (state.config or {}).get("principal") or ""
423
433
  try:
424
434
  state.evidence.append(kind, str(params.get("name")), params.get("arguments", {}), principal,
425
- gate["reason"], receipt=gate["envelope"], grant=gate["grant"], execution=execution)
435
+ gate["reason"], receipt=gate["envelope"], grant=gate["grant"], execution=execution,
436
+ client=state.client)
426
437
  except (OSError, ValueError) as exc:
427
438
  sys.stderr.write("gargoyle: evidence log unwritable: %s\n" % exc)
428
439
 
@@ -464,6 +475,8 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
464
475
  params = msg.get("params") or {}
465
476
 
466
477
  if state.upstream is not None and method in ("initialize", "tools/list"):
478
+ if method == "initialize":
479
+ state.client = client_label(params)
467
480
  reply = _relay(state, msg)
468
481
  if method == "tools/list" and state.snapshots is not None and isinstance(reply, dict) \
469
482
  and isinstance(reply.get("result"), dict) and isinstance(reply["result"].get("tools"), list):
@@ -472,6 +485,7 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
472
485
  return reply
473
486
 
474
487
  if method == "initialize":
488
+ state.client = client_label(params)
475
489
  # The handshake completes when the client sends notifications/initialized;
476
490
  # tools are served only after that.
477
491
  return _success(
@@ -0,0 +1,254 @@
1
+ """Remote middleware mode: the gate as a Streamable HTTP MCP server.
2
+
3
+ The stdio middleware sits on the workstation and fronts a local server.
4
+ Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
5
+ custom connectors) reach an MCP server over HTTPS from Anthropic's side,
6
+ and Microsoft 365 servers are reached the same way. This module runs the
7
+ same gate behind an HTTP endpoint so those paths go through it too:
8
+
9
+ POST /mcp/{token} JSON-RPC in, JSON-RPC out (MCP Streamable HTTP)
10
+ DELETE /mcp/{token} end the session named by Mcp-Session-Id
11
+
12
+ `token` is a capability URL segment that names the workstation user: the
13
+ operator mints one per person (`gargoyle remote-token`), mapping it to the
14
+ principal the receipts will name and to the identity token the gate
15
+ presents to the issuer for that person. The hosted connector cannot carry
16
+ the user's own Entra token (that lives on the workstation), so in this
17
+ mode the gate's operator vouches for the user-to-URL mapping, as an API
18
+ key does. Treat the URL as a credential. Device-bound identity remains the
19
+ stdio mode's property.
20
+
21
+ The upstream is one shared server: a stdio process started once, or a
22
+ remote MCP server reached over Streamable HTTP (HttpUpstream), with an
23
+ optional static bearer. The gate initializes the upstream once and answers
24
+ each session's `initialize` itself; every session shares one replay cache,
25
+ one evidence log and one snapshot store, so single use holds across
26
+ sessions. Everything else is gargoyle.mcp_server unchanged.
27
+ """
28
+
29
+ import json
30
+ import secrets
31
+ import threading
32
+ import uuid
33
+ from pathlib import Path
34
+ from typing import Any, Dict, List, Optional
35
+
36
+ from gargoyle.mcp_server import (EXECUTED_META_KEY, PROTOCOL_VERSION, REASON_META_KEY, SERVER_INFO, IssuerClient,
37
+ ServerState, StdioUpstream, handle_message)
38
+ from gargoyle.replay_cache import InMemoryReplayCache
39
+
40
+ ENV_REMOTE_TOKENS = "GARGOYLE_REMOTE_TOKENS" # remote-tokens.json: {token: {"principal", "issuer_token"}}
41
+ ENV_UPSTREAM_URL = "GARGOYLE_MCP_UPSTREAM_URL" # front a remote MCP server instead of a stdio command
42
+ ENV_UPSTREAM_TOKEN = "GARGOYLE_MCP_UPSTREAM_TOKEN" # optional bearer for that server
43
+
44
+ SESSION_HEADER = "Mcp-Session-Id"
45
+ PROTOCOL_HEADER = "MCP-Protocol-Version"
46
+
47
+
48
+ class HttpUpstream:
49
+ """An MCP server reached over Streamable HTTP. Same contract as
50
+ StdioUpstream: send(msg) returns the reply for a request, None for a
51
+ notification. JSON responses and SSE responses are both accepted."""
52
+
53
+ def __init__(self, url: str, token: Optional[str] = None, *, http: Any = None, timeout: float = 60.0) -> None:
54
+ import requests
55
+ self.url = url
56
+ self._token = token
57
+ self._http = http if http is not None else requests.Session()
58
+ self._timeout = timeout
59
+ self._session_id: Optional[str] = None
60
+ self._lock = threading.Lock()
61
+
62
+ def _headers(self) -> Dict[str, str]:
63
+ h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
64
+ PROTOCOL_HEADER: PROTOCOL_VERSION}
65
+ if self._token:
66
+ h["Authorization"] = "Bearer " + self._token
67
+ if self._session_id:
68
+ h[SESSION_HEADER] = self._session_id
69
+ return h
70
+
71
+ def send(self, msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
72
+ with self._lock:
73
+ r = self._http.post(self.url, data=json.dumps(msg), headers=self._headers(), timeout=self._timeout)
74
+ sid = r.headers.get(SESSION_HEADER) or r.headers.get(SESSION_HEADER.lower())
75
+ if sid:
76
+ self._session_id = sid
77
+ if "id" not in msg:
78
+ return None
79
+ if r.status_code >= 400:
80
+ raise RuntimeError("upstream answered %s" % r.status_code)
81
+ ctype = r.headers.get("content-type", "")
82
+ if ctype.startswith("text/event-stream"):
83
+ for line in r.text.splitlines():
84
+ if line.startswith("data:"):
85
+ try:
86
+ reply = json.loads(line[5:].strip())
87
+ except ValueError:
88
+ continue
89
+ if isinstance(reply, dict) and reply.get("id") == msg["id"]:
90
+ return reply
91
+ raise RuntimeError("upstream stream ended without a reply")
92
+ reply = r.json()
93
+ if isinstance(reply, list):
94
+ reply = next((m for m in reply if isinstance(m, dict) and m.get("id") == msg["id"]), None)
95
+ if not isinstance(reply, dict):
96
+ raise RuntimeError("upstream reply is not a JSON-RPC message")
97
+ return reply
98
+
99
+ def close(self) -> None:
100
+ try:
101
+ self._http.delete(self.url, headers=self._headers(), timeout=5)
102
+ except Exception:
103
+ pass
104
+
105
+
106
+ class TokenMap:
107
+ """Capability tokens to {principal, issuer_token}, from a JSON file."""
108
+
109
+ def __init__(self, path: Optional[str] = None, entries: Optional[Dict[str, Dict[str, str]]] = None) -> None:
110
+ self.path = Path(path) if path else None
111
+ self._entries: Dict[str, Dict[str, str]] = dict(entries or {})
112
+ if self.path is not None and self.path.exists():
113
+ self._entries.update(json.loads(self.path.read_text(encoding="utf-8")).get("tokens", {}))
114
+
115
+ def lookup(self, token: str) -> Optional[Dict[str, str]]:
116
+ e = self._entries.get(token)
117
+ return dict(e) if isinstance(e, dict) and e.get("principal") and e.get("issuer_token") else None
118
+
119
+ def mint(self, principal: str, issuer_token: str) -> str:
120
+ token = secrets.token_urlsafe(32)
121
+ self._entries[token] = {"principal": principal, "issuer_token": issuer_token}
122
+ if self.path is not None:
123
+ self.path.parent.mkdir(parents=True, exist_ok=True)
124
+ self.path.write_text(json.dumps({"tokens": self._entries}, indent=2) + "\n", encoding="utf-8")
125
+ try:
126
+ import os, stat
127
+ os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
128
+ except OSError:
129
+ pass
130
+ return token
131
+
132
+
133
+ def build_remote_app(
134
+ config: Dict[str, Any],
135
+ tokens: TokenMap,
136
+ *,
137
+ upstream: Any = None,
138
+ issuer_url: Optional[str] = None,
139
+ audit_log: Optional[str] = None,
140
+ state_probe: Any = "none",
141
+ snapshot_dir: Optional[str] = None,
142
+ issuer_http: Any = None,
143
+ approval_timeout: float = 300.0,
144
+ poll_seconds: float = 2.0,
145
+ sleep=None,
146
+ ):
147
+ """The FastAPI app. `config` is config_from_env()'s result minus the
148
+ principal (each token supplies one). `upstream` is a StdioUpstream or
149
+ HttpUpstream already running; the gate initializes it once here."""
150
+ from fastapi import FastAPI, Header, Request, Response
151
+
152
+ app = FastAPI(title="Gargoyle remote gate")
153
+ replay = InMemoryReplayCache()
154
+ sessions: Dict[str, ServerState] = {}
155
+ lock = threading.Lock()
156
+ shared_evidence = None
157
+ shared_snapshots = None
158
+
159
+ upstream_init: Dict[str, Any] = {"protocolVersion": PROTOCOL_VERSION, "capabilities": {"tools": {"listChanged": False}},
160
+ "serverInfo": SERVER_INFO}
161
+ if upstream is not None:
162
+ reply = upstream.send({"jsonrpc": "2.0", "id": "gargoyle-init", "method": "initialize",
163
+ "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
164
+ "clientInfo": {"name": "gargoyle-remote", "version": SERVER_INFO["version"]}}})
165
+ if isinstance(reply, dict) and isinstance(reply.get("result"), dict):
166
+ upstream_init = dict(reply["result"])
167
+ upstream_init["serverInfo"] = {"name": "gargoyle+" + str(upstream_init.get("serverInfo", {}).get("name", "upstream")),
168
+ "version": SERVER_INFO["version"]}
169
+ upstream.send({"jsonrpc": "2.0", "method": "notifications/initialized"})
170
+
171
+ def new_state(principal: str, issuer_token: str) -> ServerState:
172
+ nonlocal shared_evidence, shared_snapshots
173
+ ic = None
174
+ if issuer_url:
175
+ kw: Dict[str, Any] = {"http": issuer_http, "approval_timeout": approval_timeout, "poll_seconds": poll_seconds}
176
+ if sleep is not None:
177
+ kw["sleep"] = sleep
178
+ ic = IssuerClient(issuer_url, issuer_token, **kw)
179
+ st = ServerState(dict(config, principal=principal), replay_cache=replay, upstream=upstream, issuer_client=ic,
180
+ audit_log=audit_log, state_probe=state_probe, snapshot_dir=snapshot_dir)
181
+ # One log and one snapshot store for the whole process: the chain and
182
+ # the sidecars must not interleave from several session objects.
183
+ if st.evidence is not None:
184
+ if shared_evidence is None:
185
+ shared_evidence = st.evidence
186
+ st.evidence = shared_evidence
187
+ if st.snapshots is not None:
188
+ if shared_snapshots is None:
189
+ shared_snapshots = st.snapshots
190
+ st.snapshots = shared_snapshots
191
+ st.upstream_init = upstream_init # type: ignore[attr-defined]
192
+ return st
193
+
194
+ def handle_one(st: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
195
+ method = msg.get("method")
196
+ if method == "initialize":
197
+ from gargoyle.mcp_server import client_label
198
+ st.client = client_label(msg.get("params"))
199
+ return {"jsonrpc": "2.0", "id": msg.get("id"), "result": st.upstream_init} # type: ignore[attr-defined]
200
+ if method == "notifications/initialized":
201
+ st.initialized = True
202
+ return None
203
+ return handle_message(st, msg)
204
+
205
+ @app.post("/mcp/{token}")
206
+ async def mcp_post(token: str, request: Request,
207
+ mcp_session_id: Optional[str] = Header(default=None, alias=SESSION_HEADER)):
208
+ who = tokens.lookup(token)
209
+ if who is None:
210
+ return Response(status_code=401, content="unknown token")
211
+ try:
212
+ body = await request.json()
213
+ except Exception:
214
+ return Response(status_code=400, content="not JSON")
215
+ messages: List[Any] = body if isinstance(body, list) else [body]
216
+ is_init = any(isinstance(m, dict) and m.get("method") == "initialize" for m in messages)
217
+ with lock:
218
+ if is_init or not mcp_session_id or mcp_session_id not in sessions:
219
+ if not is_init:
220
+ return Response(status_code=404, content="no such session; initialize first")
221
+ mcp_session_id = uuid.uuid4().hex
222
+ sessions[mcp_session_id] = new_state(who["principal"], who["issuer_token"])
223
+ st = sessions[mcp_session_id]
224
+ replies = [r for r in (handle_one(st, m) for m in messages if isinstance(m, dict)) if r is not None]
225
+ headers = {SESSION_HEADER: mcp_session_id, PROTOCOL_HEADER: PROTOCOL_VERSION}
226
+ if not replies:
227
+ return Response(status_code=202, headers=headers)
228
+ payload = replies[0] if not isinstance(body, list) else replies
229
+ return Response(content=json.dumps(payload), media_type="application/json", headers=headers)
230
+
231
+ @app.get("/mcp/{token}")
232
+ async def mcp_get(token: str):
233
+ return Response(status_code=405, content="no server-initiated stream")
234
+
235
+ @app.delete("/mcp/{token}")
236
+ async def mcp_delete(token: str, mcp_session_id: Optional[str] = Header(default=None, alias=SESSION_HEADER)):
237
+ if tokens.lookup(token) is None:
238
+ return Response(status_code=401)
239
+ with lock:
240
+ sessions.pop(mcp_session_id or "", None)
241
+ return Response(status_code=200)
242
+
243
+ app.state.sessions = sessions
244
+ return app
245
+
246
+
247
+ def mint_identity_token(principal: str, idp_signing_key: Any, idp_key_id: str, audience: str, days: int = 365) -> str:
248
+ """An identity token the issuer trusts for `principal`, signed by the
249
+ pinned IdP key `gargoyle init` wrote; the gate presents it in this
250
+ user's sessions."""
251
+ from datetime import datetime, timedelta, timezone
252
+ from gargoyle.jws import encode
253
+ exp = int((datetime.now(timezone.utc) + timedelta(days=days)).timestamp())
254
+ return encode({"sub": principal, "aud": audience, "exp": exp}, idp_signing_key, idp_key_id)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.8.1.dev0
3
+ Version: 0.10.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.8.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.10.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
@@ -235,7 +235,10 @@ undone the same way: the middleware keeps what a held write replaced and
235
235
  offers `gargoyle_revert`, which composes the restoring write and sends it
236
236
  through the gate, held, shown as a diff, bound to the current state, and
237
237
  refused if anything changed since. The audit marks a revert from the
238
- state hashes alone.
238
+ state hashes alone. Every entry also names the MCP client that asked, from
239
+ its initialize handshake, so a fleet with Claude on some desks and ChatGPT
240
+ on others still has one trail that says who, through what, did what. The
241
+ receipt itself stays model-agnostic.
239
242
 
240
243
  The MCP middleware also keeps an evidence log: one hash-chained line per
241
244
  decision, refusals and declined holds included, so the trail answers "what
@@ -289,6 +292,25 @@ and `gargoyle audit` verifies them later offline.
289
292
  and through the alternative with OPA in Docker for sites that run a policy
290
293
  engine.
291
294
 
295
+ ## Remote mode
296
+
297
+ Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
298
+ custom connectors) and Microsoft 365 servers are reached over HTTPS, not
299
+ through a local process. `gargoyle remote` runs the same gate as a
300
+ Streamable HTTP MCP server in front of a stdio command or a remote MCP
301
+ server (`--upstream-url`), so those paths go through it too:
302
+
303
+ gargoyle remote-token --out gargoyle-deploy --principal dana@contoso.com --url https://gargoyle.lan:8444
304
+ gargoyle remote --out gargoyle-deploy --port 8444 --ssl-certfile ... --ssl-keyfile ... -- python -m gargoyle.fs_server /srv/shared
305
+
306
+ Each person gets a capability URL (`/mcp/<token>`) that names them on
307
+ every receipt; the operator vouches for that mapping, as with an API key,
308
+ because a hosted connector cannot carry the user's own Entra token.
309
+ Treat the URL as a credential. All sessions share one upstream, one replay
310
+ cache, one evidence log and one snapshot store, so single use and undo
311
+ hold across users. Device-bound identity remains the stdio mode's
312
+ property.
313
+
292
314
  ## The shipped consumer
293
315
 
294
316
  **MCP middleware** (`python -m gargoyle.mcp_server -- <upstream command>`):
@@ -319,6 +341,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
319
341
  | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
320
342
  | `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 |
321
343
  | `GARGOYLE_MCP_STATE_PROBE` | `filesystem` (default) binds a held file write to the file's current state: the approver sees the change as a diff, the call refuses if the file moved before the run (`REF_VERIFY_STATE_MISMATCH`), and the execution receipt records pre and post state; `none` disables |
344
+ | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
322
345
  | `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 |
323
346
  | `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 |
324
347
 
@@ -25,6 +25,7 @@ gargoyle/opa.py
25
25
  gargoyle/pending_store.py
26
26
  gargoyle/policy.py
27
27
  gargoyle/published_record_source.py
28
+ gargoyle/remote.py
28
29
  gargoyle/replay_cache.py
29
30
  gargoyle/revert.py
30
31
  gargoyle/rules.py
@@ -61,6 +62,7 @@ tests/test_notify.py
61
62
  tests/test_opa.py
62
63
  tests/test_policy.py
63
64
  tests/test_published_record.py
65
+ tests/test_remote.py
64
66
  tests/test_revert.py
65
67
  tests/test_rules.py
66
68
  tests/test_state.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.8.1.dev0"
7
+ version = "0.10.0.dev0"
8
8
  description = "Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -186,3 +186,22 @@ def test_an_honored_held_call_reports_who_released_it(tmp_path):
186
186
  with contextlib.redirect_stdout(buf):
187
187
  assert main(["audit", "--log", str(log.path), "--out", str(out)]) == 0
188
188
  assert "CHAIN_VERIFIED released by sysops@contoso.com" in buf.getvalue()
189
+
190
+
191
+ def test_entries_name_the_client_and_older_versions_still_verify(tmp_path):
192
+ from gargoyle.evidence import EVIDENCE_VERSION, _well_formed
193
+ log = EvidenceLog(str(tmp_path / "e.jsonl"))
194
+ e = log.append(REFUSED, "delete_file", {}, PRINCIPAL, "DESTRUCTIVE_TOOL", client="Claude Desktop 1.4")
195
+ assert e["evidence_version"] == EVIDENCE_VERSION and e["client"] == "Claude Desktop 1.4"
196
+ anon = log.append(REFUSED, "delete_file", {}, PRINCIPAL, "DESTRUCTIVE_TOOL")
197
+ assert anon["client"] is None
198
+ v = verify_log(read_log(str(log.path)), **TRUST)
199
+ assert v["accepted"] and [r["client"] for r in v["entries"]] == ["Claude Desktop 1.4", None]
200
+ # A version-2 entry (no client member) is still well-formed; a version-3 entry without it is not.
201
+ v2 = {k: val for k, val in anon.items() if k != "client"}; v2["evidence_version"] = "gargoyle-evidence/2"
202
+ v2["entry_sha256"] = entry_sha256(v2)
203
+ assert _well_formed(v2)
204
+ v3_missing = {k: val for k, val in anon.items() if k != "client"}
205
+ assert not _well_formed(v3_missing)
206
+ with pytest.raises(ValueError):
207
+ build_entry(seq=1, prev_sha256=GENESIS, kind=REFUSED, tool="t", args={}, principal=PRINCIPAL, reason="x", client=7)
@@ -206,7 +206,8 @@ def test_every_decision_lands_in_the_evidence_log(tmp_path):
206
206
  state = middleware(client, sleep=human_declines, approval_timeout=60, attest=True)
207
207
  from gargoyle.evidence import EvidenceLog
208
208
  state.evidence = EvidenceLog(str(tmp_path / "e.jsonl"))
209
- handle_message(state, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
209
+ handle_message(state, {"jsonrpc": "2.0", "id": 1, "method": "initialize",
210
+ "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "ChatGPT", "version": "2.1"}}})
210
211
  handle_message(state, {"jsonrpc": "2.0", "method": "notifications/initialized"})
211
212
  ok = handle_message(state, {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": call("read_file", {"path": "a"})})
212
213
  no = handle_message(state, {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": call("delete_file", {"path": "a"})})
@@ -218,6 +219,7 @@ def test_every_decision_lands_in_the_evidence_log(tmp_path):
218
219
 
219
220
  entries = read_log(str(tmp_path / "e.jsonl"))
220
221
  assert [e["kind"] for e in entries] == [HONORED, REFUSED, REFUSED]
222
+ assert {e["client"] for e in entries} == {"ChatGPT 2.1"} # which assistant asked, every entry
221
223
  assert [e["seq"] for e in entries] == [1, 2, 3] and entries[2]["prev_sha256"] == entries[1]["entry_sha256"]
222
224
  assert entries[1]["receipt"] is None # policy said no: nothing was issued
223
225
  assert entries[2]["receipt"]["requires_approval"] is True # the human said no: the hold is on record
@@ -0,0 +1,183 @@
1
+ """Remote middleware mode: the same gate behind a Streamable HTTP endpoint,
2
+ one capability URL per person, one shared upstream, shared single use."""
3
+
4
+ import json
5
+ import sys
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
9
+ from fastapi import FastAPI, Request, Response
10
+ from fastapi.testclient import TestClient
11
+
12
+ from gargoyle.approval import build_grant, sign_grant
13
+ from gargoyle.issuer_service import build_issuer_app
14
+ from gargoyle.mcp_server import EXECUTED_META_KEY, REASON_META_KEY, StdioUpstream
15
+ from gargoyle.policy import ELIGIBLE, Decision, refuse
16
+ from gargoyle.remote import SESSION_HEADER, HttpUpstream, TokenMap, build_remote_app, mint_identity_token
17
+ from gargoyle.revert import REVERT_TOOL
18
+
19
+ issuer, executor, approver, idp = (Ed25519PrivateKey.generate() for _ in range(4))
20
+ PIN = {"policy_id": "p/1", "policy_sha256": "a" * 64}
21
+ TARGET = "mcp://fs"
22
+
23
+
24
+ class FsPolicy:
25
+ def pin(self): return PIN
26
+ def decide(self, target_url, interaction):
27
+ t = interaction.get("tool")
28
+ if t == "write_file": return Decision(ELIGIBLE, None, True)
29
+ if t == "delete_file": return refuse("DESTRUCTIVE_TOOL")
30
+ return Decision(ELIGIBLE)
31
+
32
+
33
+ def _issuer():
34
+ app = build_issuer_app(policy=FsPolicy(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()},
35
+ approver_public_keys={"approver-1": approver.public_key()}, audience="gargoyle-issuer")
36
+ c = TestClient(app)
37
+
38
+ class Shim:
39
+ def post(self, url, json=None, headers=None, timeout=None): return c.post(url.replace("http://i", ""), json=json, headers=headers)
40
+ def get(self, url, headers=None, timeout=None): return c.get(url.replace("http://i", ""), headers=headers)
41
+
42
+ def human(seconds):
43
+ tok = mint_identity_token("approver", idp, "idp-1", "gargoyle-issuer")
44
+ h = {"Authorization": "Bearer " + tok}
45
+ for hold in c.get("/pending", headers=h).json()["pending"]:
46
+ env = hold["envelope"]
47
+ g = sign_grant(build_grant(decision_sha256=env["decision_sha256"], approval_request_id=env["approval_request_id"],
48
+ grant_id="g-" + env["approval_request_id"], not_after=datetime.now(timezone.utc) + timedelta(minutes=5)),
49
+ approver, "approver-1")
50
+ c.post("/grants", json=g, headers=h)
51
+ return c, Shim(), human
52
+
53
+
54
+ CONFIG = {"pinned_public_keys": {"issuer-1": issuer.public_key()}, "record": PIN, "target_id": TARGET,
55
+ "approver_public_keys": {"approver-1": approver.public_key()}, "executor_signing_key": executor, "executor_key_id": "executor-1"}
56
+
57
+
58
+ def rpc(client, token, msg, sid=None):
59
+ h = {"Content-Type": "application/json"}
60
+ if sid: h[SESSION_HEADER] = sid
61
+ return client.post("/mcp/" + token, content=json.dumps(msg), headers=h)
62
+
63
+
64
+ def test_remote_gate_over_stdio_upstream(tmp_path):
65
+ root = tmp_path / "root"; root.mkdir(); (root / "n.md").write_text("one\n", encoding="utf-8")
66
+ _, shim, human = _issuer()
67
+ tokens = TokenMap(str(tmp_path / "remote-tokens.json"))
68
+ alice = tokens.mint("alice@contoso.com", mint_identity_token("alice@contoso.com", idp, "idp-1", "gargoyle-issuer"))
69
+ assert TokenMap(str(tmp_path / "remote-tokens.json")).lookup(alice)["principal"] == "alice@contoso.com"
70
+ up = StdioUpstream([sys.executable, "-I", "-m", "gargoyle.fs_server", str(root)])
71
+ try:
72
+ app = build_remote_app(CONFIG, tokens, upstream=up, issuer_url="http://i", issuer_http=shim, audit_log=str(tmp_path / "audit.jsonl"),
73
+ state_probe="filesystem", approval_timeout=30, poll_seconds=0.01, sleep=human)
74
+ c = TestClient(app)
75
+ # Unknown capability: nothing.
76
+ assert rpc(c, "nope", {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}).status_code == 401
77
+ # Initialize: a session id comes back, the upstream's capabilities with it.
78
+ r = rpc(c, alice, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}}})
79
+ assert r.status_code == 200 and r.headers[SESSION_HEADER]
80
+ sid = r.headers[SESSION_HEADER]
81
+ assert r.json()["result"]["serverInfo"]["name"].startswith("gargoyle+")
82
+ assert rpc(c, alice, {"jsonrpc": "2.0", "method": "notifications/initialized"}, sid).status_code == 202
83
+ # Tools: the upstream's plus undo.
84
+ tools = rpc(c, alice, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, sid).json()["result"]["tools"]
85
+ assert {"read_file", "write_file", REVERT_TOOL} <= {t["name"] for t in tools}
86
+ # A free read runs; a held write is approved by the human and runs; the receipt names alice.
87
+ r = rpc(c, alice, {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": str(root / "n.md")}}}, sid).json()
88
+ assert r["result"]["_meta"][EXECUTED_META_KEY] is True and r["result"]["content"][0]["text"] == "one\n"
89
+ r = rpc(c, alice, {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "write_file", "arguments": {"path": str(root / "n.md"), "content": "two\n"}}}, sid).json()
90
+ assert r["result"]["_meta"][EXECUTED_META_KEY] is True and (root / "n.md").read_text(encoding="utf-8") == "two\n"
91
+ assert r["result"]["_meta"]["gargoyle/receipt"]["request_context"]["principal"] == "alice@contoso.com"
92
+ assert "pre_state_sha256" in r["result"]["_meta"]["gargoyle/execution"]
93
+ # A refusal is a refusal.
94
+ r = rpc(c, alice, {"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "delete_file", "arguments": {"path": str(root / "n.md")}}}, sid).json()
95
+ assert r["result"]["isError"] and r["result"]["_meta"][REASON_META_KEY] == "DESTRUCTIVE_TOOL"
96
+ # A second person, a second session, the same upstream and the same evidence log.
97
+ bob = tokens.mint("bob@contoso.com", mint_identity_token("bob@contoso.com", idp, "idp-1", "gargoyle-issuer"))
98
+ r = rpc(c, bob, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}); sid2 = r.headers[SESSION_HEADER]
99
+ rpc(c, bob, {"jsonrpc": "2.0", "method": "notifications/initialized"}, sid2)
100
+ r = rpc(c, bob, {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": str(root / "n.md")}}}, sid2).json()
101
+ assert r["result"]["_meta"]["gargoyle/receipt"]["request_context"]["principal"] == "bob@contoso.com"
102
+ lines = [json.loads(l) for l in (tmp_path / "audit.jsonl").read_text(encoding="utf-8").splitlines()]
103
+ assert [e["principal"] for e in lines] == ["alice@contoso.com"] * 3 + ["bob@contoso.com"] and [e["seq"] for e in lines] == [1, 2, 3, 4]
104
+ assert [e["client"] for e in lines] == ["t 0"] * 3 + [None] # alice's client named itself; bob's did not
105
+ # No session: told to initialize; DELETE ends one.
106
+ assert rpc(c, alice, {"jsonrpc": "2.0", "id": 9, "method": "tools/list", "params": {}}, "stale").status_code == 404
107
+ assert c.delete("/mcp/" + alice, headers={SESSION_HEADER: sid}).status_code == 200
108
+ assert rpc(c, alice, {"jsonrpc": "2.0", "id": 9, "method": "tools/list", "params": {}}, sid).status_code == 404
109
+ finally:
110
+ up.close()
111
+
112
+
113
+ def test_http_upstream_speaks_streamable_http(tmp_path):
114
+ """The gate fronting a remote MCP server: JSON and SSE answers, session id carried."""
115
+ fake = FastAPI(); calls = []
116
+
117
+ @fake.post("/mcp")
118
+ async def mcp(request: Request):
119
+ msg = await request.json(); calls.append((msg, dict(request.headers)))
120
+ if "id" not in msg:
121
+ return Response(status_code=202)
122
+ if msg["method"] == "initialize":
123
+ return Response(content=json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "graph", "version": "1"}}}),
124
+ media_type="application/json", headers={SESSION_HEADER: "up-1"})
125
+ if msg["method"] == "tools/list":
126
+ body = "event: message\ndata: " + json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"tools": [{"name": "send_mail"}]}}) + "\n\n"
127
+ return Response(content=body, media_type="text/event-stream")
128
+ return Response(content=json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"content": [{"type": "text", "text": "sent"}]}}), media_type="application/json")
129
+
130
+ fc = TestClient(fake)
131
+
132
+ class Shim:
133
+ def post(self, url, data=None, headers=None, timeout=None): return fc.post("/mcp", content=data, headers=headers)
134
+ def delete(self, url, headers=None, timeout=None): return fc.delete("/mcp", headers=headers)
135
+
136
+ up = HttpUpstream("https://graph.example/mcp", "secret", http=Shim())
137
+ init = up.send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
138
+ assert init["result"]["serverInfo"]["name"] == "graph" and calls[0][1]["authorization"] == "Bearer secret"
139
+ assert up.send({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None
140
+ assert calls[-1][1][SESSION_HEADER.lower()] == "up-1" # session carried after initialize
141
+ assert up.send({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})["result"]["tools"][0]["name"] == "send_mail"
142
+ assert up.send({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "send_mail", "arguments": {}}})["result"]["content"][0]["text"] == "sent"
143
+
144
+ # Behind the gate, with no state probe (the server is remote): free calls relay, policy still decides.
145
+ class Allow:
146
+ def pin(self): return PIN
147
+ def decide(self, target_url, interaction): return Decision(ELIGIBLE)
148
+ app = build_issuer_app(policy=Allow(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()}, audience="gargoyle-issuer")
149
+ ic = TestClient(app)
150
+
151
+ class IShim:
152
+ def post(self, url, json=None, headers=None, timeout=None): return ic.post(url.replace("http://i", ""), json=json, headers=headers)
153
+ def get(self, url, headers=None, timeout=None): return ic.get(url.replace("http://i", ""), headers=headers)
154
+ tokens = TokenMap(entries={"t1": {"principal": "carol@contoso.com", "issuer_token": mint_identity_token("carol@contoso.com", idp, "idp-1", "gargoyle-issuer")}})
155
+ gate = TestClient(build_remote_app(dict(CONFIG, target_id="mcp://graph"), tokens, upstream=up, issuer_url="http://i", issuer_http=IShim(), state_probe="none"))
156
+ r = rpc(gate, "t1", {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}); sid = r.headers[SESSION_HEADER]
157
+ rpc(gate, "t1", {"jsonrpc": "2.0", "method": "notifications/initialized"}, sid)
158
+ r = rpc(gate, "t1", {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "send_mail", "arguments": {"to": "x"}}}, sid).json()
159
+ assert r["result"]["_meta"][EXECUTED_META_KEY] is True and r["result"]["content"][0]["text"] == "sent"
160
+ assert r["result"]["_meta"]["gargoyle/receipt"]["request_context"]["principal"] == "carol@contoso.com"
161
+
162
+
163
+ def test_remote_token_command_mints_a_capability_url(tmp_path, capsys):
164
+ from gargoyle.cli import main, _read_env
165
+ out = tmp_path / "deploy"
166
+ assert main(["init", str(tmp_path / "p"), "--out", str(out), "--issuer-url", "https://gargoyle.lan"]) == 0
167
+ assert "GARGOYLE_IDP_KEY_HEX" in _read_env(out / "issuer.env") # kept, so tokens can be minted later
168
+ assert main(["remote-token", "--out", str(out), "--principal", "dana@contoso.com", "--url", "https://gargoyle.lan:8444"]) == 0
169
+ text = capsys.readouterr().out
170
+ assert "https://gargoyle.lan:8444/mcp/" in text
171
+ tokens = json.loads((out / "remote-tokens.json").read_text())["tokens"]
172
+ (cap, entry), = tokens.items()
173
+ assert entry["principal"] == "dana@contoso.com" and text.rstrip().endswith(cap)
174
+ # The minted identity token is one the deployment's issuer accepts.
175
+ from gargoyle.issuer_service import app_from_env
176
+ import os
177
+ env = _read_env(out / "issuer.env"); os.environ.update(env)
178
+ try:
179
+ c = TestClient(app_from_env())
180
+ r = c.post("/issue", json={"target_url": "mcp://x", "tool": "read_file", "args": {}}, headers={"Authorization": "Bearer " + entry["issuer_token"]})
181
+ assert r.status_code == 200 and r.json()["envelope"]["request_context"]["principal"] == "dana@contoso.com"
182
+ finally:
183
+ for k in env: os.environ.pop(k, None)