gargoyle-receipts 0.9.0.dev0__tar.gz → 0.10.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.9.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.10.0.dev0}/PKG-INFO +6 -3
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/README.md +5 -2
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/cli.py +2 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/evidence.py +21 -8
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/mcp_server.py +15 -1
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/remote.py +2 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +6 -3
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_evidence.py +19 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_mcp_self_issue.py +3 -1
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_remote.py +1 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/issuer_service.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.9.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.10.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.10.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.10.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
63
63
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
64
64
|
|
|
65
65
|
## What a receipt gives you
|
|
@@ -235,7 +235,10 @@ undone the same way: the middleware keeps what a held write replaced and
|
|
|
235
235
|
offers `gargoyle_revert`, which composes the restoring write and sends it
|
|
236
236
|
through the gate, held, shown as a diff, bound to the current state, and
|
|
237
237
|
refused if anything changed since. The audit marks a revert from the
|
|
238
|
-
state hashes alone.
|
|
238
|
+
state hashes alone. Every entry also names the MCP client that asked, from
|
|
239
|
+
its initialize handshake, so a fleet with Claude on some desks and ChatGPT
|
|
240
|
+
on others still has one trail that says who, through what, did what. The
|
|
241
|
+
receipt itself stays model-agnostic.
|
|
239
242
|
|
|
240
243
|
The MCP middleware also keeps an evidence log: one hash-chained line per
|
|
241
244
|
decision, refusals and declined holds included, so the trail answers "what
|
|
@@ -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.10.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
25
25
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
26
26
|
|
|
27
27
|
## What a receipt gives you
|
|
@@ -197,7 +197,10 @@ undone the same way: the middleware keeps what a held write replaced and
|
|
|
197
197
|
offers `gargoyle_revert`, which composes the restoring write and sends it
|
|
198
198
|
through the gate, held, shown as a diff, bound to the current state, and
|
|
199
199
|
refused if anything changed since. The audit marks a revert from the
|
|
200
|
-
state hashes alone.
|
|
200
|
+
state hashes alone. Every entry also names the MCP client that asked, from
|
|
201
|
+
its initialize handshake, so a fleet with Claude on some desks and ChatGPT
|
|
202
|
+
on others still has one trail that says who, through what, did what. The
|
|
203
|
+
receipt itself stays model-agnostic.
|
|
201
204
|
|
|
202
205
|
The MCP middleware also keeps an evidence log: one hash-chained line per
|
|
203
206
|
decision, refusals and declined holds included, so the trail answers "what
|
|
@@ -316,6 +316,8 @@ def cmd_audit(args) -> int:
|
|
|
316
316
|
why += " state %s -> %s" % (pre or "?", post or "?") if post else " state %s" % pre
|
|
317
317
|
if r.get("reverts"):
|
|
318
318
|
why += " reverts #%d" % r["reverts"]
|
|
319
|
+
if r.get("client"):
|
|
320
|
+
why += " via " + r["client"]
|
|
319
321
|
print("%4s %-5s %-8s %-24s %-28s %s%s" % (r["seq"] if r["seq"] is not None else "?",
|
|
320
322
|
"ok" if r["accepted"] else "FAIL", r["kind"] or "?",
|
|
321
323
|
(r["tool"] or "?")[:24], (r["principal"] or "?")[:28], r["reason"], why))
|
|
@@ -48,8 +48,9 @@ from gargoyle.envelope import args_sha256, canonical_json, canonical_json_legacy
|
|
|
48
48
|
from gargoyle.execution import verify_chain
|
|
49
49
|
from gargoyle.verifier import verify_provenance
|
|
50
50
|
|
|
51
|
-
EVIDENCE_VERSION = "gargoyle-evidence/
|
|
52
|
-
EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy,
|
|
51
|
+
EVIDENCE_VERSION = "gargoyle-evidence/3" # version 3 adds `client`; hashes over RFC 8785 canonical JSON
|
|
52
|
+
EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy, "gargoyle-evidence/2": canonical_json,
|
|
53
|
+
EVIDENCE_VERSION: canonical_json}
|
|
53
54
|
GENESIS = "0" * 64
|
|
54
55
|
|
|
55
56
|
HONORED = "honored"
|
|
@@ -65,6 +66,11 @@ REF_EVIDENCE_UNCHAINED = "REF_EVIDENCE_UNCHAINED" # a pre-chain {receipt,
|
|
|
65
66
|
|
|
66
67
|
_FIELDS = ("evidence_version", "seq", "prev_sha256", "at", "kind", "tool", "args_sha256",
|
|
67
68
|
"principal", "reason", "receipt", "grant", "execution", "entry_sha256")
|
|
69
|
+
# Version 3 names the MCP client that made the call ("Claude Desktop 1.2", "ChatGPT"),
|
|
70
|
+
# from the initialize handshake; null when the client did not say. The
|
|
71
|
+
# receipt stays model-agnostic: this is evidence about the session, not a
|
|
72
|
+
# bound field of the decision.
|
|
73
|
+
_FIELDS_V3 = _FIELDS + ("client",)
|
|
68
74
|
|
|
69
75
|
|
|
70
76
|
def entry_sha256(entry: Dict[str, Any]) -> str:
|
|
@@ -78,9 +84,11 @@ def entry_sha256(entry: Dict[str, Any]) -> str:
|
|
|
78
84
|
|
|
79
85
|
def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any, principal: str,
|
|
80
86
|
reason: str, receipt: Any = None, grant: Any = None, execution: Any = None,
|
|
81
|
-
at: Optional[datetime] = None) -> Dict[str, Any]:
|
|
87
|
+
at: Optional[datetime] = None, client: Optional[str] = None) -> Dict[str, Any]:
|
|
82
88
|
if kind not in (HONORED, REFUSED):
|
|
83
89
|
raise ValueError("kind must be honored or refused")
|
|
90
|
+
if client is not None and not isinstance(client, str):
|
|
91
|
+
raise ValueError("client must be a string or None")
|
|
84
92
|
entry = {
|
|
85
93
|
"evidence_version": EVIDENCE_VERSION,
|
|
86
94
|
"seq": seq,
|
|
@@ -94,6 +102,7 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
|
|
|
94
102
|
"receipt": receipt,
|
|
95
103
|
"grant": grant,
|
|
96
104
|
"execution": execution,
|
|
105
|
+
"client": client,
|
|
97
106
|
}
|
|
98
107
|
entry["entry_sha256"] = entry_sha256(entry)
|
|
99
108
|
return entry
|
|
@@ -121,13 +130,15 @@ class EvidenceLog:
|
|
|
121
130
|
self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
|
|
122
131
|
|
|
123
132
|
def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
|
|
124
|
-
receipt: Any = None, grant: Any = None, execution: Any = None
|
|
133
|
+
receipt: Any = None, grant: Any = None, execution: Any = None,
|
|
134
|
+
client: Optional[str] = None) -> Dict[str, Any]:
|
|
125
135
|
"""Write one entry and return it. Raises OSError if the file cannot
|
|
126
136
|
be written; the caller decides whether that stops the call."""
|
|
127
137
|
if self._seq is None:
|
|
128
138
|
self._load_tail()
|
|
129
139
|
entry = build_entry(seq=self._seq + 1, prev_sha256=self._tail, kind=kind, tool=tool, args=args,
|
|
130
|
-
principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution
|
|
140
|
+
principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution,
|
|
141
|
+
client=client)
|
|
131
142
|
with open(self.path, "a", encoding="utf-8") as f:
|
|
132
143
|
f.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
133
144
|
self._seq, self._tail = entry["seq"], entry["entry_sha256"]
|
|
@@ -135,8 +146,10 @@ class EvidenceLog:
|
|
|
135
146
|
|
|
136
147
|
|
|
137
148
|
def _well_formed(entry: Any) -> bool:
|
|
138
|
-
|
|
149
|
+
expected = _FIELDS_V3 if isinstance(entry, dict) and entry.get("evidence_version") == EVIDENCE_VERSION else _FIELDS
|
|
150
|
+
return (isinstance(entry, dict) and set(entry) == set(expected)
|
|
139
151
|
and entry["evidence_version"] in EVIDENCE_VERSIONS
|
|
152
|
+
and (entry.get("client") is None or isinstance(entry.get("client"), str))
|
|
140
153
|
and isinstance(entry["seq"], int) and entry["seq"] >= 1
|
|
141
154
|
and isinstance(entry["prev_sha256"], str) and len(entry["prev_sha256"]) == 64
|
|
142
155
|
and isinstance(entry["at"], str) and entry["kind"] in (HONORED, REFUSED)
|
|
@@ -179,7 +192,7 @@ def verify_log(
|
|
|
179
192
|
rc = rc if isinstance(rc, dict) else {}
|
|
180
193
|
report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
|
|
181
194
|
"accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
|
|
182
|
-
"verdict": None, "released_by": [], "state": None})
|
|
195
|
+
"verdict": None, "released_by": [], "state": None, "client": None})
|
|
183
196
|
all_ok = False
|
|
184
197
|
continue
|
|
185
198
|
e = raw
|
|
@@ -212,7 +225,7 @@ def verify_log(
|
|
|
212
225
|
if ex.get("pre_state_sha256") or ex.get("post_state_sha256") else None)
|
|
213
226
|
report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
|
|
214
227
|
"accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by,
|
|
215
|
-
"state": state})
|
|
228
|
+
"state": state, "client": e.get("client")})
|
|
216
229
|
all_ok = all_ok and ok
|
|
217
230
|
# The chain continues from what the file says, so one bad entry is
|
|
218
231
|
# reported once rather than cascading into every later one.
|
|
@@ -267,6 +267,15 @@ def _without_gargoyle_meta(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
267
267
|
return forwarded
|
|
268
268
|
|
|
269
269
|
|
|
270
|
+
def client_label(params: Any) -> Optional[str]:
|
|
271
|
+
""""name version" from an initialize request's clientInfo, or None."""
|
|
272
|
+
info = params.get("clientInfo") if isinstance(params, dict) else None
|
|
273
|
+
if not isinstance(info, dict) or not isinstance(info.get("name"), str) or not info["name"]:
|
|
274
|
+
return None
|
|
275
|
+
version = info.get("version")
|
|
276
|
+
return info["name"] + (" " + version if isinstance(version, str) and version else "")
|
|
277
|
+
|
|
278
|
+
|
|
270
279
|
def call_for(tool: str, args: Any, principal: str, state_sha256: Optional[str] = None) -> Dict[str, Any]:
|
|
271
280
|
"""The call a receipt must bind to execute `tool` with `args` through a
|
|
272
281
|
server serving `principal`: the same three fields the issuer bound, plus
|
|
@@ -319,6 +328,7 @@ class ServerState:
|
|
|
319
328
|
)
|
|
320
329
|
self.executed: List[Dict[str, Any]] = []
|
|
321
330
|
self.initialized = False
|
|
331
|
+
self.client: Optional[str] = None # "name version" from the MCP initialize handshake
|
|
322
332
|
|
|
323
333
|
|
|
324
334
|
def gate_tool_call(state: ServerState, params: Dict[str, Any]) -> (bool, str):
|
|
@@ -422,7 +432,8 @@ def _record(state: ServerState, kind: str, gate: Dict[str, Any], params: Dict[st
|
|
|
422
432
|
principal = (state.config or {}).get("principal") or ""
|
|
423
433
|
try:
|
|
424
434
|
state.evidence.append(kind, str(params.get("name")), params.get("arguments", {}), principal,
|
|
425
|
-
gate["reason"], receipt=gate["envelope"], grant=gate["grant"], execution=execution
|
|
435
|
+
gate["reason"], receipt=gate["envelope"], grant=gate["grant"], execution=execution,
|
|
436
|
+
client=state.client)
|
|
426
437
|
except (OSError, ValueError) as exc:
|
|
427
438
|
sys.stderr.write("gargoyle: evidence log unwritable: %s\n" % exc)
|
|
428
439
|
|
|
@@ -464,6 +475,8 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
|
|
|
464
475
|
params = msg.get("params") or {}
|
|
465
476
|
|
|
466
477
|
if state.upstream is not None and method in ("initialize", "tools/list"):
|
|
478
|
+
if method == "initialize":
|
|
479
|
+
state.client = client_label(params)
|
|
467
480
|
reply = _relay(state, msg)
|
|
468
481
|
if method == "tools/list" and state.snapshots is not None and isinstance(reply, dict) \
|
|
469
482
|
and isinstance(reply.get("result"), dict) and isinstance(reply["result"].get("tools"), list):
|
|
@@ -472,6 +485,7 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
|
|
|
472
485
|
return reply
|
|
473
486
|
|
|
474
487
|
if method == "initialize":
|
|
488
|
+
state.client = client_label(params)
|
|
475
489
|
# The handshake completes when the client sends notifications/initialized;
|
|
476
490
|
# tools are served only after that.
|
|
477
491
|
return _success(
|
|
@@ -194,6 +194,8 @@ def build_remote_app(
|
|
|
194
194
|
def handle_one(st: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
195
195
|
method = msg.get("method")
|
|
196
196
|
if method == "initialize":
|
|
197
|
+
from gargoyle.mcp_server import client_label
|
|
198
|
+
st.client = client_label(msg.get("params"))
|
|
197
199
|
return {"jsonrpc": "2.0", "id": msg.get("id"), "result": st.upstream_init} # type: ignore[attr-defined]
|
|
198
200
|
if method == "notifications/initialized":
|
|
199
201
|
st.initialized = True
|
{gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.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.10.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.10.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
63
63
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
64
64
|
|
|
65
65
|
## What a receipt gives you
|
|
@@ -235,7 +235,10 @@ undone the same way: the middleware keeps what a held write replaced and
|
|
|
235
235
|
offers `gargoyle_revert`, which composes the restoring write and sends it
|
|
236
236
|
through the gate, held, shown as a diff, bound to the current state, and
|
|
237
237
|
refused if anything changed since. The audit marks a revert from the
|
|
238
|
-
state hashes alone.
|
|
238
|
+
state hashes alone. Every entry also names the MCP client that asked, from
|
|
239
|
+
its initialize handshake, so a fleet with Claude on some desks and ChatGPT
|
|
240
|
+
on others still has one trail that says who, through what, did what. The
|
|
241
|
+
receipt itself stays model-agnostic.
|
|
239
242
|
|
|
240
243
|
The MCP middleware also keeps an evidence log: one hash-chained line per
|
|
241
244
|
decision, refusals and declined holds included, so the trail answers "what
|
|
@@ -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.10.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"
|
|
@@ -186,3 +186,22 @@ def test_an_honored_held_call_reports_who_released_it(tmp_path):
|
|
|
186
186
|
with contextlib.redirect_stdout(buf):
|
|
187
187
|
assert main(["audit", "--log", str(log.path), "--out", str(out)]) == 0
|
|
188
188
|
assert "CHAIN_VERIFIED released by sysops@contoso.com" in buf.getvalue()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_entries_name_the_client_and_older_versions_still_verify(tmp_path):
|
|
192
|
+
from gargoyle.evidence import EVIDENCE_VERSION, _well_formed
|
|
193
|
+
log = EvidenceLog(str(tmp_path / "e.jsonl"))
|
|
194
|
+
e = log.append(REFUSED, "delete_file", {}, PRINCIPAL, "DESTRUCTIVE_TOOL", client="Claude Desktop 1.4")
|
|
195
|
+
assert e["evidence_version"] == EVIDENCE_VERSION and e["client"] == "Claude Desktop 1.4"
|
|
196
|
+
anon = log.append(REFUSED, "delete_file", {}, PRINCIPAL, "DESTRUCTIVE_TOOL")
|
|
197
|
+
assert anon["client"] is None
|
|
198
|
+
v = verify_log(read_log(str(log.path)), **TRUST)
|
|
199
|
+
assert v["accepted"] and [r["client"] for r in v["entries"]] == ["Claude Desktop 1.4", None]
|
|
200
|
+
# A version-2 entry (no client member) is still well-formed; a version-3 entry without it is not.
|
|
201
|
+
v2 = {k: val for k, val in anon.items() if k != "client"}; v2["evidence_version"] = "gargoyle-evidence/2"
|
|
202
|
+
v2["entry_sha256"] = entry_sha256(v2)
|
|
203
|
+
assert _well_formed(v2)
|
|
204
|
+
v3_missing = {k: val for k, val in anon.items() if k != "client"}
|
|
205
|
+
assert not _well_formed(v3_missing)
|
|
206
|
+
with pytest.raises(ValueError):
|
|
207
|
+
build_entry(seq=1, prev_sha256=GENESIS, kind=REFUSED, tool="t", args={}, principal=PRINCIPAL, reason="x", client=7)
|
|
@@ -206,7 +206,8 @@ def test_every_decision_lands_in_the_evidence_log(tmp_path):
|
|
|
206
206
|
state = middleware(client, sleep=human_declines, approval_timeout=60, attest=True)
|
|
207
207
|
from gargoyle.evidence import EvidenceLog
|
|
208
208
|
state.evidence = EvidenceLog(str(tmp_path / "e.jsonl"))
|
|
209
|
-
handle_message(state, {"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
209
|
+
handle_message(state, {"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
210
|
+
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "ChatGPT", "version": "2.1"}}})
|
|
210
211
|
handle_message(state, {"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
211
212
|
ok = handle_message(state, {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": call("read_file", {"path": "a"})})
|
|
212
213
|
no = handle_message(state, {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": call("delete_file", {"path": "a"})})
|
|
@@ -218,6 +219,7 @@ def test_every_decision_lands_in_the_evidence_log(tmp_path):
|
|
|
218
219
|
|
|
219
220
|
entries = read_log(str(tmp_path / "e.jsonl"))
|
|
220
221
|
assert [e["kind"] for e in entries] == [HONORED, REFUSED, REFUSED]
|
|
222
|
+
assert {e["client"] for e in entries} == {"ChatGPT 2.1"} # which assistant asked, every entry
|
|
221
223
|
assert [e["seq"] for e in entries] == [1, 2, 3] and entries[2]["prev_sha256"] == entries[1]["entry_sha256"]
|
|
222
224
|
assert entries[1]["receipt"] is None # policy said no: nothing was issued
|
|
223
225
|
assert entries[2]["receipt"]["requires_approval"] is True # the human said no: the hold is on record
|
|
@@ -101,6 +101,7 @@ def test_remote_gate_over_stdio_upstream(tmp_path):
|
|
|
101
101
|
assert r["result"]["_meta"]["gargoyle/receipt"]["request_context"]["principal"] == "bob@contoso.com"
|
|
102
102
|
lines = [json.loads(l) for l in (tmp_path / "audit.jsonl").read_text(encoding="utf-8").splitlines()]
|
|
103
103
|
assert [e["principal"] for e in lines] == ["alice@contoso.com"] * 3 + ["bob@contoso.com"] and [e["seq"] for e in lines] == [1, 2, 3, 4]
|
|
104
|
+
assert [e["client"] for e in lines] == ["t 0"] * 3 + [None] # alice's client named itself; bob's did not
|
|
104
105
|
# No session: told to initialize; DELETE ends one.
|
|
105
106
|
assert rpc(c, alice, {"jsonrpc": "2.0", "id": 9, "method": "tools/list", "params": {}}, "stale").status_code == 404
|
|
106
107
|
assert c.delete("/mcp/" + alice, headers={SESSION_HEADER: sid}).status_code == 200
|
|
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.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/key_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.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
|
|
File without changes
|
{gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|