gargoyle-receipts 0.10.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.10.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.11.0.dev0}/PKG-INFO +19 -2
  2. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/README.md +18 -1
  3. gargoyle_receipts-0.11.0.dev0/gargoyle/adapters.py +279 -0
  4. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/cli.py +4 -2
  5. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/mcp_server.py +24 -21
  6. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/remote.py +2 -1
  7. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/revert.py +32 -23
  8. gargoyle_receipts-0.11.0.dev0/gargoyle/specs/graph-reference.json +56 -0
  9. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/state.py +3 -1
  10. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/static/approve.html +4 -1
  11. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +19 -2
  12. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +3 -0
  13. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/pyproject.toml +2 -2
  14. gargoyle_receipts-0.11.0.dev0/tests/test_graph.py +165 -0
  15. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_revert.py +8 -7
  16. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/LICENSE +0 -0
  17. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/NOTICE +0 -0
  18. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/__init__.py +0 -0
  19. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approval.py +0 -0
  20. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approver_cli.py +0 -0
  21. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approver_trust.py +0 -0
  22. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approvers.py +0 -0
  23. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/consumer.py +0 -0
  24. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/entra_login.py +0 -0
  25. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/envelope.py +0 -0
  26. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/evidence.py +0 -0
  27. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/execution.py +0 -0
  28. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/fs_server.py +0 -0
  29. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/identity.py +0 -0
  30. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/issuer.py +0 -0
  31. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/issuer_service.py +0 -0
  32. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/jws.py +0 -0
  33. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/key_record_source.py +0 -0
  34. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/notify.py +0 -0
  35. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/opa.py +0 -0
  36. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/pending_store.py +0 -0
  37. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/policy.py +0 -0
  38. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/published_record_source.py +0 -0
  39. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/replay_cache.py +0 -0
  40. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/rules.py +0 -0
  41. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/static/approve.js +0 -0
  42. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/verifier.py +0 -0
  43. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  44. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  45. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  46. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  47. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/setup.cfg +0 -0
  48. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_annotations.py +0 -0
  49. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_approver_cli.py +0 -0
  50. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_approvers.py +0 -0
  51. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_canonical.py +0 -0
  52. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_cli.py +0 -0
  53. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_console_js.py +0 -0
  54. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_consumer.py +0 -0
  55. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_entra_identity.py +0 -0
  56. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_entra_login.py +0 -0
  57. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_evidence.py +0 -0
  58. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_execution.py +0 -0
  59. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_fs_server.py +0 -0
  60. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_historical.py +0 -0
  61. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_identity.py +0 -0
  62. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_issuer_service.py +0 -0
  63. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_jws.py +0 -0
  64. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_mcp_proxy.py +0 -0
  65. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  66. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_mcp_server.py +0 -0
  67. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_notify.py +0 -0
  68. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_opa.py +0 -0
  69. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_policy.py +0 -0
  70. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_published_record.py +0 -0
  71. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_remote.py +0 -0
  72. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_rules.py +0 -0
  73. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_state.py +0 -0
  74. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_stdio_encoding.py +0 -0
  75. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_threshold.py +0 -0
  76. {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_typescript_conformance.py +0 -0
  77. {gargoyle_receipts-0.10.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.10.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.10.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
@@ -292,6 +292,22 @@ 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
+ ## 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
+
295
311
  ## Remote mode
296
312
 
297
313
  Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
@@ -341,6 +357,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
341
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 |
342
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 |
343
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) |
344
361
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
345
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 |
346
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.10.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
@@ -254,6 +254,22 @@ 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
+ ## 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
+
257
273
  ## Remote mode
258
274
 
259
275
  Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
@@ -303,6 +319,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
303
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 |
304
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 |
305
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) |
306
323
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
307
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 |
308
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:
@@ -375,8 +376,9 @@ def main(argv=None) -> int:
375
376
  p.add_argument("--ssl-certfile"); p.add_argument("--ssl-keyfile")
