gargoyle-receipts 0.9.0.dev0__tar.gz → 0.11.0.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {gargoyle_receipts-0.9.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.11.0.dev0}/PKG-INFO +23 -3
  2. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/README.md +22 -2
  3. gargoyle_receipts-0.11.0.dev0/gargoyle/adapters.py +279 -0
  4. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/cli.py +6 -2
  5. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/evidence.py +21 -8
  6. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/mcp_server.py +39 -22
  7. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/remote.py +4 -1
  8. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/revert.py +32 -23
  9. gargoyle_receipts-0.11.0.dev0/gargoyle/specs/graph-reference.json +56 -0
  10. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/state.py +3 -1
  11. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/static/approve.html +4 -1
  12. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +23 -3
  13. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +3 -0
  14. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/pyproject.toml +2 -2
  15. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_evidence.py +19 -0
  16. gargoyle_receipts-0.11.0.dev0/tests/test_graph.py +165 -0
  17. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_mcp_self_issue.py +3 -1
  18. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_remote.py +1 -0
  19. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_revert.py +8 -7
  20. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/LICENSE +0 -0
  21. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/NOTICE +0 -0
  22. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/__init__.py +0 -0
  23. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approval.py +0 -0
  24. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approver_cli.py +0 -0
  25. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approver_trust.py +0 -0
  26. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approvers.py +0 -0
  27. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/consumer.py +0 -0
  28. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/entra_login.py +0 -0
  29. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/envelope.py +0 -0
  30. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/execution.py +0 -0
  31. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/fs_server.py +0 -0
  32. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/identity.py +0 -0
  33. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/issuer.py +0 -0
  34. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/issuer_service.py +0 -0
  35. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/jws.py +0 -0
  36. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/key_record_source.py +0 -0
  37. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/notify.py +0 -0
  38. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/opa.py +0 -0
  39. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/pending_store.py +0 -0
  40. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/policy.py +0 -0
  41. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/published_record_source.py +0 -0
  42. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/replay_cache.py +0 -0
  43. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/rules.py +0 -0
  44. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/static/approve.js +0 -0
  45. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/verifier.py +0 -0
  46. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  47. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  48. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  49. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  50. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/setup.cfg +0 -0
  51. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_annotations.py +0 -0
  52. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_approver_cli.py +0 -0
  53. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_approvers.py +0 -0
  54. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_canonical.py +0 -0
  55. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_cli.py +0 -0
  56. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_console_js.py +0 -0
  57. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_consumer.py +0 -0
  58. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_entra_identity.py +0 -0
  59. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_entra_login.py +0 -0
  60. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_execution.py +0 -0
  61. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_fs_server.py +0 -0
  62. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_historical.py +0 -0
  63. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_identity.py +0 -0
  64. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_issuer_service.py +0 -0
  65. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_jws.py +0 -0
  66. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_mcp_proxy.py +0 -0
  67. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_mcp_server.py +0 -0
  68. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_notify.py +0 -0
  69. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_opa.py +0 -0
  70. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_policy.py +0 -0
  71. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_published_record.py +0 -0
  72. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_rules.py +0 -0
  73. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_state.py +0 -0
  74. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_stdio_encoding.py +0 -0
  75. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_threshold.py +0 -0
  76. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_typescript_conformance.py +0 -0
  77. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_wire_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.9.0.dev0
3
+ Version: 0.11.0.dev0
4
4
  Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
5
5
  Author: Justin LaPorte
6
6
  License-Expression: Apache-2.0
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
59
59
 
60
60
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.9.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.11.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,22 @@ 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
+ ## Microsoft 365 and other targets: adapters
296
+
297
+ What a held call changes is the adapter's business (`gargoyle.adapters`):
298
+ it describes the target's state before and after, tells the approver what
299
+ the call means in plain words, and composes the call that undoes it. Files
300
+ on the executor's host are the default. `GARGOYLE_MCP_STATE_PROBE=graph`
301
+ selects the Graph adapter for a Microsoft Graph MCP server: inbox rules,
302
+ calendar events, contacts and Teams membership are bound to their state,
303
+ explained on the card ("creates inbox rule 'Fwd invoices' (to an outside
304
+ address: x@evil.example)"), undone by the inverse Graph call, and a send
305
+ is marked as something that cannot be undone. The adapter reads state
306
+ through the upstream server itself, so the gate needs no Graph credential
307
+ of its own. Which tool is which is a JSON spec; the reference naming is in
308
+ `gargoyle/specs/graph-reference.json`, and a different server's names are
309
+ mapped there, not in code.
310
+
292
311
  ## Remote mode
