gargoyle-receipts 0.8.1.dev0__tar.gz → 0.9.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.9.0.dev0}/PKG-INFO +22 -2
  2. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/README.md +21 -1
  3. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/cli.py +82 -1
  4. gargoyle_receipts-0.9.0.dev0/gargoyle/remote.py +252 -0
  5. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +22 -2
  6. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
  7. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/pyproject.toml +1 -1
  8. gargoyle_receipts-0.9.0.dev0/tests/test_remote.py +182 -0
  9. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/LICENSE +0 -0
  10. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/NOTICE +0 -0
  11. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/__init__.py +0 -0
  12. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/approval.py +0 -0
  13. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/approver_cli.py +0 -0
  14. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/approver_trust.py +0 -0
  15. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/approvers.py +0 -0
  16. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/consumer.py +0 -0
  17. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/entra_login.py +0 -0
  18. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/envelope.py +0 -0
  19. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/evidence.py +0 -0
  20. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/execution.py +0 -0
  21. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/fs_server.py +0 -0
  22. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/identity.py +0 -0
  23. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/issuer.py +0 -0
  24. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/issuer_service.py +0 -0
  25. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/jws.py +0 -0
  26. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/key_record_source.py +0 -0
  27. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/mcp_server.py +0 -0
  28. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/notify.py +0 -0
  29. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/opa.py +0 -0
  30. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/pending_store.py +0 -0
  31. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/policy.py +0 -0
  32. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/published_record_source.py +0 -0
  33. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/replay_cache.py +0 -0
  34. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/revert.py +0 -0
  35. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/rules.py +0 -0
  36. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/state.py +0 -0
  37. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/static/approve.html +0 -0
  38. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/static/approve.js +0 -0
  39. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/verifier.py +0 -0
  40. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  41. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  42. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  43. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  44. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/setup.cfg +0 -0
  45. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_annotations.py +0 -0
  46. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_approver_cli.py +0 -0
  47. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_approvers.py +0 -0
  48. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_canonical.py +0 -0
  49. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_cli.py +0 -0
  50. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_console_js.py +0 -0
  51. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_consumer.py +0 -0
  52. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_entra_identity.py +0 -0
  53. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_entra_login.py +0 -0
  54. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_evidence.py +0 -0
  55. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_execution.py +0 -0
  56. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_fs_server.py +0 -0
  57. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_historical.py +0 -0
  58. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_identity.py +0 -0
  59. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_issuer_service.py +0 -0
  60. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_jws.py +0 -0
  61. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_mcp_proxy.py +0 -0
  62. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  63. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_mcp_server.py +0 -0
  64. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_notify.py +0 -0
  65. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_opa.py +0 -0
  66. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_policy.py +0 -0
  67. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_published_record.py +0 -0
  68. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_revert.py +0 -0
  69. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_rules.py +0 -0
  70. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_state.py +0 -0
  71. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_stdio_encoding.py +0 -0
  72. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_threshold.py +0 -0
  73. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_typescript_conformance.py +0 -0
  74. {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.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.9.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.9.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
@@ -289,6 +289,25 @@ and `gargoyle audit` verifies them later offline.
289
289
  and through the alternative with OPA in Docker for sites that run a policy
290
290
  engine.
291
291
 
292
+ ## Remote mode
293
+
294
+ Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
295
+ custom connectors) and Microsoft 365 servers are reached over HTTPS, not
296
+ through a local process. `gargoyle remote` runs the same gate as a
297
+ Streamable HTTP MCP server in front of a stdio command or a remote MCP
298
+ server (`--upstream-url`), so those paths go through it too:
299
+
300
+ gargoyle remote-token --out gargoyle-deploy --principal dana@contoso.com --url https://gargoyle.lan:8444
301
+ gargoyle remote --out gargoyle-deploy --port 8444 --ssl-certfile ... --ssl-keyfile ... -- python -m gargoyle.fs_server /srv/shared
302
+
303
+ Each person gets a capability URL (`/mcp/<token>`) that names them on
304
+ every receipt; the operator vouches for that mapping, as with an API key,
305
+ because a hosted connector cannot carry the user's own Entra token.
306
+ Treat the URL as a credential. All sessions share one upstream, one replay
307
+ cache, one evidence log and one snapshot store, so single use and undo
308
+ hold across users. Device-bound identity remains the stdio mode's
309
+ property.
310
+
292
311
  ## The shipped consumer
293
312
 
294
313
  **MCP middleware** (`python -m gargoyle.mcp_server -- <upstream command>`):
@@ -319,6 +338,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
319
338
  | 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
339
  | `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
340
  | `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 |
341
+ | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
322
342
  | `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
343
  | `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
344
 