376
377
  p.add_argument("--upstream-url", help="front a remote MCP server (Streamable HTTP)")
377
378
  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")
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)")
380
382
  p.add_argument("--verbose", action="store_true")
381
383
  p.add_argument("rest", nargs=argparse.REMAINDER, help="-- <command> for a stdio server to front")
382
384
  p.set_defaults(fn=cmd_remote)
@@ -99,6 +99,7 @@ ENV_APPROVAL_TIMEOUT = "GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS"
99
99
  ENV_POLL_SECONDS = "GARGOYLE_MCP_POLL_SECONDS"
100
100
  ENV_AUDIT_LOG = "GARGOYLE_MCP_AUDIT_LOG"
101
101
  ENV_STATE_PROBE = "GARGOYLE_MCP_STATE_PROBE"
102
+ ENV_ADAPTER_SPEC = "GARGOYLE_MCP_ADAPTER_SPEC" # with GARGOYLE_MCP_STATE_PROBE=graph: the tool mapping JSON
102
103
  ENV_SNAPSHOT_DIR = "GARGOYLE_MCP_SNAPSHOT_DIR" # where pre-change snapshots live (default: snapshots/ beside the audit log); "none" disables undo # "filesystem" (default): bind held calls to the target file's state; "none" to disable
103
104
  REF_TARGET_LOGIN_REQUIRED = "REF_TARGET_LOGIN_REQUIRED" # Entra login mode and no usable token: run `gargoyle login` # the evidence log: one hash-chained JSON line per decision, honored or refused
104
105
 
@@ -302,15 +303,18 @@ class ServerState:
302
303
  audit_log: Optional[str] = None,
303
304
  state_probe: Any = "filesystem",
304
305
  snapshot_dir: Optional[str] = None,
306
+ adapter_spec: Optional[str] = None,
305
307
  ) -> None:
306
- from gargoyle.state import probe_from_name
308
+ from gargoyle.adapters import adapter_from_name
307
309
  from gargoyle.revert import SnapshotStore
308
310
  self.config = config
309
- self.probe = state_probe if callable(state_probe) else probe_from_name(state_probe)
311
+ # The adapter describes, explains and undoes what a tool changes
312
+ # (gargoyle.adapters): files on this host by default, a Graph server by spec.
313
+ self.adapter = adapter_from_name(state_probe, upstream, adapter_spec)
310
314
  if snapshot_dir is None and audit_log:
311
315
  snapshot_dir = str(Path(audit_log).parent / "snapshots")
312
316
  self.snapshots: Optional[SnapshotStore] = (SnapshotStore(snapshot_dir)
313
- if snapshot_dir and snapshot_dir != "none" and self.probe is not None else None)
317
+ if snapshot_dir and snapshot_dir != "none" and self.adapter is not None else None)
314
318
  self.upstream = upstream
315
319
  self.issuer_client = issuer_client
316
320
  self.evidence: Optional[EvidenceLog] = EvidenceLog(audit_log) if audit_log else None
@@ -363,8 +367,8 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
363
367
  # verified against the pinned issuer key like any other. The
364
368
  # target's current state goes with the request (gargoyle.state), so
365
369
  # a hold is approved against it and bound to it.
366
- pre_state = state.probe(name, args) if state.probe is not None else None
367
- res = state.issuer_client.issue(state.config["target_id"], name, args, state=pre_state)
370
+ probed = state.adapter.probe(name, args) if state.adapter is not None else None
371
+ res = state.issuer_client.issue(state.config["target_id"], name, args, state=probed[0] if probed else None)
368
372
  if res["envelope"] is None:
369
373
  return refused(res["reason"])
370
374
  envelope, self_issued = res["envelope"], True
@@ -376,12 +380,12 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
376
380
 
377
381
  # A state-bound receipt is checked against the state as it is now, after
378
382
  # any wait for approval: if the target moved, the call does not run.
379
- live_state = None
383
+ live_state, now_probe = None, None
380
384
  if isinstance(envelope, dict) and isinstance(envelope.get("request_context"), dict) \
