gargoyle-receipts 0.10.0.dev0__tar.gz → 0.12.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 (79) hide show
  1. {gargoyle_receipts-0.10.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.12.0.dev0}/PKG-INFO +32 -2
  2. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/README.md +31 -1
  3. gargoyle_receipts-0.12.0.dev0/gargoyle/adapters.py +279 -0
  4. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/cli.py +6 -2
  5. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/consumer.py +21 -1
  6. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/evidence.py +9 -4
  7. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/execution.py +9 -2
  8. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/issuer_service.py +96 -11
  9. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/mcp_server.py +128 -29
  10. gargoyle_receipts-0.12.0.dev0/gargoyle/plans.py +209 -0
  11. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/policy.py +1 -0
  12. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/remote.py +2 -1
  13. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/revert.py +32 -23
  14. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/rules.py +6 -2
  15. gargoyle_receipts-0.12.0.dev0/gargoyle/specs/graph-reference.json +56 -0
  16. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/state.py +3 -1
  17. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/static/approve.html +36 -1
  18. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +32 -2
  19. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +5 -0
  20. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/pyproject.toml +2 -2
  21. gargoyle_receipts-0.12.0.dev0/tests/test_graph.py +165 -0
  22. gargoyle_receipts-0.12.0.dev0/tests/test_plans.py +187 -0
  23. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_revert.py +8 -7
  24. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/LICENSE +0 -0
  25. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/NOTICE +0 -0
  26. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/__init__.py +0 -0
  27. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approval.py +0 -0
  28. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approver_cli.py +0 -0
  29. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approver_trust.py +0 -0
  30. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approvers.py +0 -0
  31. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/entra_login.py +0 -0
  32. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/envelope.py +0 -0
  33. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/fs_server.py +0 -0
  34. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/identity.py +0 -0
  35. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/issuer.py +0 -0
  36. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/jws.py +0 -0
  37. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/key_record_source.py +0 -0
  38. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/notify.py +0 -0
  39. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/opa.py +0 -0
  40. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/pending_store.py +0 -0
  41. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/published_record_source.py +0 -0
  42. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/replay_cache.py +0 -0
  43. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/static/approve.js +0 -0
  44. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/verifier.py +0 -0
  45. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  46. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  47. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  48. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  49. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/setup.cfg +0 -0
  50. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_annotations.py +0 -0
  51. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_approver_cli.py +0 -0
  52. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_approvers.py +0 -0
  53. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_canonical.py +0 -0
  54. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_cli.py +0 -0
  55. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_console_js.py +0 -0
  56. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_consumer.py +0 -0
  57. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_entra_identity.py +0 -0
  58. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_entra_login.py +0 -0
  59. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_evidence.py +0 -0
  60. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_execution.py +0 -0
  61. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_fs_server.py +0 -0
  62. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_historical.py +0 -0
  63. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_identity.py +0 -0
  64. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_issuer_service.py +0 -0
  65. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_jws.py +0 -0
  66. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_mcp_proxy.py +0 -0
  67. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  68. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_mcp_server.py +0 -0
  69. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_notify.py +0 -0
  70. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_opa.py +0 -0
  71. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_policy.py +0 -0
  72. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_published_record.py +0 -0
  73. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_remote.py +0 -0
  74. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_rules.py +0 -0
  75. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_state.py +0 -0
  76. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_stdio_encoding.py +0 -0
  77. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_threshold.py +0 -0
  78. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_typescript_conformance.py +0 -0
  79. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.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.10.0.dev0
3
+ Version: 0.12.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.10.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.12.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
@@ -292,6 +292,35 @@ and `gargoyle audit` verifies them later offline.
292
292
  and through the alternative with OPA in Docker for sites that run a policy
293
293
  engine.
294
294
 
295
+ ## Scoped grants
296
+
297
+ One card per held call is right for a mailbox rule and wrong for a long
298
+ task. The assistant can ask for a plan (`gargoyle_plan`): a list of steps,
299
+ each a tool with exact arguments or argument patterns, and a note saying
300
+ why. The issuer judges every step under the policy, signs the plan, and
301
+ holds it as one card; the approver signs once; each later call matching
302
+ an unused step runs without a card of its own until the plan expires.
303
+ Every step is still bound, claimed once, and recorded with the plan and
304
+ step that released it. The rules file decides what may be planned
305
+ (`"plan": false` keeps a tool individual; sends should be), because a
306
+ plan approves scope rather than content.
307
+
308
+ ## Microsoft 365 and other targets: adapters
309
+
310
+ What a held call changes is the adapter's business (`gargoyle.adapters`):
311
+ it describes the target's state before and after, tells the approver what
312
+ the call means in plain words, and composes the call that undoes it. Files
313
+ on the executor's host are the default. `GARGOYLE_MCP_STATE_PROBE=graph`
314
+ selects the Graph adapter for a Microsoft Graph MCP server: inbox rules,
315
+ calendar events, contacts and Teams membership are bound to their state,
316
+ explained on the card ("creates inbox rule 'Fwd invoices' (to an outside
317
+ address: x@evil.example)"), undone by the inverse Graph call, and a send
318
+ is marked as something that cannot be undone. The adapter reads state
319
+ through the upstream server itself, so the gate needs no Graph credential
320
+ of its own. Which tool is which is a JSON spec; the reference naming is in
321
+ `gargoyle/specs/graph-reference.json`, and a different server's names are
322
+ mapped there, not in code.
323
+
295
324
  ## Remote mode