@@ -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.9.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
@@ -251,6 +251,25 @@ and `gargoyle audit` verifies them later offline.
251
251
  and through the alternative with OPA in Docker for sites that run a policy
252
252
  engine.
253
253
 
254
+ ## Remote mode
255
+
256
+ Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
257
+ custom connectors) and Microsoft 365 servers are reached over HTTPS, not
258
+ through a local process. `gargoyle remote` runs the same gate as a
259
+ Streamable HTTP MCP server in front of a stdio command or a remote MCP
260
+ server (`--upstream-url`), so those paths go through it too:
261
+
262
+ gargoyle remote-token --out gargoyle-deploy --principal dana@contoso.com --url https://gargoyle.lan:8444
263
+ gargoyle remote --out gargoyle-deploy --port 8444 --ssl-certfile ... --ssl-keyfile ... -- python -m gargoyle.fs_server /srv/shared
264
+
265
+ Each person gets a capability URL (`/mcp/<token>`) that names them on
266
+ every receipt; the operator vouches for that mapping, as with an API key,
267
+ because a hosted connector cannot carry the user's own Entra token.
268
+ Treat the URL as a credential. All sessions share one upstream, one replay
269
+ cache, one evidence log and one snapshot store, so single use and undo
270
+ hold across users. Device-bound identity remains the stdio mode's
271
+ property.
272
+
254
273
  ## The shipped consumer
255
274
 
256
275
  **MCP middleware** (`python -m gargoyle.mcp_server -- <upstream command>`):
@@ -281,6 +300,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
281
300
  | 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
301
  | `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
302
  | `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 |
303
+ | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
284
304
  | `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
305
  | `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
306
 
@@ -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
@@ -303,6 +367,23 @@ def main(argv=None) -> int:
303
367
  p.add_argument("--out", default="gargoyle-deploy")
304
368
  p.add_argument("--watch", type=float, metavar="SECONDS"); p.add_argument("--yes", action="store_true")
305
369
  p.set_defaults(fn=cmd_approve)
370
+ p = sub.add_parser("remote", help="run the gate as an HTTPS MCP server for hosted connectors")
371
+ p.add_argument("--out", default="gargoyle-deploy")
372
+ p.add_argument("--host", default="127.0.0.1"); p.add_argument("--port", type=int, default=8444)
373
+ p.add_argument("--ssl-certfile"); p.add_argument("--ssl-keyfile")
374
+ p.add_argument("--upstream-url", help="front a remote MCP server (Streamable HTTP)")
375
+ p.add_argument("--upstream-token", help="bearer for that server (or GARGOYLE_MCP_UPSTREAM_TOKEN)")
376
+ p.add_argument("--state-probe", default="none", choices=["none", "filesystem"],
377
+ help="filesystem only when the upstream is a local filesystem server on this host")
378
+ p.add_argument("--verbose", action="store_true")
379
+ p.add_argument("rest", nargs=argparse.REMAINDER, help="-- <command> for a stdio server to front")
380
+ p.set_defaults(fn=cmd_remote)
381
+ p = sub.add_parser("remote-token", help="mint one person's capability URL for the remote gate")
382
+ p.add_argument("--out", default="gargoyle-deploy")
383
+ p.add_argument("--principal", required=True, help="the UPN or principal the receipts will name")
384
+ p.add_argument("--days", type=int, default=365)
385
+ p.add_argument("--url", help="the gate's public base URL for the printed connector URL")
386
+ p.set_defaults(fn=cmd_remote_token)
306
387
  p = sub.add_parser("audit", help="verify a receipt chain offline, or a whole evidence log")
307
388
  p.add_argument("receipt", nargs="?"); p.add_argument("execution", nargs="?"); p.add_argument("grant", nargs="?")
308
389
  p.add_argument("--log", help="the middleware's audit.jsonl: verify every entry")