293
312
 
294
313
  Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
@@ -338,6 +357,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
338
357
  | 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 |
339
358
  | `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 |
340
359
  | `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 |
360
+ | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
341
361
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
342
362
  | `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 |
343
363
  | `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 |
@@ -21,7 +21,7 @@ your MCP server and lets through only what both agreed to.
21
21
 
22
22
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.9.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.11.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,22 @@ 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
+ ## Microsoft 365 and other targets: adapters
258
+
259
+ What a held call changes is the adapter's business (`gargoyle.adapters`):
260
+ it describes the target's state before and after, tells the approver what
261
+ the call means in plain words, and composes the call that undoes it. Files
262
+ on the executor's host are the default. `GARGOYLE_MCP_STATE_PROBE=graph`
263
+ selects the Graph adapter for a Microsoft Graph MCP server: inbox rules,
264
+ calendar events, contacts and Teams membership are bound to their state,
265
+ explained on the card ("creates inbox rule 'Fwd invoices' (to an outside
266
+ address: x@evil.example)"), undone by the inverse Graph call, and a send
267
+ is marked as something that cannot be undone. The adapter reads state
268
+ through the upstream server itself, so the gate needs no Graph credential
269
+ of its own. Which tool is which is a JSON spec; the reference naming is in
270
+ `gargoyle/specs/graph-reference.json`, and a different server's names are
271
+ mapped there, not in code.
272
+
254
273
  ## Remote mode
255
274
 
256
275
  Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
@@ -300,6 +319,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
300
319
  | 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 |
301
320
  | `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 |
302
321
  | `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 |
322
+ | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
303
323
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
304
324
  | `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 |
305
325
  | `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 |
