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.
- {gargoyle_receipts-0.8.1.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.9.0.dev0}/PKG-INFO +22 -2
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/README.md +21 -1
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/cli.py +82 -1
- gargoyle_receipts-0.9.0.dev0/gargoyle/remote.py +252 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +22 -2
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/pyproject.toml +1 -1
- gargoyle_receipts-0.9.0.dev0/tests/test_remote.py +182 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/evidence.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/issuer_service.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/mcp_server.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.8.1.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.9.0.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
23
23
|
|
|
24
|
-
Status: pre-release (`0.
|
|
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)
|
{gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
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
|
|
{gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt
RENAMED
|
@@ -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.
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/gargoyle/published_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.8.1.dev0 → gargoyle_receipts-0.9.0.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|