@@ -0,0 +1,252 @@
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
+ return {"jsonrpc": "2.0", "id": msg.get("id"), "result": st.upstream_init} # type: ignore[attr-defined]
198
+ if method == "notifications/initialized":
199
+ st.initialized = True
200
+ return None
201
+ return handle_message(st, msg)
202
+
203
+ @app.post("/mcp/{token}")
204
+ async def mcp_post(token: str, request: Request,
205
+ mcp_session_id: Optional[str] = Header(default=None, alias=SESSION_HEADER)):
206
+ who = tokens.lookup(token)
207
+ if who is None:
208
+ return Response(status_code=401, content="unknown token")
209
+ try:
210
+ body = await request.json()
211
+ except Exception:
212
+ return Response(status_code=400, content="not JSON")
213
+ messages: List[Any] = body if isinstance(body, list) else [body]
214
+ is_init = any(isinstance(m, dict) and m.get("method") == "initialize" for m in messages)
215
+ with lock:
216
+ if is_init or not mcp_session_id or mcp_session_id not in sessions:
217
+ if not is_init:
218
+ return Response(status_code=404, content="no such session; initialize first")
219
+ mcp_session_id = uuid.uuid4().hex
220
+ sessions[mcp_session_id] = new_state(who["principal"], who["issuer_token"])
221
+ st = sessions[mcp_session_id]
222
+ replies = [r for r in (handle_one(st, m) for m in messages if isinstance(m, dict)) if r is not None]
223
+ headers = {SESSION_HEADER: mcp_session_id, PROTOCOL_HEADER: PROTOCOL_VERSION}
224
+ if not replies:
225
+ return Response(status_code=202, headers=headers)
226
+ payload = replies[0] if not isinstance(body, list) else replies
227
+ return Response(content=json.dumps(payload), media_type="application/json", headers=headers)
228
+
229
+ @app.get("/mcp/{token}")
230
+ async def mcp_get(token: str):
231
+ return Response(status_code=405, content="no server-initiated stream")
232
+
233
+ @app.delete("/mcp/{token}")
234
+ async def mcp_delete(token: str, mcp_session_id: Optional[str] = Header(default=None, alias=SESSION_HEADER)):
235
+ if tokens.lookup(token) is None:
236
+ return Response(status_code=401)
237
+ with lock:
238
+ sessions.pop(mcp_session_id or "", None)
239
+ return Response(status_code=200)
240
+
241
+ app.state.sessions = sessions
242
+ return app
243
+
244
+
245
+ def mint_identity_token(principal: str, idp_signing_key: Any, idp_key_id: str, audience: str, days: int = 365) -> str:
246
+ """An identity token the issuer trusts for `principal`, signed by the
247
+ pinned IdP key `gargoyle init` wrote; the gate presents it in this
248
+ user's sessions."""
249
+ from datetime import datetime, timedelta, timezone
250
+ from gargoyle.jws import encode
251
+ exp = int((datetime.now(timezone.utc) + timedelta(days=days)).timestamp())
252
+ 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.9.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.9.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
@@ -289,6 +289,25 @@ and `gargoyle audit` verifies them later offline.
289
289
  and through the alternative with OPA in Docker for sites that run a policy
290
290
  engine.
291
291
 
292
+ ## Remote mode
293
+
294
+ Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
295
+ custom connectors) and Microsoft 365 servers are reached over HTTPS, not
296
+ through a local process. `gargoyle remote` runs the same gate as a
297
+ Streamable HTTP MCP server in front of a stdio command or a remote MCP
298
+ server (`--upstream-url`), so those paths go through it too:
299
+
300
+ gargoyle remote-token --out gargoyle-deploy --principal dana@contoso.com --url https://gargoyle.lan:8444
301
+ gargoyle remote --out gargoyle-deploy --port 8444 --ssl-certfile ... --ssl-keyfile ... -- python -m gargoyle.fs_server /srv/shared
302
+
303
+ Each person gets a capability URL (`/mcp/<token>`) that names them on
304
+ every receipt; the operator vouches for that mapping, as with an API key,
305
+ because a hosted connector cannot carry the user's own Entra token.
306
+ Treat the URL as a credential. All sessions share one upstream, one replay
307
+ cache, one evidence log and one snapshot store, so single use and undo
308
+ hold across users. Device-bound identity remains the stdio mode's
309
+ property.
310
+
292
311
  ## The shipped consumer
293
312
 
294
313
  **MCP middleware** (`python -m gargoyle.mcp_server -- <upstream command>`):
@@ -319,6 +338,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
319
338
  | 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
339
  | `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
340
  | `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 |
341
+ | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
322
342
  | `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
343
  | `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
344
 