@@ -0,0 +1,279 @@
1
+ """Adapters: what a held call changes, in a form the gate can bind, show,
2
+ and undo. The gate itself knows only tool names and arguments. An adapter
3
+ knows the target: it describes the target's state before and after a call
4
+ (gargoyle.state), tells the approver what the call means, and composes the
5
+ call that restores the earlier state (gargoyle.revert).
6
+
7
+ probe(tool, args) -> (description, bytes) | None
8
+ description: {"ref", "sha256", "size", "text"?, "summary"?, "irreversible"?}
9
+ bytes: the state's canonical bytes (the hash's preimage), kept as the
10
+ snapshot; None when the target does not exist
11
+ restore(meta, pre_bytes, post_bytes) -> {"params": {"name", "arguments"}} | {"reason": REF_REVERT_*}
12
+ the inverse call, given the sidecar and the two snapshots
13
+
14
+ FilesystemAdapter: a tool with a path changes that file. GraphAdapter: a
15
+ Microsoft Graph MCP server's tools change mail rules, events, contacts,
16
+ Teams objects; which tool is which is a JSON spec (gargoyle/specs/
17
+ graph-reference.json is the reference naming; map your server's names).
18
+ The Graph adapter reads state through the upstream server itself, with
19
+ the same credential the write will use, so the gate needs no Graph access
20
+ of its own.
21
+ """
22
+
23
+ import hashlib
24
+ import json
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+
28
+ from gargoyle.envelope import canonical_json
29
+ from gargoyle.state import describe, filesystem_probe, read_target
30
+
31
+ REF_REVERT_UNKNOWN = "REF_REVERT_UNKNOWN"
32
+ REF_REVERT_NEEDS_DELETE = "REF_REVERT_NEEDS_DELETE"
33
+ REF_REVERT_NOT_TEXT = "REF_REVERT_NOT_TEXT"
34
+ REF_REVERT_IRREVERSIBLE = "REF_REVERT_IRREVERSIBLE"
35
+ REF_REVERT_UNSUPPORTED = "REF_REVERT_UNSUPPORTED"
36
+
37
+ Probe = Optional[Tuple[Dict[str, Any], Optional[bytes]]]
38
+
39
+
40
+ class FilesystemAdapter:
41
+ """Files on the executor's host (the stdio middleware's own case)."""
42
+
43
+ name = "filesystem"
44
+
45
+ def probe(self, tool: str, args: Any) -> Probe:
46
+ desc = filesystem_probe(tool, args)
47
+ if desc is None:
48
+ return None
49
+ return desc, read_target(desc)
50
+
51
+ def restore(self, meta: Dict[str, Any], pre: Optional[bytes], post: Optional[bytes]) -> Dict[str, Any]:
52
+ if pre is None:
53
+ return {"reason": REF_REVERT_NEEDS_DELETE} # undoing a create is a delete, which the gate refuses
54
+ try:
55
+ text = pre.decode("utf-8")
56
+ except UnicodeDecodeError:
57
+ return {"reason": REF_REVERT_NOT_TEXT}
58
+ return {"params": {"name": "write_file", "arguments": {"path": meta["ref"], "content": text}}}
59
+
60
+
61
+ def _tool_json(reply: Any) -> Any:
62
+ """The JSON a Graph MCP tool returned, from its text content."""
63
+ if not isinstance(reply, dict) or "result" not in reply:
64
+ return None
65
+ result = reply["result"]
66
+ if isinstance(result, dict) and result.get("isError"):
67
+ return None
68
+ if isinstance(result, dict) and isinstance(result.get("structuredContent"), (dict, list)):
69
+ return result["structuredContent"]
70
+ for item in (result.get("content") or []) if isinstance(result, dict) else []:
71
+ if isinstance(item, dict) and item.get("type") == "text":
72
+ try:
73
+ return json.loads(item["text"])
74
+ except (ValueError, TypeError):
75
+ return None
76
+ return None
77
+
78
+
79
+ class GraphAdapter:
80
+ """A Microsoft Graph MCP server, by spec.
81
+
82
+ spec = {
83
+ "internal_domains": ["contoso.com"],
84
+ "kinds": {
85
+ "<kind>": {"label": "inbox rule", "name_field": "displayName", "id_field": "id",
86
+ "list_tool": "...", "list_args": {}, "items_path": "value",
87
+ "get_tool": "...", "id_arg": "id",
88
+ "create_tool": "...", "create_arg": "rule",
89
+ "update_tool": "...", "update_arg": "rule",
90
+ "delete_tool": "...",
91
+ "writable_fields": ["displayName", "sequence", "conditions", "actions", "isEnabled"]}
92
+ },
93
+ "tools": {
94
+ "<tool name>": {"kind": "<kind>", "op": "create" | "update" | "delete"},
95
+ "<tool name>": {"op": "irreversible", "label": "send mail", "summary": "sends mail to {toRecipients}: {subject}"}
96
+ }
97
+ }"""
98
+
99
+ name = "graph"
100
+
101
+ def __init__(self, upstream: Any, spec: Dict[str, Any]) -> None:
102
+ self.upstream = upstream
103
+ self.spec = spec
104
+ self._next_id = 0
105
+
106
+ @classmethod
107
+ def from_file(cls, upstream: Any, path: str) -> "GraphAdapter":
108
+ return cls(upstream, json.loads(Path(path).read_text(encoding="utf-8")))
109
+
110
+ # ---- talking to the upstream ----
111
+ def _call(self, tool: str, args: Dict[str, Any]) -> Any:
112
+ self._next_id += 1
113
+ reply = self.upstream.send({"jsonrpc": "2.0", "id": "gargoyle-probe-%d" % self._next_id, "method": "tools/call",
114
+ "params": {"name": tool, "arguments": args}})
115
+ return _tool_json(reply)
116
+
117
+ def _items(self, kind: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
118
+ doc = self._call(kind["list_tool"], dict(kind.get("list_args") or {}))
119
+ if doc is None:
120
+ return None
121
+ items = doc
122
+ for key in (kind.get("items_path") or "").split("."):
123
+ if key:
124
+ items = items.get(key) if isinstance(items, dict) else None
125
+ if not isinstance(items, list):
126
+ return None
127
+ idf = kind.get("id_field", "id")
128
+ return sorted((i for i in items if isinstance(i, dict) and idf in i), key=lambda i: str(i[idf]))
129
+
130
+ def _item(self, kind: Dict[str, Any], item_id: str) -> Optional[Dict[str, Any]]:
131
+ doc = self._call(kind["get_tool"], {kind.get("id_arg", "id"): item_id})
132
+ return doc if isinstance(doc, dict) else None
133
+
134
+ # ---- descriptions ----
135
+ @staticmethod
136
+ def _desc(ref: str, obj: Any, summary: str, irreversible: bool = False) -> Tuple[Dict[str, Any], Optional[bytes]]:
137
+ data = canonical_json(obj).encode("utf-8") if obj is not None else None
138
+ desc = describe(ref, data, with_text=False)
139
+ if data is not None:
140
+ desc["text"] = json.dumps(obj, indent=2, sort_keys=True)
141
+ desc["summary"] = summary
142
+ if irreversible:
143
+ desc["irreversible"] = True
144
+ return desc, data
145
+
146
+ def _external(self, value: Any) -> List[str]:
147
+ """Addresses in `value` outside the internal domains."""
148
+ internal = [d.lower() for d in self.spec.get("internal_domains", [])]
149
+ found: List[str] = []
150
+
151
+ def walk(v):
152
+ if isinstance(v, str) and "@" in v:
153
+ dom = v.rsplit("@", 1)[1].strip(">").lower()
154
+ if internal and dom not in internal:
155
+ found.append(v)
156
+ elif isinstance(v, dict):
157
+ for x in v.values():
158
+ walk(x)
159
+ elif isinstance(v, list):
160
+ for x in v:
161
+ walk(x)
162
+ walk(value)
163
+ return found
164
+
165
+ def probe(self, tool: str, args: Any) -> Probe:
166
+ rule = (self.spec.get("tools") or {}).get(tool)
167
+ if rule is None or not isinstance(args, dict):
168
+ return None
169
+ op = rule.get("op")
170
+ if op == "irreversible":
171
+ text = rule.get("summary", "{tool}").replace("{tool}", tool)
172
+ for k, v in args.items():
173
+ text = text.replace("{" + k + "}", v if isinstance(v, str) else json.dumps(v))
174
+ ext = self._external(args)
175
+ if ext:
176
+ text += " (to an outside address: %s)" % ", ".join(ext[:3])
177
+ return self._desc("%s:%s" % (tool, rule.get("label", tool)), None, text + ". Cannot be undone.", irreversible=True)
178
+ kind = (self.spec.get("kinds") or {}).get(rule.get("kind"))
179
+ if kind is None:
180
+ return None
181
+ label = kind.get("label", rule.get("kind"))
182
+ name_field = kind.get("name_field")
183
+ if op == "create":
184
+ items = self._items(kind)
185
+ if items is None:
186
+ return None
187
+ payload = args.get(kind.get("create_arg", "item"), args)
188
+ name = payload.get(name_field) if isinstance(payload, dict) and name_field else None
189
+ summary = "creates %s%s" % (label, (" '%s'" % name) if name else "")
190
+ ext = self._external(payload)
191
+ if ext:
192
+ summary += " (to an outside address: %s)" % ", ".join(ext[:3])
193
+ return self._desc("%s:all" % rule["kind"], items, summary + "; %d %s(s) exist now" % (len(items), label))
194
+ item_id = args.get(kind.get("id_arg", "id"))
195
+ if not isinstance(item_id, str) or not item_id:
196
+ return None
197
+ item = self._item(kind, item_id)
198
+ name = item.get(name_field) if isinstance(item, dict) and name_field else None
199
+ verb = "updates" if op == "update" else "deletes" if op == "delete" else op
200
+ summary = "%s %s%s" % (verb, label, (" '%s'" % name) if name else " " + item_id)
201
+ if op == "update":
202
+ ext = self._external(args.get(kind.get("update_arg", "item"), args))
203
+ if ext:
204
+ summary += " (to an outside address: %s)" % ", ".join(ext[:3])
205
+ if item is None:
206
+ summary += " (does not exist now)"
207
+ return self._desc("%s:%s" % (rule["kind"], item_id), item, summary)
208
+
209
+ # ---- undo ----
210
+ def restore(self, meta: Dict[str, Any], pre: Optional[bytes], post: Optional[bytes]) -> Dict[str, Any]:
211
+ rule = (self.spec.get("tools") or {}).get(meta.get("tool"))
212
+ if rule is None:
213
+ return {"reason": REF_REVERT_UNSUPPORTED}
214
+ if rule.get("op") == "irreversible":
215
+ return {"reason": REF_REVERT_IRREVERSIBLE}
216
+ kind = (self.spec.get("kinds") or {}).get(rule.get("kind")) or {}
217
+ idf = kind.get("id_field", "id")
218
+ load = lambda b: json.loads(b.decode("utf-8")) if b is not None else None
219
+ before, after = load(pre), load(post)
220
+ if rule["op"] == "create":
221
+ # The created object is the one in the post set that was not in the pre set.
222
+ if not isinstance(before, list) or not isinstance(after, list) or not kind.get("delete_tool"):
223
+ return {"reason": REF_REVERT_UNSUPPORTED}
224
+ old = {str(i.get(idf)) for i in before}
225
+ new = [i for i in after if str(i.get(idf)) not in old]
226
+ if len(new) != 1:
227
+ return {"reason": REF_REVERT_UNKNOWN}
228
+ return {"params": {"name": kind["delete_tool"], "arguments": {kind.get("id_arg", "id"): str(new[0][idf])}}}
229
+ if rule["op"] == "update":
230
+ if not isinstance(before, dict) or not kind.get("update_tool"):
231
+ return {"reason": REF_REVERT_UNSUPPORTED}
232
+ fields = kind.get("writable_fields")
233
+ body = {k: v for k, v in before.items() if (fields is None or k in fields) and k != idf}
234
+ return {"params": {"name": kind["update_tool"], "arguments": {kind.get("id_arg", "id"): str(before[idf]),
235
+ kind.get("update_arg", "item"): body}}}
236
+ if rule["op"] == "delete":
237
+ if not isinstance(before, dict) or not kind.get("create_tool"):
238
+ return {"reason": REF_REVERT_UNSUPPORTED}
239
+ fields = kind.get("writable_fields")
240
+ body = {k: v for k, v in before.items() if (fields is None or k in fields) and k != idf}
241
+ return {"params": {"name": kind["create_tool"], "arguments": {kind.get("create_arg", "item"): body}}}
242
+ return {"reason": REF_REVERT_UNSUPPORTED}
243
+
244
+
245
+ def adapter_from_name(name: Any, upstream: Any = None, spec_path: Optional[str] = None) -> Any:
246
+ """GARGOYLE_MCP_STATE_PROBE: "filesystem" (default), "none", or "graph"
247
+ with GARGOYLE_MCP_ADAPTER_SPEC naming the tool mapping (default: the
248
+ reference naming shipped in gargoyle/specs/graph-reference.json)."""
249
+ if name is None or name == "" or name == "filesystem":
250
+ return FilesystemAdapter()
251
+ if name == "none":
252
+ return None
253
+ if name == "graph":
254
+ if upstream is None:
255
+ raise ValueError("the graph adapter needs the upstream server")
256
+ path = spec_path or str(Path(__file__).parent / "specs" / "graph-reference.json")
257
+ return GraphAdapter.from_file(upstream, path)
258
+ if hasattr(name, "probe"):
259
+ return name
260
+ if callable(name):
261
+ return _LegacyProbeAdapter(name)
262
+ raise ValueError("unknown state probe: %s" % name)
263
+
264
+
265
+ class _LegacyProbeAdapter:
266
+ """A bare probe function (tool, args) -> description, for tests and
267
+ integrators who wrote one before adapters existed."""
268
+
269
+ name = "custom"
270
+
271
+ def __init__(self, fn) -> None:
272
+ self.fn = fn
273
+
274
+ def probe(self, tool: str, args: Any) -> Probe:
275
+ desc = self.fn(tool, args)
276
+ return None if desc is None else (desc, read_target(desc))
277
+
278
+ def restore(self, meta, pre, post):
279
+ return FilesystemAdapter().restore(meta, pre, post)
@@ -238,6 +238,7 @@ def cmd_remote(args) -> int:
238
238
  app = build_remote_app(config, tokens, upstream=upstream, issuer_url=env.get("GARGOYLE_MCP_ISSUER_URL"),
239
239
  audit_log=env.get("GARGOYLE_MCP_AUDIT_LOG") or str(out / "remote-audit.jsonl"),
240
240
  state_probe=args.state_probe, snapshot_dir=env.get("GARGOYLE_MCP_SNAPSHOT_DIR"),
241
+ adapter_spec=args.adapter_spec or env.get("GARGOYLE_MCP_ADAPTER_SPEC"),
241
242
  approval_timeout=float(env.get("GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS", "300")))
242
243
  tls = {}
243
244
  if args.ssl_certfile or args.ssl_keyfile:
@@ -316,6 +317,8 @@ def cmd_audit(args) -> int:
316
317
  why += " state %s -> %s" % (pre or "?", post or "?") if post else " state %s" % pre
317
318
  if r.get("reverts"):
318
319
  why += " reverts #%d" % r["reverts"]
320
+ if r.get("client"):
321
+ why += " via " + r["client"]
319
322
  print("%4s %-5s %-8s %-24s %-28s %s%s" % (r["seq"] if r["seq"] is not None else "?",
320
323
  "ok" if r["accepted"] else "FAIL", r["kind"] or "?",
321
324
  (r["tool"] or "?")[:24], (r["principal"] or "?")[:28], r["reason"], why))
@@ -373,8 +376,9 @@ def main(argv=None) -> int:
373
376
  p.add_argument("--ssl-certfile"); p.add_argument("--ssl-keyfile")
374
377
  p.add_argument("--upstream-url", help="front a remote MCP server (Streamable HTTP)")
375
378
  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")
379
+ p.add_argument("--state-probe", default="none", choices=["none", "filesystem", "graph"],
380
+ help="filesystem when the upstream is a local filesystem server on this host; graph for a Microsoft Graph MCP server")
381
+ p.add_argument("--adapter-spec", help="with --state-probe graph: the tool mapping JSON (default: the reference naming)")
378
382
  p.add_argument("--verbose", action="store_true")
379
383
  p.add_argument("rest", nargs=argparse.REMAINDER, help="-- <command> for a stdio server to front")
380
384
  p.set_defaults(fn=cmd_remote)
@@ -48,8 +48,9 @@ from gargoyle.envelope import args_sha256, canonical_json, canonical_json_legacy
48
48
  from gargoyle.execution import verify_chain
49
49
  from gargoyle.verifier import verify_provenance
50
50
 
51
- EVIDENCE_VERSION = "gargoyle-evidence/2" # entry hashes over RFC 8785 canonical JSON
52
- EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy, EVIDENCE_VERSION: canonical_json}
51
+ EVIDENCE_VERSION = "gargoyle-evidence/3" # version 3 adds `client`; hashes over RFC 8785 canonical JSON
52
+ EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy, "gargoyle-evidence/2": canonical_json,
53
+ EVIDENCE_VERSION: canonical_json}
53
54
  GENESIS = "0" * 64