381
- and "state_sha256" in envelope["request_context"] and state.probe is not None:
385
+ and "state_sha256" in envelope["request_context"] and state.adapter is not None:
382
386
  from gargoyle.state import state_sha256 as _state_hash
383
- now_desc = state.probe(name, args)
384
- live_state = _state_hash(now_desc) if now_desc is not None else None
387
+ now_probe = state.adapter.probe(name, args)
388
+ live_state = _state_hash(now_probe[0]) if now_probe is not None else None
385
389
  verdict = state.consumer.check(envelope, call_for(name, args, state.config["principal"], live_state),
386
390
  grant=grant)
387
391
  if not verdict.honored:
@@ -390,12 +394,9 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
390
394
  # Undo material: for a held, state-bound call, keep the target as it is
391
395
  # right now (the state the receipt bound and the check just confirmed).
392
396
  if (state.snapshots is not None and isinstance(envelope, dict) and envelope.get("requires_approval")
393
- and live_state is not None and isinstance(envelope.get("decision_id"), str)):
397
+ and now_probe is not None and isinstance(envelope.get("decision_id"), str)):
394
398
  try:
395
- desc = state.probe(name, args)
396
- if desc is not None and state.config and state.config.get("principal"):
397
- from gargoyle.state import read_target
398
- state.snapshots.put(envelope["decision_id"], desc, read_target(desc), name)
399
+ state.snapshots.put(envelope["decision_id"], now_probe[0], now_probe[1], name, args)
399
400
  except Exception as exc:
400
401
  sys.stderr.write("gargoyle: snapshot failed: %s\n" % exc)
401
402
 