@@ -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.9.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"
@@ -0,0 +1,182 @@
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
+ # No session: told to initialize; DELETE ends one.
105
+ assert rpc(c, alice, {"jsonrpc": "2.0", "id": 9, "method": "tools/list", "params": {}}, "stale").status_code == 404
106
+ assert c.delete("/mcp/" + alice, headers={SESSION_HEADER: sid}).status_code == 200
107
+ assert rpc(c, alice, {"jsonrpc": "2.0", "id": 9, "method": "tools/list", "params": {}}, sid).status_code == 404
108
+ finally:
109
+ up.close()
110
+
111
+
112
+ def test_http_upstream_speaks_streamable_http(tmp_path):
113
+ """The gate fronting a remote MCP server: JSON and SSE answers, session id carried."""
114
+ fake = FastAPI(); calls = []
115
+
116
+ @fake.post("/mcp")
117
+ async def mcp(request: Request):
118
+ msg = await request.json(); calls.append((msg, dict(request.headers)))
119
+ if "id" not in msg:
120
+ return Response(status_code=202)
121
+ if msg["method"] == "initialize":
122
+ return Response(content=json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "graph", "version": "1"}}}),
123
+ media_type="application/json", headers={SESSION_HEADER: "up-1"})
124
+ if msg["method"] == "tools/list":
125
+ body = "event: message\ndata: " + json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"tools": [{"name": "send_mail"}]}}) + "\n\n"
126
+ return Response(content=body, media_type="text/event-stream")
127
+ return Response(content=json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"content": [{"type": "text", "text": "sent"}]}}), media_type="application/json")
128
+
129
+ fc = TestClient(fake)
130
+
131
+ class Shim:
132
+ def post(self, url, data=None, headers=None, timeout=None): return fc.post("/mcp", content=data, headers=headers)
133
+ def delete(self, url, headers=None, timeout=None): return fc.delete("/mcp", headers=headers)
134
+
135
+ up = HttpUpstream("https://graph.example/mcp", "secret", http=Shim())
136
+ init = up.send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
137
+ assert init["result"]["serverInfo"]["name"] == "graph" and calls[0][1]["authorization"] == "Bearer secret"
138
+ assert up.send({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None
139
+ assert calls[-1][1][SESSION_HEADER.lower()] == "up-1" # session carried after initialize
140
+ assert up.send({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})["result"]["tools"][0]["name"] == "send_mail"
141
+ assert up.send({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "send_mail", "arguments": {}}})["result"]["content"][0]["text"] == "sent"
142
+
143
+ # Behind the gate, with no state probe (the server is remote): free calls relay, policy still decides.
144
+ class Allow:
145
+ def pin(self): return PIN
146
+ def decide(self, target_url, interaction): return Decision(ELIGIBLE)
147
+ app = build_issuer_app(policy=Allow(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()}, audience="gargoyle-issuer")
148
+ ic = TestClient(app)
149
+
150
+ class IShim:
151
+ def post(self, url, json=None, headers=None, timeout=None): return ic.post(url.replace("http://i", ""), json=json, headers=headers)
152
+ def get(self, url, headers=None, timeout=None): return ic.get(url.replace("http://i", ""), headers=headers)
153
+ tokens = TokenMap(entries={"t1": {"principal": "carol@contoso.com", "issuer_token": mint_identity_token("carol@contoso.com", idp, "idp-1", "gargoyle-issuer")}})
154
+ gate = TestClient(build_remote_app(dict(CONFIG, target_id="mcp://graph"), tokens, upstream=up, issuer_url="http://i", issuer_http=IShim(), state_probe="none"))
155
+ r = rpc(gate, "t1", {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}); sid = r.headers[SESSION_HEADER]
156
+ rpc(gate, "t1", {"jsonrpc": "2.0", "method": "notifications/initialized"}, sid)
157
+ r = rpc(gate, "t1", {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "send_mail", "arguments": {"to": "x"}}}, sid).json()
158
+ assert r["result"]["_meta"][EXECUTED_META_KEY] is True and r["result"]["content"][0]["text"] == "sent"
159
+ assert r["result"]["_meta"]["gargoyle/receipt"]["request_context"]["principal"] == "carol@contoso.com"
160
+
161
+
162
+ def test_remote_token_command_mints_a_capability_url(tmp_path, capsys):
163
+ from gargoyle.cli import main, _read_env
164
+ out = tmp_path / "deploy"
165
+ assert main(["init", str(tmp_path / "p"), "--out", str(out), "--issuer-url", "https://gargoyle.lan"]) == 0
166
+ assert "GARGOYLE_IDP_KEY_HEX" in _read_env(out / "issuer.env") # kept, so tokens can be minted later
167
+ assert main(["remote-token", "--out", str(out), "--principal", "dana@contoso.com", "--url", "https://gargoyle.lan:8444"]) == 0
168
+ text = capsys.readouterr().out
169
+ assert "https://gargoyle.lan:8444/mcp/" in text
170
+ tokens = json.loads((out / "remote-tokens.json").read_text())["tokens"]
171
+ (cap, entry), = tokens.items()
172
+ assert entry["principal"] == "dana@contoso.com" and text.rstrip().endswith(cap)
173
+ # The minted identity token is one the deployment's issuer accepts.
174
+ from gargoyle.issuer_service import app_from_env
175
+ import os
176
+ env = _read_env(out / "issuer.env"); os.environ.update(env)
177
+ try:
178
+ c = TestClient(app_from_env())
179
+ r = c.post("/issue", json={"target_url": "mcp://x", "tool": "read_file", "args": {}}, headers={"Authorization": "Bearer " + entry["issuer_token"]})
180
+ assert r.status_code == 200 and r.json()["envelope"]["request_context"]["principal"] == "dana@contoso.com"
181
+ finally:
182
+ for k in env: os.environ.pop(k, None)