54
55
 
55
56
  HONORED = "honored"
@@ -65,6 +66,11 @@ REF_EVIDENCE_UNCHAINED = "REF_EVIDENCE_UNCHAINED" # a pre-chain {receipt,
65
66
 
66
67
  _FIELDS = ("evidence_version", "seq", "prev_sha256", "at", "kind", "tool", "args_sha256",
67
68
  "principal", "reason", "receipt", "grant", "execution", "entry_sha256")
69
+ # Version 3 names the MCP client that made the call ("Claude Desktop 1.2", "ChatGPT"),
70
+ # from the initialize handshake; null when the client did not say. The
71
+ # receipt stays model-agnostic: this is evidence about the session, not a
72
+ # bound field of the decision.
73
+ _FIELDS_V3 = _FIELDS + ("client",)
68
74
 
69
75
 
70
76
  def entry_sha256(entry: Dict[str, Any]) -> str:
@@ -78,9 +84,11 @@ def entry_sha256(entry: Dict[str, Any]) -> str:
78
84
 
79
85
  def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any, principal: str,
80
86
  reason: str, receipt: Any = None, grant: Any = None, execution: Any = None,
81
- at: Optional[datetime] = None) -> Dict[str, Any]:
87
+ at: Optional[datetime] = None, client: Optional[str] = None) -> Dict[str, Any]:
82
88
  if kind not in (HONORED, REFUSED):
