gargoyle-receipts 0.7.1.dev0__tar.gz → 0.8.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 (72) hide show
  1. {gargoyle_receipts-0.7.1.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.8.0.dev0}/PKG-INFO +9 -3
  2. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/README.md +8 -2
  3. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/cli.py +2 -0
  4. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/evidence.py +11 -0
  5. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/mcp_server.py +47 -3
  6. gargoyle_receipts-0.8.0.dev0/gargoyle/revert.py +124 -0
  7. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/state.py +11 -0
  8. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +9 -3
  9. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
  10. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/pyproject.toml +1 -1
  11. gargoyle_receipts-0.8.0.dev0/tests/test_revert.py +140 -0
  12. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/LICENSE +0 -0
  13. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/NOTICE +0 -0
  14. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/__init__.py +0 -0
  15. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/approval.py +0 -0
  16. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/approver_cli.py +0 -0
  17. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/approver_trust.py +0 -0
  18. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/approvers.py +0 -0
  19. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/consumer.py +0 -0
  20. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/entra_login.py +0 -0
  21. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/envelope.py +0 -0
  22. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/execution.py +0 -0
  23. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/fs_server.py +0 -0
  24. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/identity.py +0 -0
  25. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/issuer.py +0 -0
  26. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/issuer_service.py +0 -0
  27. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/jws.py +0 -0
  28. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/key_record_source.py +0 -0
  29. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/notify.py +0 -0
  30. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/opa.py +0 -0
  31. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/pending_store.py +0 -0
  32. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/policy.py +0 -0
  33. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/published_record_source.py +0 -0
  34. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/replay_cache.py +0 -0
  35. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/rules.py +0 -0
  36. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/static/approve.html +0 -0
  37. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/static/approve.js +0 -0
  38. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/verifier.py +0 -0
  39. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  40. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  41. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  42. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  43. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/setup.cfg +0 -0
  44. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_annotations.py +0 -0
  45. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_approver_cli.py +0 -0
  46. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_approvers.py +0 -0
  47. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_canonical.py +0 -0
  48. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_cli.py +0 -0
  49. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_console_js.py +0 -0
  50. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_consumer.py +0 -0
  51. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_entra_identity.py +0 -0
  52. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_entra_login.py +0 -0
  53. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_evidence.py +0 -0
  54. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_execution.py +0 -0
  55. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_fs_server.py +0 -0
  56. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_historical.py +0 -0
  57. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_identity.py +0 -0
  58. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_issuer_service.py +0 -0
  59. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_jws.py +0 -0
  60. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_mcp_proxy.py +0 -0
  61. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  62. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_mcp_server.py +0 -0
  63. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_notify.py +0 -0
  64. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_opa.py +0 -0
  65. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_policy.py +0 -0
  66. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_published_record.py +0 -0
  67. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_rules.py +0 -0
  68. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_state.py +0 -0
  69. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_stdio_encoding.py +0 -0
  70. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_threshold.py +0 -0
  71. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_typescript_conformance.py +0 -0
  72. {gargoyle_receipts-0.7.1.dev0 → gargoyle_receipts-0.8.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.7.1.dev0
3
+ Version: 0.8.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.7.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.8.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
@@ -230,7 +230,12 @@ shows the proposed content as a diff against that state; after the human
230
230
  signs, the middleware hashes the target again and refuses if it moved; and
231
231
  the execution receipt carries the state before and after. An approval
232
232
  cannot be redirected by changing the file under it, and an audit shows not
233
- only that a write ran but what it changed.
233
+ only that a write ran but what it changed. An approved change can be
234
+ undone the same way: the middleware keeps what a held write replaced and
235
+ offers `gargoyle_revert`, which composes the restoring write and sends it
236
+ through the gate, held, shown as a diff, bound to the current state, and
237
+ refused if anything changed since. The audit marks a revert from the
238
+ state hashes alone.
234
239
 
235
240
  The MCP middleware also keeps an evidence log: one hash-chained line per
236
241
  decision, refusals and declined holds included, so the trail answers "what
@@ -314,6 +319,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
314
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 |
315
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 |
316
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_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 |
317
323
  | `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 |
318
324
 
319
325
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
@@ -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.7.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.8.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
@@ -192,7 +192,12 @@ shows the proposed content as a diff against that state; after the human
192
192
  signs, the middleware hashes the target again and refuses if it moved; and
193
193
  the execution receipt carries the state before and after. An approval
194
194
  cannot be redirected by changing the file under it, and an audit shows not
195
- only that a write ran but what it changed.
195
+ only that a write ran but what it changed. An approved change can be
196
+ undone the same way: the middleware keeps what a held write replaced and
197
+ offers `gargoyle_revert`, which composes the restoring write and sends it
198
+ through the gate, held, shown as a diff, bound to the current state, and
199
+ refused if anything changed since. The audit marks a revert from the
200
+ state hashes alone.
196
201
 
197
202
  The MCP middleware also keeps an evidence log: one hash-chained line per
198
203
  decision, refusals and declined holds included, so the trail answers "what
@@ -276,6 +281,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
276
281
  | 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 |
277
282
  | `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 |
278
283
  | `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 |
284
+ | `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 |
279
285
  | `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 |
280
286
 
281
287
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
@@ -250,6 +250,8 @@ def cmd_audit(args) -> int:
250
250
  # A state-bound call: the target as it was approved, and as the run left it.
251
251
  pre, post = (r["state"].get("pre") or "")[:8], (r["state"].get("post") or "")[:8]
252
252
  why += " state %s -> %s" % (pre or "?", post or "?") if post else " state %s" % pre
253
+ if r.get("reverts"):
254
+ why += " reverts #%d" % r["reverts"]
253
255
  print("%4s %-5s %-8s %-24s %-28s %s%s" % (r["seq"] if r["seq"] is not None else "?",
254
256
  "ok" if r["accepted"] else "FAIL", r["kind"] or "?",
255
257
  (r["tool"] or "?")[:24], (r["principal"] or "?")[:28], r["reason"], why))
@@ -218,6 +218,17 @@ def verify_log(
218
218
  # reported once rather than cascading into every later one.
219
219
  expect_seq, prev, tail = e["seq"] + 1, e["entry_sha256"], e["entry_sha256"]
220
220
 
221
+ # A revert is recognisable without any annotation: its pre-state is an
222
+ # earlier honored entry's post-state and its post-state that entry's pre-state.
223
+ for i, r in enumerate(report):
224
+ r["reverts"] = None
225
+ st = r.get("state")
226
+ if r["accepted"] and st and st.get("pre") and st.get("post"):
227
+ for prev in reversed(report[:i]):
228
+ ps = prev.get("state")
229
+ if prev["accepted"] and ps and ps.get("post") == st["pre"] and ps.get("pre") == st["post"]:
230
+ r["reverts"] = prev["seq"]
231
+ break
221
232
  return {"accepted": all_ok, "reason": ACCEPT_EVIDENCE_VERIFIED if all_ok else _first_failure(report),
222
233
  "tail_sha256": tail, "count": len(report), "honored": counts[HONORED], "refused": counts[REFUSED],
223
234
  "entries": report}
@@ -42,6 +42,7 @@ Run as a stdio MCP server: python3 -m gargoyle.mcp_server
42
42
  import json
43
43
  import subprocess
44
44
  import sys
45
+ from pathlib import Path
45
46
  import threading
46
47
  import time
47
48
  from typing import Any, Dict, List, Optional
@@ -97,7 +98,8 @@ ENV_CLIENT_TOKEN = "GARGOYLE_MCP_CLIENT_TOKEN"
97
98
  ENV_APPROVAL_TIMEOUT = "GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS"
98
99
  ENV_POLL_SECONDS = "GARGOYLE_MCP_POLL_SECONDS"
99
100
  ENV_AUDIT_LOG = "GARGOYLE_MCP_AUDIT_LOG"
100
- ENV_STATE_PROBE = "GARGOYLE_MCP_STATE_PROBE" # "filesystem" (default): bind held calls to the target file's state; "none" to disable
101
+ ENV_STATE_PROBE = "GARGOYLE_MCP_STATE_PROBE"
102
+ 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
101
103
  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
102
104
 
103
105
  # Self-issue refusals.
@@ -290,10 +292,16 @@ class ServerState:
290
292
  issuer_client: Optional[IssuerClient] = None,
291
293
  audit_log: Optional[str] = None,
292
294
  state_probe: Any = "filesystem",
295
+ snapshot_dir: Optional[str] = None,
293
296
  ) -> None:
294
297
  from gargoyle.state import probe_from_name
298
+ from gargoyle.revert import SnapshotStore
295
299
  self.config = config
296
300
  self.probe = state_probe if callable(state_probe) else probe_from_name(state_probe)
301
+ if snapshot_dir is None and audit_log:
302
+ snapshot_dir = str(Path(audit_log).parent / "snapshots")
303
+ self.snapshots: Optional[SnapshotStore] = (SnapshotStore(snapshot_dir)
304
+ if snapshot_dir and snapshot_dir != "none" and self.probe is not None else None)
297
305
  self.upstream = upstream
298
306
  self.issuer_client = issuer_client
299
307
  self.evidence: Optional[EvidenceLog] = EvidenceLog(audit_log) if audit_log else None
@@ -369,6 +377,18 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
369
377
  if not verdict.honored:
370
378
  return refused(verdict.reason, envelope)
371
379
 
380
+ # Undo material: for a held, state-bound call, keep the target as it is
381
+ # right now (the state the receipt bound and the check just confirmed).
382
+ if (state.snapshots is not None and isinstance(envelope, dict) and envelope.get("requires_approval")
383
+ and live_state is not None and isinstance(envelope.get("decision_id"), str)):
384
+ try:
385
+ desc = state.probe(name, args)
386
+ if desc is not None and state.config and state.config.get("principal"):
387
+ from gargoyle.state import read_target
388
+ state.snapshots.put(envelope["decision_id"], desc, read_target(desc), name)
389
+ except Exception as exc:
390
+ sys.stderr.write("gargoyle: snapshot failed: %s\n" % exc)
391
+
372
392
  state.executed.append({"tool": name, "args": args}) # THE side effect
373
393
  return {"executed": True, "reason": verdict.reason, "envelope": envelope, "grant": grant,
374
394
  "self_issued": self_issued}
@@ -419,6 +439,11 @@ def _attest(state: ServerState, envelope: Optional[Dict[str, Any]], outputs: Any
419
439
  from gargoyle.state import state_sha256 as _state_hash
420
440
  desc = state.probe(str(params.get("name")), params.get("arguments", {}))
421
441
  post = _state_hash(desc) if desc is not None else None
442
+ if post is not None and state.snapshots is not None and isinstance(envelope.get("decision_id"), str):
443
+ try:
444
+ state.snapshots.record_post(envelope["decision_id"], post)
445
+ except Exception:
446
+ pass
422
447
  return state.consumer.attest(envelope, outputs, post_state_sha256=post)
423
448
 
424
449
 
@@ -439,7 +464,12 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
439
464
  params = msg.get("params") or {}
440
465
 
441
466
  if state.upstream is not None and method in ("initialize", "tools/list"):
442
- return _relay(state, msg)
467
+ reply = _relay(state, msg)
468
+ if method == "tools/list" and state.snapshots is not None and isinstance(reply, dict) \
469
+ and isinstance(reply.get("result"), dict) and isinstance(reply["result"].get("tools"), list):
470
+ from gargoyle.revert import REVERT_TOOL_DEF
471
+ reply["result"]["tools"] = list(reply["result"]["tools"]) + [REVERT_TOOL_DEF]
472
+ return reply
443
473
 
444
474
  if method == "initialize":
445
475
  # The handshake completes when the client sends notifications/initialized;
@@ -477,9 +507,23 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
477
507
  return _error(msg_id, JSONRPC_NOT_INITIALIZED, "server not initialized")
478
508
 
479
509
  if method == "tools/list":
480
- return _success(msg_id, {"tools": ADVERTISED_TOOLS})
510
+ from gargoyle.revert import REVERT_TOOL_DEF
511
+ return _success(msg_id, {"tools": ADVERTISED_TOOLS + ([REVERT_TOOL_DEF] if state.snapshots is not None else [])})
481
512
 
482
513
  if method == "tools/call":
514
+ from gargoyle.revert import REVERT_TOOL, plan_revert
515
+ if params.get("name") == REVERT_TOOL:
516
+ # An undo is a write the middleware composes from its snapshot; it
517
+ # then goes through the gate exactly as the assistant's own writes.
518
+ if state.snapshots is None:
519
+ return _success(msg_id, {"content": [{"type": "text", "text": "refused: undo is not enabled"}], "isError": True,
520
+ "_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: "REF_REVERT_UNKNOWN"}})
521
+ plan = plan_revert(state.snapshots, str((params.get("arguments") or {}).get("decision_id") or "last"), state.probe)
522
+ if "reason" in plan:
523
+ return _success(msg_id, {"content": [{"type": "text", "text": "refused: " + plan["reason"]}], "isError": True,
524
+ "_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: plan["reason"]}})
525
+ params = plan["params"]
526
+ msg = dict(msg, params=params)
483
527
  gate = _gate(state, params)
484
528
  executed, reason = gate["executed"], gate["reason"]
485
529
  if executed and state.upstream is not None:
@@ -0,0 +1,124 @@
1
+ """Undo for an approved change: a revert is another state-bound write.
2
+
3
+ Before a held, state-bound call runs, the executor keeps the target's
4
+ bytes in a snapshot store beside the audit log, keyed by the state hash
5
+ the receipt bound, with a sidecar naming the decision, the target and,
6
+ after the run, the state the call left. `gargoyle_revert` is a tool the
7
+ middleware adds to the upstream's list: given a decision id (or "last"),
8
+ it builds the write that restores the snapshot and sends it through the
9
+ gate like any other write. So an undo is held, shown to the approver as a
10
+ diff from the current content back to the snapshot, bound to the current
11
+ state, signed, executed once, and recorded. The chain proves the
12
+ relationship without trusting any annotation: the revert's pre-state is
13
+ the original's post-state and its post-state is the original's pre-state;
14
+ `gargoyle audit` prints "reverts #N" when it sees that.
15
+
16
+ Refused before a card is ever raised:
17
+ REF_REVERT_UNKNOWN no snapshot for that decision (or nothing to revert)
18
+ REF_REVERT_STATE_CHANGED the target is no longer as the original call left it
19
+ REF_REVERT_NEEDS_DELETE the target did not exist before; undoing would delete
20
+ REF_REVERT_NOT_TEXT the snapshot is not UTF-8 text the write tool can carry
21
+
22
+ Text files only, and only files a write can restore. A revert of a create
23
+ would be a delete, which the gate refuses; a revert of a move would be a
24
+ move back, not built here.
25
+ """
26
+
27
+ import json
28
+ import os
29
+ import stat
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from typing import Any, Dict, List, Optional
33
+
34
+ from gargoyle.state import state_sha256
35
+
36
+ REVERT_TOOL = "gargoyle_revert"
37
+ REVERT_TOOL_DEF = {
38
+ "name": REVERT_TOOL,
39
+ "description": ("Undo an approved file change made through this gate: restores the file to its content "
40
+ "before that change. Held for approval like any write; refused if the file changed since. "
41
+ "decision_id: the change to undo, or \"last\" for the most recent approved change."),
42
+ "inputSchema": {"type": "object", "properties": {"decision_id": {"type": "string", "default": "last"}}},
43
+ }
44
+
45
+ REF_REVERT_UNKNOWN = "REF_REVERT_UNKNOWN"
46
+ 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
+
50
+
51
+ class SnapshotStore:
52
+ """Content by state hash, plus a sidecar per decision."""
53
+
54
+ def __init__(self, directory: str) -> None:
55
+ self.dir = Path(directory)
56
+
57
+ def _ensure(self) -> None:
58
+ self.dir.mkdir(parents=True, exist_ok=True)
59
+
60
+ def put(self, decision_id: str, description: Dict[str, Any], data: Optional[bytes], tool: str) -> Dict[str, Any]:
61
+ """Keep the pre-state of `decision_id`: the bytes (None when the target
62
+ did not exist) and a sidecar. Returns the sidecar."""
63
+ self._ensure()
64
+ key = state_sha256(description)
65
+ if data is not None:
66
+ blob = self.dir / (key + ".bin")
67
+ if not blob.exists():
68
+ blob.write_bytes(data)
69
+ try:
70
+ os.chmod(blob, stat.S_IRUSR | stat.S_IWUSR)
71
+ except OSError:
72
+ pass
73
+ meta = {"decision_id": decision_id, "tool": tool, "ref": description["ref"], "pre_state_sha256": key,
74
+ "existed": data is not None, "post_state_sha256": None, "at": datetime.now(timezone.utc).isoformat()}
75
+ (self.dir / (decision_id + ".json")).write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
76
+ return meta
77
+
78
+ def record_post(self, decision_id: str, post_state_sha256: Optional[str]) -> None:
79
+ p = self.dir / (decision_id + ".json")
80
+ if p.exists():
81
+ meta = json.loads(p.read_text(encoding="utf-8"))
82
+ meta["post_state_sha256"] = post_state_sha256
83
+ p.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
84
+
85
+ def sidecars(self) -> List[Dict[str, Any]]:
86
+ if not self.dir.exists():
87
+ return []
88
+ out = [json.loads(p.read_text(encoding="utf-8")) for p in self.dir.glob("*.json")]
89
+ return sorted(out, key=lambda m: m.get("at", ""))
90
+
91
+ def find(self, decision_id: str) -> Optional[Dict[str, Any]]:
92
+ if decision_id == "last":
93
+ done = [m for m in self.sidecars() if m.get("post_state_sha256")]
94
+ return done[-1] if done else None
95
+ p = self.dir / (decision_id + ".json")
96
+ return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
97
+
98
+ def content(self, pre_state_sha256: str) -> Optional[bytes]:
99
+ p = self.dir / (pre_state_sha256 + ".bin")
100
+ return p.read_bytes() if p.exists() else None
101
+
102
+
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
107
+ target is still as that call left it."""
108
+ meta = store.find(decision_id or "last")
109
+ if meta is None or not meta.get("post_state_sha256"):
110
+ 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:
115
+ 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"]:
123
+ return {"reason": REF_REVERT_STATE_CHANGED}
124
+ return {"params": {"name": "write_file", "arguments": args}, "reverts": meta["decision_id"]}
@@ -85,6 +85,17 @@ def filesystem_probe(tool: str, args: Any) -> Optional[Dict[str, Any]]:
85
85
  return describe(path, data)
86
86
 
87
87
 
88
+ def read_target(description: Dict[str, Any]) -> Optional[bytes]:
89
+ """The bytes behind a filesystem state description, or None when absent."""
90
+ if description.get("sha256") is None:
91
+ return None
92
+ try:
93
+ with open(description["ref"], "rb") as f:
94
+ return f.read()
95
+ except OSError:
96
+ return None
97
+
98
+
88
99
  def probe_from_name(name: Optional[str]) -> Optional[Callable[[str, Any], Optional[Dict[str, Any]]]]:
89
100
  """GARGOYLE_MCP_STATE_PROBE: "filesystem" (default) or "none"."""
90
101
  if name in (None, "", "filesystem"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.7.1.dev0
3
+ Version: 0.8.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.7.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.8.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
@@ -230,7 +230,12 @@ shows the proposed content as a diff against that state; after the human
230
230
  signs, the middleware hashes the target again and refuses if it moved; and
231
231
  the execution receipt carries the state before and after. An approval
232
232
  cannot be redirected by changing the file under it, and an audit shows not
233
- only that a write ran but what it changed.
233
+ only that a write ran but what it changed. An approved change can be
234
+ undone the same way: the middleware keeps what a held write replaced and
235
+ offers `gargoyle_revert`, which composes the restoring write and sends it
236
+ through the gate, held, shown as a diff, bound to the current state, and
237
+ refused if anything changed since. The audit marks a revert from the
238
+ state hashes alone.
234
239
 
235
240
  The MCP middleware also keeps an evidence log: one hash-chained line per
236
241
  decision, refusals and declined holds included, so the trail answers "what
@@ -314,6 +319,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
314
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 |
315
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 |
316
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_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 |
317
323
  | `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 |
318
324
 
319
325
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
@@ -26,6 +26,7 @@ gargoyle/pending_store.py
26
26
  gargoyle/policy.py
27
27
  gargoyle/published_record_source.py
28
28
  gargoyle/replay_cache.py
29
+ gargoyle/revert.py
29
30
  gargoyle/rules.py
30
31
  gargoyle/state.py
31
32
  gargoyle/verifier.py
@@ -60,6 +61,7 @@ tests/test_notify.py
60
61
  tests/test_opa.py
61
62
  tests/test_policy.py
62
63
  tests/test_published_record.py
64
+ tests/test_revert.py
63
65
  tests/test_rules.py
64
66
  tests/test_state.py
65
67
  tests/test_stdio_encoding.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.7.1.dev0"
7
+ version = "0.8.0.dev0"
8
8
  description = "Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,140 @@
1
+ """Undo: an approved write leaves a snapshot; gargoyle_revert composes the
2
+ write that restores it and sends it through the gate, held and state-bound.
3
+ Run against the real filesystem server as the upstream."""
4
+
5
+ import json
6
+ import sys
7
+ from datetime import datetime, timedelta, timezone
8
+ from pathlib import Path
9
+
10
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
11
+ from fastapi.testclient import TestClient
12
+
13
+ from gargoyle.approval import build_grant, sign_grant
14
+ from gargoyle.evidence import read_log, verify_log
15
+ from gargoyle.issuer_service import build_issuer_app
16
+ from gargoyle.jws import encode
17
+ from gargoyle.mcp_server import EXECUTED_META_KEY, REASON_META_KEY, IssuerClient, ServerState, StdioUpstream, handle_message
18
+ from gargoyle.policy import ELIGIBLE, Decision, refuse
19
+ from gargoyle.revert import (REF_REVERT_NEEDS_DELETE, REF_REVERT_STATE_CHANGED, REF_REVERT_UNKNOWN, REVERT_TOOL,
20
+ SnapshotStore, plan_revert)
21
+ from gargoyle.state import describe, filesystem_probe, state_sha256
22
+
23
+ issuer, executor, approver, idp = (Ed25519PrivateKey.generate() for _ in range(4))
24
+ PIN = {"policy_id": "p/1", "policy_sha256": "a" * 64}
25
+ TARGET = "mcp://fs"
26
+ PRINCIPAL = "agent-7"
27
+
28
+
29
+ class FsPolicy:
30
+ def pin(self): return PIN
31
+ def decide(self, target_url, interaction):
32
+ t = interaction.get("tool")
33
+ if t in ("write_file", "move_file", "create_directory"): return Decision(ELIGIBLE, None, True)
34
+ if t == "delete_file": return refuse("DESTRUCTIVE_TOOL")
35
+ return Decision(ELIGIBLE)
36
+
37
+
38
+ def token():
39
+ return encode({"sub": PRINCIPAL, "exp": int((datetime.now(timezone.utc) + timedelta(hours=1)).timestamp())}, idp, "idp-1")
40
+
41
+
42
+ def test_snapshot_store_and_plan(tmp_path):
43
+ store = SnapshotStore(str(tmp_path / "snap"))
44
+ f = tmp_path / "n.md"; f.write_bytes(b"one\n")
45
+ desc = describe(str(f), b"one\n")
46
+ meta = store.put("d-1", desc, b"one\n", "write_file")
47
+ assert meta["pre_state_sha256"] == state_sha256(desc) and meta["existed"] and meta["post_state_sha256"] is None
48
+ assert plan_revert(store, "d-1", filesystem_probe)["reason"] == REF_REVERT_UNKNOWN # not finished yet
49
+ f.write_bytes(b"two\n"); store.record_post("d-1", state_sha256(describe(str(f), b"two\n")))
50
+ plan = plan_revert(store, "last", filesystem_probe)
51
+ assert plan["reverts"] == "d-1" and plan["params"] == {"name": "write_file", "arguments": {"path": str(f), "content": "one\n"}}
52
+ f.write_bytes(b"three\n")
53
+ assert plan_revert(store, "d-1", filesystem_probe)["reason"] == REF_REVERT_STATE_CHANGED
54
+ assert plan_revert(store, "nope", filesystem_probe)["reason"] == REF_REVERT_UNKNOWN
55
+ store.put("d-2", describe(str(tmp_path / "new.md"), None), None, "write_file"); store.record_post("d-2", "b" * 64)
56
+ assert plan_revert(store, "d-2", filesystem_probe)["reason"] == REF_REVERT_NEEDS_DELETE
57
+
58
+
59
+ def _rig(tmp_path, root):
60
+ app = build_issuer_app(policy=FsPolicy(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()},
61
+ approver_public_keys={"approver-1": approver.public_key()})
62
+ c = TestClient(app); h = {"Authorization": "Bearer " + token()}
63
+ cards = []
64
+
65
+ class Shim:
66
+ def post(self, url, json=None, headers=None, timeout=None): return c.post(url.replace("http://i", ""), json=json, headers=headers)
67
+ def get(self, url, headers=None, timeout=None): return c.get(url.replace("http://i", ""), headers=headers)
68
+
69
+ def human(seconds):
70
+ for hold in c.get("/pending", headers=h).json()["pending"]:
71
+ cards.append(hold)
72
+ env = hold["envelope"]
73
+ g = sign_grant(build_grant(decision_sha256=env["decision_sha256"], approval_request_id=env["approval_request_id"],
74
+ grant_id="g-" + env["approval_request_id"], not_after=datetime.now(timezone.utc) + timedelta(minutes=5)),
75
+ approver, "approver-1")
76
+ c.post("/grants", json=g, headers=h)
77
+
78
+ up = StdioUpstream([sys.executable, "-I", "-m", "gargoyle.fs_server", str(root)])
79
+ state = ServerState({"pinned_public_keys": {"issuer-1": issuer.public_key()}, "record": PIN, "target_id": TARGET, "principal": PRINCIPAL,
80
+ "approver_public_keys": {"approver-1": approver.public_key()},
81
+ "executor_signing_key": executor, "executor_key_id": "executor-1"},
82
+ upstream=up, issuer_client=IssuerClient("http://i", token(), http=Shim(), approval_timeout=30, poll_seconds=0.01, sleep=human),
83
+ audit_log=str(tmp_path / "audit.jsonl"))
84
+ handle_message(state, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
85
+ handle_message(state, {"jsonrpc": "2.0", "method": "notifications/initialized"})
86
+ return state, up, cards
87
+
88
+
89
+ def call(state, n, name, args):
90
+ return handle_message(state, {"jsonrpc": "2.0", "id": n, "method": "tools/call", "params": {"name": name, "arguments": args}})
91
+
92
+
93
+ def test_revert_restores_an_approved_write_through_the_gate(tmp_path):
94
+ root = tmp_path / "root"; root.mkdir()
95
+ target = root / "notes.md"; target.write_text("one\n", encoding="utf-8")
96
+ state, up, cards = _rig(tmp_path, root)
97
+ try:
98
+ tools = handle_message(state, {"jsonrpc": "2.0", "id": 0, "method": "tools/list", "params": {}})["result"]["tools"]
99
+ assert any(t["name"] == REVERT_TOOL for t in tools) and any(t["name"] == "write_file" for t in tools)
100
+
101
+ r = call(state, 2, "write_file", {"path": str(target), "content": "two\n"})
102
+ assert r["result"]["_meta"][EXECUTED_META_KEY] is True and target.read_text(encoding="utf-8") == "two\n"
103
+ assert cards[-1]["state"]["text"] == "one\n"
104
+ snaps = list((tmp_path / "snapshots").glob("*.json")); assert len(snaps) == 1
105
+ assert json.loads(snaps[0].read_text())["post_state_sha256"] == state_sha256(describe(str(target), b"two\n"))
106
+
107
+ # Undo: held, shown as the restore, approved, executed, file back to "one".
108
+ r = call(state, 3, REVERT_TOOL, {"decision_id": "last"})
109
+ assert r["result"]["_meta"][EXECUTED_META_KEY] is True, r["result"]
110
+ assert target.read_text(encoding="utf-8") == "one\n"
111
+ assert cards[-1]["envelope"]["request_context"]["tool"] == "write_file" and cards[-1]["args"]["content"] == "one\n"
112
+ assert cards[-1]["state"]["text"] == "two\n" # the approver saw the current state
113
+ ex = r["result"]["_meta"]["gargoyle/execution"]
114
+ assert ex["pre_state_sha256"] == state_sha256(describe(str(target), b"two\n"))
115
+ assert ex["post_state_sha256"] == state_sha256(describe(str(target), b"one\n"))
116
+
117
+ # The audit recognises the revert from the hashes alone.
118
+ v = verify_log(read_log(str(tmp_path / "audit.jsonl")), issuer_public_keys={"issuer-1": issuer.public_key()},
119
+ executor_public_keys={"executor-1": executor.public_key()}, approver_public_keys={"approver-1": approver.public_key()})
120
+ assert v["accepted"]
121
+ writes = [e for e in v["entries"] if e["tool"] == "write_file"]
122
+ assert writes[0]["reverts"] is None and writes[1]["reverts"] == writes[0]["seq"]
123
+
124
+ # Nothing newer to revert: the revert itself finished, so "last" now points at it (a redo).
125
+ r = call(state, 4, REVERT_TOOL, {"decision_id": "last"})
126
+ assert r["result"]["_meta"][EXECUTED_META_KEY] is True and target.read_text(encoding="utf-8") == "two\n"
127
+
128
+ # Changed since: refused before any card.
129
+ n_cards = len(cards)
130
+ target.write_text("someone edited this\n", encoding="utf-8")
131
+ r = call(state, 5, REVERT_TOOL, {"decision_id": "last"})
132
+ assert r["result"]["isError"] and r["result"]["_meta"][REASON_META_KEY] == REF_REVERT_STATE_CHANGED and len(cards) == n_cards
133
+
134
+ # A create cannot be undone by a write.
135
+ new = root / "fresh.md"
136
+ assert call(state, 6, "write_file", {"path": str(new), "content": "x"})["result"]["_meta"][EXECUTED_META_KEY]
137
+ r = call(state, 7, REVERT_TOOL, {"decision_id": "last"})
138
+ assert r["result"]["_meta"][REASON_META_KEY] == REF_REVERT_NEEDS_DELETE and new.exists()
139
+ finally:
140
+ up.close()