gargoyle-receipts 0.7.0.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.
- {gargoyle_receipts-0.7.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.8.0.dev0}/PKG-INFO +9 -3
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/README.md +8 -2
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/cli.py +6 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/evidence.py +19 -3
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/mcp_server.py +47 -3
- gargoyle_receipts-0.8.0.dev0/gargoyle/revert.py +124 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/state.py +11 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +9 -3
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/pyproject.toml +1 -1
- gargoyle_receipts-0.8.0.dev0/tests/test_revert.py +140 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_state.py +10 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/issuer_service.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.7.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.8.0.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
23
23
|
|
|
24
|
-
Status: pre-release (`0.
|
|
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
|
|
@@ -246,6 +246,12 @@ def cmd_audit(args) -> int:
|
|
|
246
246
|
why = (" (%s)" % r["verdict"]) if r["kind"] == "refused" and r["verdict"] else ""
|
|
247
247
|
if r.get("released_by"):
|
|
248
248
|
why += " released by " + ", ".join(r["released_by"])
|
|
249
|
+
if r.get("state"):
|
|
250
|
+
# A state-bound call: the target as it was approved, and as the run left it.
|
|
251
|
+
pre, post = (r["state"].get("pre") or "")[:8], (r["state"].get("post") or "")[:8]
|
|
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"]
|
|
249
255
|
print("%4s %-5s %-8s %-24s %-28s %s%s" % (r["seq"] if r["seq"] is not None else "?",
|
|
250
256
|
"ok" if r["accepted"] else "FAIL", r["kind"] or "?",
|
|
251
257
|
(r["tool"] or "?")[:24], (r["principal"] or "?")[:28], r["reason"], why))
|
|
@@ -160,7 +160,8 @@ def verify_log(
|
|
|
160
160
|
{"accepted", "reason", "tail_sha256", "count", "honored", "refused",
|
|
161
161
|
"entries": [{"seq", "kind", "tool", "principal", "accepted", "reason", "verdict", "released_by"}, ...]},
|
|
162
162
|
`reason` the verification outcome, `verdict` what the executor recorded,
|
|
163
|
-
`released_by` the approver key ids of the grants an honored held call carried
|
|
163
|
+
`released_by` the approver key ids of the grants an honored held call carried,
|
|
164
|
+
`state` {"pre", "post"} the target's state hashes when the call was state-bound.
|
|
164
165
|
Verification continues past a bad entry so the report names every one."""
|
|
165
166
|
trust = dict(issuer_public_keys=issuer_public_keys, executor_public_keys=executor_public_keys,
|
|
166
167
|
approver_public_keys=approver_public_keys)
|
|
@@ -178,7 +179,7 @@ def verify_log(
|
|
|
178
179
|
rc = rc if isinstance(rc, dict) else {}
|
|
179
180
|
report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
|
|
180
181
|
"accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
|
|
181
|
-
"verdict": None, "released_by": []})
|
|
182
|
+
"verdict": None, "released_by": [], "state": None})
|
|
182
183
|
all_ok = False
|
|
183
184
|
continue
|
|
184
185
|
e = raw
|
|
@@ -206,13 +207,28 @@ def verify_log(
|
|
|
206
207
|
counts[e["kind"]] += 1
|
|
207
208
|
released_by = [g.get("approver_key_id") for g in (e["grant"] if isinstance(e["grant"], list) else [e["grant"]])
|
|
208
209
|
if isinstance(g, dict)] if e["grant"] is not None else []
|
|
210
|
+
ex = e["execution"] if isinstance(e["execution"], dict) else {}
|
|
211
|
+
state = ({"pre": ex.get("pre_state_sha256"), "post": ex.get("post_state_sha256")}
|
|
212
|
+
if ex.get("pre_state_sha256") or ex.get("post_state_sha256") else None)
|
|
209
213
|
report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
|
|
210
|
-
"accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by
|
|
214
|
+
"accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by,
|
|
215
|
+
"state": state})
|
|
211
216
|
all_ok = all_ok and ok
|
|
212
217
|
# The chain continues from what the file says, so one bad entry is
|
|
213
218
|
# reported once rather than cascading into every later one.
|
|
214
219
|
expect_seq, prev, tail = e["seq"] + 1, e["entry_sha256"], e["entry_sha256"]
|
|
215
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
|
|
216
232
|
return {"accepted": all_ok, "reason": ACCEPT_EVIDENCE_VERIFIED if all_ok else _first_failure(report),
|
|
217
233
|
"tail_sha256": tail, "count": len(report), "honored": counts[HONORED], "refused": counts[REFUSED],
|
|
218
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"
|
|
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
|
-
|
|
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
|
-
|
|
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"):
|
{gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
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
|
{gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|
+
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()
|
|
@@ -168,3 +168,13 @@ def test_middleware_refuses_when_the_file_moved_between_card_and_run(tmp_path):
|
|
|
168
168
|
chain = verify_chain(gate["envelope"], ex, grant=gate["grant"], issuer_public_keys=ISSUER_KEYS,
|
|
169
169
|
executor_public_keys={"executor-1": executor.public_key()}, approver_public_keys={"approver-1": approver.public_key()})
|
|
170
170
|
assert chain["reason"] == ACCEPT_CHAIN_VERIFIED
|
|
171
|
+
# The audit names the state transition.
|
|
172
|
+
from gargoyle.evidence import verify_log
|
|
173
|
+
entry = {"evidence_version": "gargoyle-evidence/2", "seq": 1, "prev_sha256": "0" * 64, "at": NOW.isoformat(), "kind": "honored",
|
|
174
|
+
"tool": "write_file", "args_sha256": gate["envelope"]["request_context"]["args_sha256"], "principal": "agent-7",
|
|
175
|
+
"reason": gate["reason"], "receipt": gate["envelope"], "grant": gate["grant"], "execution": ex}
|
|
176
|
+
from gargoyle.evidence import entry_sha256
|
|
177
|
+
entry["entry_sha256"] = entry_sha256(entry)
|
|
178
|
+
v = verify_log([entry], issuer_public_keys=ISSUER_KEYS, executor_public_keys={"executor-1": executor.public_key()},
|
|
179
|
+
approver_public_keys={"approver-1": approver.public_key()})
|
|
180
|
+
assert v["accepted"] and v["entries"][0]["state"] == {"pre": ex["pre_state_sha256"], "post": ex["post_state_sha256"]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/gargoyle/published_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.7.0.dev0 → gargoyle_receipts-0.8.0.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|