83
89
  raise ValueError("kind must be honored or refused")
90
+ if client is not None and not isinstance(client, str):
91
+ raise ValueError("client must be a string or None")
84
92
  entry = {
85
93
  "evidence_version": EVIDENCE_VERSION,
86
94
  "seq": seq,
@@ -94,6 +102,7 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
94
102
  "receipt": receipt,
95
103
  "grant": grant,
96
104
  "execution": execution,
105
+ "client": client,
97
106
  }
98
107
  entry["entry_sha256"] = entry_sha256(entry)
99
108
  return entry
@@ -121,13 +130,15 @@ class EvidenceLog:
121
130
  self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
122
131
 
123
132
  def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
124
- receipt: Any = None, grant: Any = None, execution: Any = None) -> Dict[str, Any]:
133
+ receipt: Any = None, grant: Any = None, execution: Any = None,
134
+ client: Optional[str] = None) -> Dict[str, Any]:
125
135
  """Write one entry and return it. Raises OSError if the file cannot
126
136
  be written; the caller decides whether that stops the call."""
127
137
  if self._seq is None:
128
138
  self._load_tail()
129
139
  entry = build_entry(seq=self._seq + 1, prev_sha256=self._tail, kind=kind, tool=tool, args=args,
130
- principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution)
140
+ principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution,
141
+ client=client)
131
142
  with open(self.path, "a", encoding="utf-8") as f:
