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