@@ -445,14 +446,15 @@ def _attest(state: ServerState, envelope: Optional[Dict[str, Any]], outputs: Any
445
446
  probed again after the run, so the receipt says what it changed."""
446
447
  if state.consumer is None or not state.consumer.attests or envelope is None:
447
448
  return None
448
- post = None
449
- if params is not None and state.probe is not None and "state_sha256" in (envelope.get("request_context") or {}):
449
+ post, post_data = None, None
450
+ if params is not None and state.adapter is not None and "state_sha256" in (envelope.get("request_context") or {}):
450
451
  from gargoyle.state import state_sha256 as _state_hash
451
- desc = state.probe(str(params.get("name")), params.get("arguments", {}))
452
- post = _state_hash(desc) if desc is not None else None
452
+ probed = state.adapter.probe(str(params.get("name")), params.get("arguments", {}))
453
+ if probed is not None:
454
+ post, post_data = _state_hash(probed[0]), probed[1]
453
455
  if post is not None and state.snapshots is not None and isinstance(envelope.get("decision_id"), str):
454
456
  try:
455
- state.snapshots.record_post(envelope["decision_id"], post)
457
+ state.snapshots.record_post(envelope["decision_id"], post, post_data)
456
458
  except Exception:
457
459
  pass
458
460
  return state.consumer.attest(envelope, outputs, post_state_sha256=post)
@@ -532,7 +534,7 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
532
534
  if state.snapshots is None:
533
535
  return _success(msg_id, {"content": [{"type": "text", "text": "refused: undo is not enabled"}], "isError": True,
534
536
  "_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: "REF_REVERT_UNKNOWN"}})
535
- plan = plan_revert(state.snapshots, str((params.get("arguments") or {}).get("decision_id") or "last"), state.probe)
537
+ plan = plan_revert(state.snapshots, str((params.get("arguments") or {}).get("decision_id") or "last"), state.adapter)
536
538
  if "reason" in plan:
537
539
  return _success(msg_id, {"content": [{"type": "text", "text": "refused: " + plan["reason"]}], "isError": True,
538
540
  "_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: plan["reason"]}})
@@ -719,7 +721,8 @@ if __name__ == "__main__":
719
721
  try:
720
722
  serve_stdio(ServerState(config_from_env(), upstream=_upstream, issuer_client=issuer_client_from_env(),
721
723
  audit_log=_os.environ.get(ENV_AUDIT_LOG) or None,
722
- state_probe=_os.environ.get(ENV_STATE_PROBE) or "filesystem"))
724
+ state_probe=_os.environ.get(ENV_STATE_PROBE) or "filesystem",
725
+ adapter_spec=_os.environ.get(ENV_ADAPTER_SPEC) or None))
723
726
  finally:
724
727
  if _upstream is not None:
725
728
  _upstream.close()
@@ -138,6 +138,7 @@ def build_remote_app(
138
138
  issuer_url: Optional[str] = None,
139
139
  audit_log: Optional[str] = None,
140
140
  state_probe: Any = "none",
141
+ adapter_spec: Optional[str] = None,
141
142
  snapshot_dir: Optional[str] = None,
142
143
  issuer_http: Any = None,
143
144
  approval_timeout: float = 300.0,
@@ -177,7 +178,7 @@ def build_remote_app(
177
178
  kw["sleep"] = sleep
178
179
  ic = IssuerClient(issuer_url, issuer_token, **kw)
179
180
  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
+ audit_log=audit_log, state_probe=state_probe, snapshot_dir=snapshot_dir, adapter_spec=adapter_spec)
181
182
  # One log and one snapshot store for the whole process: the chain and
182
183
  # the sidecars must not interleave from several session objects.
183
184
  if st.evidence is not None:
@@ -32,6 +32,8 @@ from pathlib import Path
32
32
  from typing import Any, Dict, List, Optional
33
33
 
34
34
  from gargoyle.state import state_sha256
35
+ from gargoyle.adapters import (REF_REVERT_IRREVERSIBLE, REF_REVERT_NEEDS_DELETE, REF_REVERT_NOT_TEXT, REF_REVERT_UNKNOWN,
36
+ REF_REVERT_UNSUPPORTED)
35
37
 
36
38
  REVERT_TOOL = "gargoyle_revert"
37
39
  REVERT_TOOL_DEF = {
@@ -42,10 +44,7 @@ REVERT_TOOL_DEF = {
42
44
  "inputSchema": {"type": "object", "properties": {"decision_id": {"type": "string", "default": "last"}}},
43
45
  }
44
46
 
45
- REF_REVERT_UNKNOWN = "REF_REVERT_UNKNOWN"
46
47
  REF_REVERT_STATE_CHANGED = "REF_REVERT_STATE_CHANGED"
47
- REF_REVERT_NEEDS_DELETE = "REF_REVERT_NEEDS_DELETE"
48
- REF_REVERT_NOT_TEXT = "REF_REVERT_NOT_TEXT"
49
48
 
50
49
 
51
50
  class SnapshotStore:
@@ -57,9 +56,12 @@ class SnapshotStore:
57
56
  def _ensure(self) -> None:
58
57
  self.dir.mkdir(parents=True, exist_ok=True)
59
58
 
60
- def put(self, decision_id: str, description: Dict[str, Any], data: Optional[bytes], tool: str) -> Dict[str, Any]:
59
+ def put(self, decision_id: str, description: Dict[str, Any], data: Optional[bytes], tool: str,
60
+ args: Any = None) -> Dict[str, Any]:
61
61
  """Keep the pre-state of `decision_id`: the bytes (None when the target
62
- did not exist) and a sidecar. Returns the sidecar."""
62
+ did not exist) and a sidecar naming the call, so the same probe can be
63
+ repeated later to ask whether the target is still as the call left it.
64
+ Returns the sidecar."""
63
65
  self._ensure()
64
66
  key = state_sha256(description)
65
67
  if data is not None:
@@ -70,17 +72,23 @@ class SnapshotStore:
70
72
  os.chmod(blob, stat.S_IRUSR | stat.S_IWUSR)
71
73
  except OSError:
72
74
  pass
73
- meta = {"decision_id": decision_id, "tool": tool, "ref": description["ref"], "pre_state_sha256": key,
75
+ meta = {"decision_id": decision_id, "tool": tool, "args": args, "ref": description["ref"], "pre_state_sha256": key,
74
76
  "existed": data is not None, "post_state_sha256": None, "at": datetime.now(timezone.utc).isoformat()}
75
77
  (self.dir / (decision_id + ".json")).write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
76
78
  return meta
77
79
 
78
- def record_post(self, decision_id: str, post_state_sha256: Optional[str]) -> None:
80
+ def record_post(self, decision_id: str, post_state_sha256: Optional[str], data: Optional[bytes] = None) -> None:
81
+ """The state the call left: its hash in the sidecar, its bytes under
82
+ that hash (an adapter may need both snapshots to compose the undo)."""
79
83
  p = self.dir / (decision_id + ".json")
80
84
  if p.exists():
81
85
  meta = json.loads(p.read_text(encoding="utf-8"))
82
86
  meta["post_state_sha256"] = post_state_sha256
83
87
  p.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
88
+ if data is not None and post_state_sha256:
89
+ blob = self.dir / (post_state_sha256 + ".bin")
90
+ if not blob.exists():
91
+ blob.write_bytes(data)
84
92
 
85
93
  def sidecars(self) -> List[Dict[str, Any]]:
86
94
  if not self.dir.exists():
@@ -100,25 +108,26 @@ class SnapshotStore:
100
108
  return p.read_bytes() if p.exists() else None
101
109
 
102
110
 
103
- def plan_revert(store: SnapshotStore, decision_id: str, probe) -> Dict[str, Any]:
104
- """The write that undoes `decision_id`, or a refusal:
105
- {"params": {"name": "write_file", "arguments": {...}}, "reverts": id} |
106
- {"reason": REF_REVERT_*}. `probe` is the state probe, used to confirm the
111
+ def plan_revert(store: SnapshotStore, decision_id: str, adapter) -> Dict[str, Any]:
112
+ """The call that undoes `decision_id`, or a refusal:
113
+ {"params": {"name", "arguments"}, "reverts": id} | {"reason": REF_REVERT_*}.
114
+ The adapter composes the inverse from the two snapshots and confirms the
107
115
  target is still as that call left it."""
108
116
  meta = store.find(decision_id or "last")
109
117
  if meta is None or not meta.get("post_state_sha256"):
110
118
  return {"reason": REF_REVERT_UNKNOWN}
111
- if not meta.get("existed"):
112
- return {"reason": REF_REVERT_NEEDS_DELETE}
113
- data = store.content(meta["pre_state_sha256"])
114
- if data is None:
119
+ if adapter is None:
120
+ return {"reason": REF_REVERT_UNSUPPORTED}
121
+ pre = store.content(meta["pre_state_sha256"]) if meta.get("existed") else None
122
+ if meta.get("existed") and pre is None:
115
123
  return {"reason": REF_REVERT_UNKNOWN}
116
- try:
117
- text = data.decode("utf-8")
118
- except UnicodeDecodeError:
119
- return {"reason": REF_REVERT_NOT_TEXT}
120
- args = {"path": meta["ref"], "content": text}
121
- now = probe("write_file", args) if probe is not None else None
122
- if now is None or state_sha256(now) != meta["post_state_sha256"]:
124
+ post = store.content(meta["post_state_sha256"])
125
+ plan = adapter.restore(meta, pre, post)
126
+ if "reason" in plan:
127
+ return plan
128
+ # Still as the original call left it? Repeat the original call's probe
129
+ # now and compare with the state recorded after it ran.
130
+ probe = adapter.probe(meta["tool"], meta.get("args") if meta.get("args") is not None else {"path": meta["ref"]})
131
+ if probe is None or state_sha256(probe[0]) != meta["post_state_sha256"]:
123
132
  return {"reason": REF_REVERT_STATE_CHANGED}
124
- return {"params": {"name": "write_file", "arguments": args}, "reverts": meta["decision_id"]}
133
+ return {"params": plan["params"], "reverts": meta["decision_id"]}