132
143
  f.write(json.dumps(entry, sort_keys=True) + "\n")
133
144
  self._seq, self._tail = entry["seq"], entry["entry_sha256"]
@@ -135,8 +146,10 @@ class EvidenceLog:
135
146
 
136
147
 
137
148
  def _well_formed(entry: Any) -> bool:
138
- return (isinstance(entry, dict) and set(entry) == set(_FIELDS)
149
+ expected = _FIELDS_V3 if isinstance(entry, dict) and entry.get("evidence_version") == EVIDENCE_VERSION else _FIELDS
150
+ return (isinstance(entry, dict) and set(entry) == set(expected)
139
151
  and entry["evidence_version"] in EVIDENCE_VERSIONS
152
+ and (entry.get("client") is None or isinstance(entry.get("client"), str))
140
153
  and isinstance(entry["seq"], int) and entry["seq"] >= 1
141
154
  and isinstance(entry["prev_sha256"], str) and len(entry["prev_sha256"]) == 64
142
155
  and isinstance(entry["at"], str) and entry["kind"] in (HONORED, REFUSED)
@@ -179,7 +192,7 @@ def verify_log(
179
192
  rc = rc if isinstance(rc, dict) else {}
180
193
  report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
181
194
  "accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
182
- "verdict": None, "released_by": [], "state": None})
195
+ "verdict": None, "released_by": [], "state": None, "client": None})
183
196
  all_ok = False
184
197
  continue
185
198
  e = raw
@@ -212,7 +225,7 @@ def verify_log(
212
225
  if ex.get("pre_state_sha256") or ex.get("post_state_sha256") else None)
213
226
  report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
214
227
  "accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by,
215
- "state": state})
228
+ "state": state, "client": e.get("client")})
216
229
  all_ok = all_ok and ok
217
230
  # The chain continues from what the file says, so one bad entry is
218
231
  # reported once rather than cascading into every later one.