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.
Files changed (74) hide show
  1. {gargoyle_receipts-0.9.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.10.0.dev0}/PKG-INFO +6 -3
  2. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/README.md +5 -2
  3. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/cli.py +2 -0
  4. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/evidence.py +21 -8
  5. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/mcp_server.py +15 -1
  6. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/remote.py +2 -0
  7. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +6 -3
  8. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/pyproject.toml +1 -1
  9. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_evidence.py +19 -0
  10. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_mcp_self_issue.py +3 -1
  11. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_remote.py +1 -0
  12. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/LICENSE +0 -0
  13. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/NOTICE +0 -0
  14. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/__init__.py +0 -0
  15. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/approval.py +0 -0
  16. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/approver_cli.py +0 -0
  17. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/approver_trust.py +0 -0
  18. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/approvers.py +0 -0
  19. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/consumer.py +0 -0
  20. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/entra_login.py +0 -0
  21. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/envelope.py +0 -0
  22. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/execution.py +0 -0
  23. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/fs_server.py +0 -0
  24. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/identity.py +0 -0
  25. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/issuer.py +0 -0
  26. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/issuer_service.py +0 -0
  27. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/jws.py +0 -0
  28. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/key_record_source.py +0 -0
  29. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/notify.py +0 -0
  30. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/opa.py +0 -0
  31. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/pending_store.py +0 -0
  32. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/policy.py +0 -0
  33. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/published_record_source.py +0 -0
  34. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/replay_cache.py +0 -0
  35. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/revert.py +0 -0
  36. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/rules.py +0 -0
  37. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/state.py +0 -0
  38. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/static/approve.html +0 -0
  39. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/static/approve.js +0 -0
  40. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle/verifier.py +0 -0
  41. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
  42. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  43. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  44. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  45. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  46. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/setup.cfg +0 -0
  47. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_annotations.py +0 -0
  48. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_approver_cli.py +0 -0
  49. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_approvers.py +0 -0
  50. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_canonical.py +0 -0
  51. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_cli.py +0 -0
  52. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_console_js.py +0 -0
  53. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_consumer.py +0 -0
  54. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_entra_identity.py +0 -0
  55. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_entra_login.py +0 -0
  56. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_execution.py +0 -0
  57. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_fs_server.py +0 -0
  58. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_historical.py +0 -0
  59. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_identity.py +0 -0
  60. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_issuer_service.py +0 -0
  61. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_jws.py +0 -0
  62. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_mcp_proxy.py +0 -0
  63. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_mcp_server.py +0 -0
  64. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_notify.py +0 -0
  65. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_opa.py +0 -0
  66. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_policy.py +0 -0
  67. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_published_record.py +0 -0
  68. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_revert.py +0 -0
  69. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_rules.py +0 -0
  70. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_state.py +0 -0
  71. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_stdio_encoding.py +0 -0
  72. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_threshold.py +0 -0
  73. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.0.dev0}/tests/test_typescript_conformance.py +0 -0
  74. {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.10.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.9.0.dev0
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.9.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.9.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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/2" # entry hashes over RFC 8785 canonical JSON
52
- EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy, EVIDENCE_VERSION: canonical_json}
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) -> Dict[str, Any]:
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
- return (isinstance(entry, dict) and set(entry) == set(_FIELDS)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.9.0.dev0
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.9.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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.9.0.dev0"
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", "params": {}})
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