296
325
 
297
326
  Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
@@ -341,6 +370,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
341
370
  | 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 |
342
371
  | `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 |
343
372
  | `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 |
373
+ | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
344
374
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
345
375
  | `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 |
346
376
  | `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.10.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.12.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
@@ -254,6 +254,35 @@ and `gargoyle audit` verifies them later offline.
254
254
  and through the alternative with OPA in Docker for sites that run a policy
255
255
  engine.
256
256
 
257
+ ## Scoped grants
258
+
259
+ One card per held call is right for a mailbox rule and wrong for a long
260
+ task. The assistant can ask for a plan (`gargoyle_plan`): a list of steps,
261
+ each a tool with exact arguments or argument patterns, and a note saying
262
+ why. The issuer judges every step under the policy, signs the plan, and
263
+ holds it as one card; the approver signs once; each later call matching
264
+ an unused step runs without a card of its own until the plan expires.
265
+ Every step is still bound, claimed once, and recorded with the plan and
266
+ step that released it. The rules file decides what may be planned
267
+ (`"plan": false` keeps a tool individual; sends should be), because a
268
+ plan approves scope rather than content.
269
+
270
+ ## Microsoft 365 and other targets: adapters
271
+
272
+ What a held call changes is the adapter's business (`gargoyle.adapters`):
273
+ it describes the target's state before and after, tells the approver what
274
+ the call means in plain words, and composes the call that undoes it. Files
275
+ on the executor's host are the default. `GARGOYLE_MCP_STATE_PROBE=graph`
276
+ selects the Graph adapter for a Microsoft Graph MCP server: inbox rules,
277
+ calendar events, contacts and Teams membership are bound to their state,
278
+ explained on the card ("creates inbox rule 'Fwd invoices' (to an outside
279
+ address: x@evil.example)"), undone by the inverse Graph call, and a send
280
+ is marked as something that cannot be undone. The adapter reads state
281
+ through the upstream server itself, so the gate needs no Graph credential
282
+ of its own. Which tool is which is a JSON spec; the reference naming is in
283
+ `gargoyle/specs/graph-reference.json`, and a different server's names are
284
+ mapped there, not in code.
285
+
257
286
  ## Remote mode
258
287
 
259
288
  Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
@@ -303,6 +332,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
303
332
  | 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 |
304
333
  | `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 |
305
334
  | `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 |
335
+ | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
306
336
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
307
337
  | `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 |
308
338
  | `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:
@@ -310,6 +311,8 @@ def cmd_audit(args) -> int:
310
311
  why = (" (%s)" % r["verdict"]) if r["kind"] == "refused" and r["verdict"] else ""
311
312
  if r.get("released_by"):
312
313
  why += " released by " + ", ".join(r["released_by"])
314
+ if r.get("plan"):
315
+ why += " (plan %s step %d)" % (str(r["plan"].get("plan_id") or "?")[:8], r["plan"].get("step", -1) + 1)
313
316
  if r.get("state"):
314
317
  # A state-bound call: the target as it was approved, and as the run left it.
315
318
  pre, post = (r["state"].get("pre") or "")[:8], (r["state"].get("post") or "")[:8]
@@ -375,8 +378,9 @@ def main(argv=None) -> int:
375
378
  p.add_argument("--ssl-certfile"); p.add_argument("--ssl-keyfile")
376
379
  p.add_argument("--upstream-url", help="front a remote MCP server (Streamable HTTP)")
377
380
  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")
381
+ p.add_argument("--state-probe", default="none", choices=["none", "filesystem", "graph"],
382
+ help="filesystem when the upstream is a local filesystem server on this host; graph for a Microsoft Graph MCP server")
383
+ p.add_argument("--adapter-spec", help="with --state-probe graph: the tool mapping JSON (default: the reference naming)")
380
384
  p.add_argument("--verbose", action="store_true")
381
385
  p.add_argument("rest", nargs=argparse.REMAINDER, help="-- <command> for a stdio server to front")
382
386
  p.set_defaults(fn=cmd_remote)
@@ -273,6 +273,7 @@ class Consumer:
273
273
  *,
274
274
  grant: Any = None,
275
275
  now: Optional[datetime] = None,
276
+ plan_release: Any = None,
276
277
  ) -> Verdict:
277
278
  """Decide whether to honor `envelope` for `call` ({"tool", "args",
278
279
  "principal"}, the principal being the one this consumer
@@ -321,7 +322,26 @@ class Consumer:
321
322
  # inside the signed region, so they are the issuer's word, not the
322
323
  # caller's. The grant is checked and claimed before the receipt is
323
324
  # claimed, so a refused grant leaves the receipt unspent.
324
- if envelope["requires_approval"]:
325
+ if envelope["requires_approval"] and plan_release is not None:
326
+ # A scoped grant (gargoyle.plans): the plan's step is claimed once
327
+ # in place of a per-call grant id.
328
+ from gargoyle.plans import verify_plan_release
329
+ approver_keys = self._approver_keys(key_record_view, envelope.get("issuer_key_id"), now)
330
+ if not approver_keys:
331
+ return Verdict(False, REF_TARGET_NO_APPROVER_TRUST)
332
+ r = verify_plan_release(plan_release, envelope, call, issuer_public_keys=pinned_public_keys,
333
+ approver_public_keys=approver_keys, now=now, clock_skew=self.clock_skew)
334
+ if not r["accepted"]:
335
+ return Verdict(False, r["reason"])
336
+ plan = plan_release["plan"]
337
+ try:
338
+ plan_exp = datetime.fromisoformat(plan["not_after"]) + self.clock_skew
339
+ except (ValueError, TypeError, KeyError):
340
+ plan_exp = None
341
+ if not self.replay_cache.check_and_claim("plan:%s:%d" % (plan["plan_id"], r["step"]), plan_exp, now=now):
342
+ from gargoyle.plans import REF_PLAN_STEP_CONSUMED
343
+ return Verdict(False, REF_PLAN_STEP_CONSUMED)
344
+ elif envelope["requires_approval"]:
325
345
  grants = as_grant_list(grant)
326
346
  if not grants:
327
347
  return Verdict(False, REF_TARGET_APPROVAL_REQUIRED)
@@ -192,7 +192,7 @@ def verify_log(
192
192
  rc = rc if isinstance(rc, dict) else {}
193
193
  report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
194
194
  "accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
195
- "verdict": None, "released_by": [], "state": None, "client": None})
195
+ "verdict": None, "released_by": [], "state": None, "client": None, "plan": None})
196
196
  all_ok = False
197
197
  continue
198
198
  e = raw
@@ -218,14 +218,19 @@ def verify_log(
218
218
  now=_parse_at(e["at"]), historical=True)
219
219
  ok, reason = p["accepted"], (ACCEPT_ENTRY_VERIFIED if p["accepted"] else p["reason"])
220
220
  counts[e["kind"]] += 1
221
- released_by = [g.get("approver_key_id") for g in (e["grant"] if isinstance(e["grant"], list) else [e["grant"]])
222
- if isinstance(g, dict)] if e["grant"] is not None else []
221
+ plan_ref = None
222
+ grant_set = e["grant"]
223
+ if isinstance(grant_set, dict) and "plan" in grant_set and "step" in grant_set:
224
+ plan_ref = {"plan_id": (grant_set["plan"] or {}).get("plan_id"), "step": grant_set["step"]}
225
+ grant_set = grant_set.get("grants")
226
+ released_by = [g.get("approver_key_id") for g in (grant_set if isinstance(grant_set, list) else [grant_set])
227
+ if isinstance(g, dict)] if grant_set is not None else []
223
228
  ex = e["execution"] if isinstance(e["execution"], dict) else {}
224
229
  state = ({"pre": ex.get("pre_state_sha256"), "post": ex.get("post_state_sha256")}
225
230
  if ex.get("pre_state_sha256") or ex.get("post_state_sha256") else None)
226
231
  report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
227
232
  "accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by,
228
- "state": state, "client": e.get("client")})
233
+ "state": state, "client": e.get("client"), "plan": plan_ref})
229
234
  all_ok = all_ok and ok
230
235
  # The chain continues from what the file says, so one bad entry is
231
236
  # reported once rather than cascading into every later one.
@@ -218,10 +218,17 @@ def verify_chain(
218
218
  return {"accepted": False, "reason": provenance["reason"], "steps": steps}
219
219
 
220
220
  if envelope["requires_approval"]:
221
- if not as_grant_list(grant):
221
+ from gargoyle.plans import is_plan_release, verify_plan_release
222
+ if is_plan_release(grant):
223
+ # Released by a scoped grant: the plan, its grants and the step,
224
+ # re-matched against the arguments the release recorded.
225
+ g = verify_plan_release(grant, envelope, {}, issuer_public_keys=issuer_public_keys,
226
+ approver_public_keys=approver_public_keys or {}, now=executed_at)
227
+ elif not as_grant_list(grant):
222
228
  steps.append(("approval", REF_EXEC_GRANT_MISSING))
223
229
  return {"accepted": False, "reason": REF_EXEC_GRANT_MISSING, "steps": steps}
224
- g = verify_grants(grant, envelope=envelope, approver_public_keys=approver_public_keys or {}, now=executed_at)
230
+ else:
231
+ g = verify_grants(grant, envelope=envelope, approver_public_keys=approver_public_keys or {}, now=executed_at)
225
232
  steps.append(("approval", g["reason"]))
226
233
  if not g["accepted"]:
227
234
  return {"accepted": False, "reason": g["reason"], "steps": steps}