gargoyle-receipts 0.6.1.dev0__tar.gz → 0.7.1.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 (70) hide show
  1. {gargoyle_receipts-0.6.1.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.7.1.dev0}/PKG-INFO +12 -2
  2. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/README.md +11 -1
  3. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/cli.py +4 -0
  4. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/consumer.py +3 -2
  5. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/envelope.py +5 -0
  6. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/evidence.py +8 -3
  7. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/execution.py +20 -2
  8. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/issuer_service.py +16 -0
  9. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/mcp_server.py +43 -13
  10. gargoyle_receipts-0.7.1.dev0/gargoyle/state.py +94 -0
  11. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/static/approve.html +26 -1
  12. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/static/approve.js +18 -0
  13. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/verifier.py +11 -1
  14. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0/gargoyle_receipts.egg-info}/PKG-INFO +12 -2
  15. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
  16. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/pyproject.toml +1 -1
  17. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_cli.py +0 -1
  18. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_evidence.py +35 -0
  19. gargoyle_receipts-0.7.1.dev0/tests/test_state.py +180 -0
  20. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/LICENSE +0 -0
  21. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/NOTICE +0 -0
  22. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/__init__.py +0 -0
  23. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/approval.py +0 -0
  24. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/approver_cli.py +0 -0
  25. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/approver_trust.py +0 -0
  26. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/approvers.py +0 -0
  27. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/entra_login.py +0 -0
  28. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/fs_server.py +0 -0
  29. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/identity.py +0 -0
  30. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/issuer.py +0 -0
  31. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/jws.py +0 -0
  32. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/key_record_source.py +0 -0
  33. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/notify.py +0 -0
  34. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/opa.py +0 -0
  35. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/pending_store.py +0 -0
  36. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/policy.py +0 -0
  37. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/published_record_source.py +0 -0
  38. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/replay_cache.py +0 -0
  39. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/rules.py +0 -0
  40. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  41. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  42. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  43. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  44. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/setup.cfg +0 -0
  45. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_annotations.py +0 -0
  46. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_approver_cli.py +0 -0
  47. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_approvers.py +0 -0
  48. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_canonical.py +0 -0
  49. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_console_js.py +0 -0
  50. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_consumer.py +0 -0
  51. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_entra_identity.py +0 -0
  52. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_entra_login.py +0 -0
  53. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_execution.py +0 -0
  54. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_fs_server.py +0 -0
  55. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_historical.py +0 -0
  56. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_identity.py +0 -0
  57. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_issuer_service.py +0 -0
  58. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_jws.py +0 -0
  59. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_mcp_proxy.py +0 -0
  60. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_mcp_self_issue.py +0 -0
  61. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_mcp_server.py +0 -0
  62. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_notify.py +0 -0
  63. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_opa.py +0 -0
  64. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_policy.py +0 -0
  65. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_published_record.py +0 -0
  66. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_rules.py +0 -0
  67. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_stdio_encoding.py +0 -0
  68. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_threshold.py +0 -0
  69. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_typescript_conformance.py +0 -0
  70. {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.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.6.1.dev0
3
+ Version: 0.7.1.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.6.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.7.1.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
@@ -223,6 +223,15 @@ policy version authorized a specific call for a specific principal, a
223
223
  human's key released it, and exactly that call ran once, verifiable offline
224
224
  by anyone, in Python or TypeScript.
225
225
 
226
+ A held call is approved against the state it was shown in. Before asking
227
+ for the receipt the middleware hashes the target (a file's bytes, or its
228
+ absence); the issuer binds that hash into the signed receipt; the console
229
+ shows the proposed content as a diff against that state; after the human
230
+ signs, the middleware hashes the target again and refuses if it moved; and
231
+ the execution receipt carries the state before and after. An approval
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.
234
+
226
235
  The MCP middleware also keeps an evidence log: one hash-chained line per
227
236
  decision, refusals and declined holds included, so the trail answers "what
228
237
  did the agent try" as well as "what did it do". `gargoyle audit --log`
@@ -304,6 +313,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
304
313
  | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
305
314
  | 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 |
306
315
  | `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
+ | `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 |
307
317
  | `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 |
308
318
 
309
319
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
@@ -21,7 +21,7 @@ your MCP server and lets through only what both agreed to.
21
21
 
22
22
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.6.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.7.1.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
@@ -185,6 +185,15 @@ policy version authorized a specific call for a specific principal, a
185
185
  human's key released it, and exactly that call ran once, verifiable offline
186
186
  by anyone, in Python or TypeScript.
187
187
 
188
+ A held call is approved against the state it was shown in. Before asking
189
+ for the receipt the middleware hashes the target (a file's bytes, or its
190
+ absence); the issuer binds that hash into the signed receipt; the console
191
+ shows the proposed content as a diff against that state; after the human
192
+ signs, the middleware hashes the target again and refuses if it moved; and
193
+ the execution receipt carries the state before and after. An approval
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.
196
+
188
197
  The MCP middleware also keeps an evidence log: one hash-chained line per
189
198
  decision, refusals and declined holds included, so the trail answers "what
190
199
  did the agent try" as well as "what did it do". `gargoyle audit --log`
@@ -266,6 +275,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
266
275
  | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
267
276
  | 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 |
268
277
  | `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
+ | `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 |
269
279
  | `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 |
270
280
 
271
281
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
@@ -246,6 +246,10 @@ 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
249
253
  print("%4s %-5s %-8s %-24s %-28s %s%s" % (r["seq"] if r["seq"] is not None else "?",
250
254
  "ok" if r["accepted"] else "FAIL", r["kind"] or "?",
251
255
  (r["tool"] or "?")[:24], (r["principal"] or "?")[:28], r["reason"], why))
@@ -241,13 +241,14 @@ class Consumer:
241
241
  return self._executor_signing_key is not None
242
242
 
243
243
  def attest(self, envelope: Dict[str, Any], outputs: Any,
244
- executed_at: Optional[datetime] = None) -> Dict[str, Any]:
244
+ executed_at: Optional[datetime] = None, post_state_sha256: Optional[str] = None) -> Dict[str, Any]:
245
245
  """The signed execution receipt for a call this consumer honored and
246
246
  the caller has just run with `outputs`. Raises when no executor key
247
247
  is configured: an unsigned statement of execution is not evidence."""
248
248
  if not self.attests:
249
249
  raise ValueError("this consumer has no executor signing key; cannot attest")
250
- execution = build_execution_receipt(envelope=envelope, outputs=outputs, executed_at=executed_at)
250
+ execution = build_execution_receipt(envelope=envelope, outputs=outputs, executed_at=executed_at,
251
+ post_state_sha256=post_state_sha256)
251
252
  return sign_execution_receipt(execution, self._executor_signing_key, self.executor_key_id)
252
253
 
253
254
  def _approver_keys(self, key_record_view, gate_key_id, now) -> Dict[str, Any]:
@@ -289,6 +289,9 @@ def build_envelope(
289
289
  on_behalf_of = call.get("on_behalf_of")
290
290
  if on_behalf_of is not None and not valid_principal(on_behalf_of):
291
291
  raise ValueError("call['on_behalf_of'], when present, must be a non-empty string")
292
+ state_hash = call.get("state_sha256")
293
+ if state_hash is not None and not (isinstance(state_hash, str) and len(state_hash) == 64):
294
+ raise ValueError("call['state_sha256'], when present, must be 64 hex characters")
292
295
  args_hash = args_sha256(call["args"])
293
296
  if timestamp_utc is None:
294
297
  timestamp_utc = datetime.now(timezone.utc).isoformat()
@@ -311,6 +314,8 @@ def build_envelope(
311
314
  }
312
315
  if on_behalf_of is not None:
313
316
  envelope["request_context"]["on_behalf_of"] = on_behalf_of
317
+ if state_hash is not None:
318
+ envelope["request_context"]["state_sha256"] = state_hash
314
319
  if approval is not None:
315
320
  envelope["approval"] = {"required": approval["required"]}
316
321
  if "approvers" in approval:
@@ -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,8 +207,12 @@ 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.
@@ -78,11 +78,14 @@ def build_execution_receipt(
78
78
  envelope: Dict[str, Any],
79
79
  outputs: Any,
80
80
  executed_at: Optional[datetime] = None,
81
+ post_state_sha256: Optional[str] = None,
81
82
  ) -> Dict[str, Any]:
82
83
  """The unsigned execution receipt for a call the executor has just run
83
84
  under `envelope` (a receipt it honored, so decision_id is present) with
84
85
  `outputs` (any JSON value; only its hash is recorded). `executed_at`
85
- must be timezone-aware; it defaults to now."""
86
+ must be timezone-aware; it defaults to now. When the receipt bound the
87
+ target's state, `pre_state_sha256` is copied from it and
88
+ `post_state_sha256` (gargoyle.state) records the state after the run."""
86
89
  if not isinstance(envelope, dict):
87
90
  raise TypeError("envelope must be the honored authorization receipt")
88
91
  for key in _BOUND_FROM_RECEIPT:
@@ -94,7 +97,9 @@ def build_execution_receipt(
94
97
  executed_at = datetime.now(timezone.utc)
95
98
  if executed_at.tzinfo is None:
96
99
  raise ValueError("executed_at must be timezone-aware (UTC)")
97
- return {
100
+ if post_state_sha256 is not None and not (isinstance(post_state_sha256, str) and len(post_state_sha256) == 64):
101
+ raise ValueError("post_state_sha256 must be 64 hex characters")
102
+ out = {
98
103
  "execution_version": EXECUTION_VERSION,
99
104
  "decision_id": envelope["decision_id"],
100
105
  "decision_sha256": envelope["decision_sha256"],
@@ -104,6 +109,12 @@ def build_execution_receipt(
104
109
  "outputs_sha256": args_sha256(outputs),
105
110
  "executed_at": executed_at.isoformat(),
106
111
  }
112
+ pre = envelope["request_context"].get("state_sha256")
113
+ if pre is not None:
114
+ out["pre_state_sha256"] = pre
115
+ if post_state_sha256 is not None:
116
+ out["post_state_sha256"] = post_state_sha256
117
+ return out
107
118
 
108
119
 
109
120
  def sign_execution_receipt(execution: Dict[str, Any], signing_key: Any, key_id: str) -> Dict[str, Any]:
@@ -156,6 +167,13 @@ def verify_execution_receipt(
156
167
  for key in _BOUND_FROM_RECEIPT:
157
168
  if key not in envelope or serialize(execution[key]) != serialize(envelope[key]):
158
169
  return _reject(REF_EXEC_BINDING_MISMATCH)
170
+ # A state-bound receipt's execution receipt names the same pre-state.
171
+ pre = envelope["request_context"].get("state_sha256") if isinstance(envelope.get("request_context"), dict) else None
172
+ if (pre is not None or "pre_state_sha256" in execution) and execution.get("pre_state_sha256") != pre:
173
+ return _reject(REF_EXEC_BINDING_MISMATCH)
174
+ post = execution.get("post_state_sha256")
175
+ if post is not None and not (isinstance(post, str) and len(post) == 64):
176
+ return _reject(REF_EXEC_MALFORMED)
159
177
  if check_outputs:
160
178
  try:
161
179
  if args_sha256(outputs) != execution["outputs_sha256"]:
@@ -52,6 +52,7 @@ STATIC = Path(__file__).resolve().parent / "static"
52
52
  from gargoyle.approval import (REF_APPROVAL_APPROVER_NOT_ELIGIBLE, REF_APPROVAL_DUPLICATE_APPROVER,
53
53
  verify_grant)
54
54
  from gargoyle.envelope import approval_terms
55
+ from gargoyle.state import state_sha256, valid_description
55
56
  from gargoyle.notify import notifier_from_env
56
57
  from gargoyle.approvers import (DEFAULT_APPROVER_ROLE, ENV_APPROVER_GROUPS, ENV_APPROVER_ROLE, ENV_APPROVERS_PATH,
57
58
  ApproverRegistry, may_enroll, valid_public_key_hex)
@@ -104,6 +105,7 @@ def build_issuer_app(
104
105
  app = FastAPI(title="Gargoyle issuing service")
105
106
  app.state.pending: Dict[str, Dict[str, Any]] = {} # approval_request_id -> envelope
106
107
  app.state.pending_args: Dict[str, Any] = {} # approval_request_id -> the call's arguments
108
+ app.state.pending_state: Dict[str, Any] = {} # approval_request_id -> the target's state when held
107
109
  app.state.grants: Dict[str, Dict[str, Any]] = {} # approval_request_id -> grant
108
110
  app.state.declined: set = set() # approval_request_ids a human refused
109
111
  registry = approver_registry if approver_registry is not None else ApproverRegistry(None)
@@ -147,6 +149,15 @@ def build_issuer_app(
147
149
  interaction = {**body.get("claims", {}), "tool": body["tool"], "args": body["args"],
148
150
  "principal": who["principal"], "groups": who.get("groups", []),
149
151
  "roles": who.get("roles", [])}
152
+ state = body.get("state")
153
+ if state is not None:
154
+ # The executor's description of the target before the call: its
155
+ # hash is bound into the receipt; the description is shown to
156
+ # the approver and seen by the policy.
157
+ if not valid_description(state):
158
+ raise _refuse(400, REF_ISSUER_BAD_REQUEST)
159
+ interaction["state_sha256"] = state_sha256(state)
160
+ interaction["state"] = {k: v for k, v in state.items() if k != "text"}
150
161
  if who["on_behalf_of"]:
151
162
  interaction["on_behalf_of"] = who["on_behalf_of"]
152
163
  result = issue(policy, body["target_url"], interaction, signing_key=signing_key, key_id=key_id,
@@ -157,6 +168,8 @@ def build_issuer_app(
157
168
  arid = result.envelope["approval_request_id"]
158
169
  app.state.pending[arid] = result.envelope
159
170
  app.state.pending_args[arid] = body["args"]
171
+ if state is not None:
172
+ app.state.pending_state[arid] = state
160
173
  if on_hold is not None:
161
174
  try:
162
175
  on_hold(result.envelope, body["args"])
@@ -174,10 +187,12 @@ def build_issuer_app(
174
187
  if datetime.fromisoformat(env["not_after"]) <= current:
175
188
  del app.state.pending[arid] # an expired hold can never be released
176
189
  app.state.pending_args.pop(arid, None)
190
+ app.state.pending_state.pop(arid, None)
177
191
  app.state.grants.pop(arid, None)
178
192
  elif not _complete(arid):
179
193
  have = app.state.grants.get(arid, [])
180
194
  live[arid] = {"envelope": env, "args": app.state.pending_args.get(arid),
195
+ "state": app.state.pending_state.get(arid),
181
196
  "collected": len(have), "required": approval_terms(env)["required"],
182
197
  "approvers": [g["approver_key_id"] for g in have]}
183
198
  return {"pending": list(live.values())}
@@ -230,6 +245,7 @@ def build_issuer_app(
230
245
  return {"declined": False, "reason": REF_ISSUER_UNKNOWN_HOLD}
231
246
  del app.state.pending[arid]
232
247
  app.state.pending_args.pop(arid, None)
248
+ app.state.pending_state.pop(arid, None)
233
249
  app.state.grants.pop(arid, None)
234
250
  app.state.declined.add(arid)
235
251
  return {"declined": True, "reason": None}
@@ -97,6 +97,7 @@ ENV_CLIENT_TOKEN = "GARGOYLE_MCP_CLIENT_TOKEN"
97
97
  ENV_APPROVAL_TIMEOUT = "GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS"
98
98
  ENV_POLL_SECONDS = "GARGOYLE_MCP_POLL_SECONDS"
99
99
  ENV_AUDIT_LOG = "GARGOYLE_MCP_AUDIT_LOG"
100
+ ENV_STATE_PROBE = "GARGOYLE_MCP_STATE_PROBE" # "filesystem" (default): bind held calls to the target file's state; "none" to disable
100
101
  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
101
102
 
102
103
  # Self-issue refusals.
@@ -134,13 +135,16 @@ class IssuerClient:
134
135
  def _get(self, path: str) -> Any:
135
136
  return self._http.get(self.url + path, headers=self._headers(), timeout=self.request_timeout)
136
137
 
137
- def issue(self, target_url: str, tool: str, args: Any) -> Dict[str, Any]:
138
+ def issue(self, target_url: str, tool: str, args: Any, state: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
138
139
  """{"envelope": ...} or {"envelope": None, "reason": ...}. The cause of
139
140
  an unavailable issuer (TLS, DNS, a refused connection) goes to stderr,
140
141
  where an MCP client's server log shows it; the refusal code stays
141
142
  generic."""
142
143
  try:
143
- r = self._post("/issue", {"target_url": target_url, "tool": tool, "args": args})
144
+ body_out: Dict[str, Any] = {"target_url": target_url, "tool": tool, "args": args}
145
+ if state is not None:
146
+ body_out["state"] = state
147
+ r = self._post("/issue", body_out)
144
148
  except Exception as exc:
145
149
  if type(exc).__name__ == "LoginRequired":
146
150
  sys.stderr.write("gargoyle: %s\n" % exc)
@@ -261,10 +265,14 @@ def _without_gargoyle_meta(params: Dict[str, Any]) -> Dict[str, Any]:
261
265
  return forwarded
262
266
 
263
267
 
264
- def call_for(tool: str, args: Any, principal: str) -> Dict[str, Any]:
268
+ def call_for(tool: str, args: Any, principal: str, state_sha256: Optional[str] = None) -> Dict[str, Any]:
265
269
  """The call a receipt must bind to execute `tool` with `args` through a
266
- server serving `principal`: the same three fields the issuer bound."""
267
- return {"tool": tool, "args": args, "principal": principal}
270
+ server serving `principal`: the same three fields the issuer bound, plus
271
+ the target's current state hash when the receipt bound one."""
272
+ call = {"tool": tool, "args": args, "principal": principal}
273
+ if state_sha256 is not None:
274
+ call["state_sha256"] = state_sha256
275
+ return call
268
276
 
269
277
 
270
278
  class ServerState:
@@ -281,8 +289,11 @@ class ServerState:
281
289
  upstream: Optional[StdioUpstream] = None,
282
290
  issuer_client: Optional[IssuerClient] = None,
283
291
  audit_log: Optional[str] = None,
292
+ state_probe: Any = "filesystem",
284
293
  ) -> None:
294
+ from gargoyle.state import probe_from_name
285
295
  self.config = config
296
+ self.probe = state_probe if callable(state_probe) else probe_from_name(state_probe)
286
297
  self.upstream = upstream
287
298
  self.issuer_client = issuer_client
288
299
  self.evidence: Optional[EvidenceLog] = EvidenceLog(audit_log) if audit_log else None
@@ -331,8 +342,11 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
331
342
  if envelope is None and state.issuer_client is not None:
332
343
  # Self-issue: ask the issuing service, and wait for the human if it
333
344
  # flagged the call. Nothing below trusts the answer: the receipt is
334
- # verified against the pinned issuer key like any other.
335
- res = state.issuer_client.issue(state.config["target_id"], name, args)
345
+ # verified against the pinned issuer key like any other. The
346
+ # target's current state goes with the request (gargoyle.state), so
347
+ # a hold is approved against it and bound to it.
348
+ pre_state = state.probe(name, args) if state.probe is not None else None
349
+ res = state.issuer_client.issue(state.config["target_id"], name, args, state=pre_state)
336
350
  if res["envelope"] is None:
337
351
  return refused(res["reason"])
338
352
  envelope, self_issued = res["envelope"], True
@@ -342,7 +356,15 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
342
356
  return refused(waited["reason"], envelope)
343
357
  grant = waited["grant"]
344
358
 
345
- verdict = state.consumer.check(envelope, call_for(name, args, state.config["principal"]),
359
+ # A state-bound receipt is checked against the state as it is now, after
360
+ # any wait for approval: if the target moved, the call does not run.
361
+ live_state = None
362
+ if isinstance(envelope, dict) and isinstance(envelope.get("request_context"), dict) \
363
+ and "state_sha256" in envelope["request_context"] and state.probe is not None:
364
+ from gargoyle.state import state_sha256 as _state_hash
365
+ now_desc = state.probe(name, args)
366
+ live_state = _state_hash(now_desc) if now_desc is not None else None
367
+ verdict = state.consumer.check(envelope, call_for(name, args, state.config["principal"], live_state),
346
368
  grant=grant)
347
369
  if not verdict.honored:
348
370
  return refused(verdict.reason, envelope)
@@ -359,7 +381,7 @@ def _audit_meta(state: ServerState, gate: Dict[str, Any], outputs: Any, params:
359
381
  goes into the evidence log when one is configured, so a client that
360
382
  discards _meta still leaves evidence."""
361
383
  meta: Dict[str, Any] = {}
362
- execution = _attest(state, gate["envelope"], outputs)
384
+ execution = _attest(state, gate["envelope"], outputs, params)
363
385
  if execution is not None:
364
386
  meta[EXECUTION_META_KEY] = execution
365
387
  if gate["self_issued"]:
@@ -385,12 +407,19 @@ def _record(state: ServerState, kind: str, gate: Dict[str, Any], params: Dict[st
385
407
  sys.stderr.write("gargoyle: evidence log unwritable: %s\n" % exc)
386
408
 
387
409
 
388
- def _attest(state: ServerState, envelope: Optional[Dict[str, Any]], outputs: Any) -> Optional[Dict[str, Any]]:
410
+ def _attest(state: ServerState, envelope: Optional[Dict[str, Any]], outputs: Any,
411
+ params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
389
412
  """The signed execution receipt for an honored call, or None when this
390
- server has no executor key."""
413
+ server has no executor key. For a state-bound call the target is
414
+ probed again after the run, so the receipt says what it changed."""
391
415
  if state.consumer is None or not state.consumer.attests or envelope is None:
392
416
  return None
393
- return state.consumer.attest(envelope, outputs)
417
+ post = None
418
+ if params is not None and state.probe is not None and "state_sha256" in (envelope.get("request_context") or {}):
419
+ from gargoyle.state import state_sha256 as _state_hash
420
+ desc = state.probe(str(params.get("name")), params.get("arguments", {}))
421
+ post = _state_hash(desc) if desc is not None else None
422
+ return state.consumer.attest(envelope, outputs, post_state_sha256=post)
394
423
 
395
424
 
396
425
  def _success(msg_id: Any, result: Dict[str, Any]) -> Dict[str, Any]:
@@ -631,7 +660,8 @@ if __name__ == "__main__":
631
660
  _upstream = upstream_from_argv(sys.argv[1:])
632
661
  try:
633
662
  serve_stdio(ServerState(config_from_env(), upstream=_upstream, issuer_client=issuer_client_from_env(),
634
- audit_log=_os.environ.get(ENV_AUDIT_LOG) or None))
663
+ audit_log=_os.environ.get(ENV_AUDIT_LOG) or None,
664
+ state_probe=_os.environ.get(ENV_STATE_PROBE) or "filesystem"))
635
665
  finally:
636
666
  if _upstream is not None:
637
667
  _upstream.close()
@@ -0,0 +1,94 @@
1
+ """State-bound approval: the world the human saw is the world the call runs in.
2
+
3
+ A held call is approved against what the approver was shown. Without this
4
+ module that is the arguments alone; the target could change between the
5
+ card and the run, and the executor would not notice. With it, the executor
6
+ describes the target's current state before asking for the receipt, the
7
+ issuer puts the state's hash into the receipt's signed request context,
8
+ the approver's grant covers it, and at execution the executor describes
9
+ the state again and refuses if it moved (REF_VERIFY_STATE_MISMATCH). The
10
+ execution receipt then carries the pre-state and post-state hashes, so an
11
+ auditor can see not only that the call ran but what it changed.
12
+
13
+ The state description is the executor's own statement, as the execution
14
+ receipt is; it binds the executor to one state, it does not make the
15
+ executor honest. Its canonical form:
16
+
17
+ {"ref": <what was examined, e.g. the file path>,
18
+ "sha256": <hex of the bytes> | null when absent,
19
+ "size": <byte count> | null}
20
+
21
+ and state_sha256 = sha256(canonical_json(description)). A `text` member
22
+ (the bytes as UTF-8, when small enough) may travel beside the description
23
+ to the issuer so the console can show a diff; it is not part of the hash,
24
+ which already covers the bytes.
25
+
26
+ The reference probe knows filesystem tools: for a tool that takes a path,
27
+ the state is that file. Any executor can supply its own probe.
28
+ """
29
+
30
+ import hashlib
31
+ from typing import Any, Callable, Dict, Optional
32
+
33
+ from gargoyle.envelope import canonical_json
34
+
35
+ PROBE_TOOLS = ("write_file", "edit_file", "create_directory", "move_file", "delete_file")
36
+ PATH_ARGS = ("path", "source")
37
+ TEXT_LIMIT = 65536
38
+
39
+
40
+ def describe(ref: str, data: Optional[bytes], *, with_text: bool = True) -> Dict[str, Any]:
41
+ """The state description for `ref` holding `data` (None when absent)."""
42
+ desc: Dict[str, Any] = {"ref": ref, "sha256": hashlib.sha256(data).hexdigest() if data is not None else None,
43
+ "size": len(data) if data is not None else None}
44
+ if with_text and data is not None and len(data) <= TEXT_LIMIT:
45
+ try:
46
+ desc["text"] = data.decode("utf-8")
47
+ except UnicodeDecodeError:
48
+ pass
49
+ return desc
50
+
51
+
52
+ def state_sha256(description: Dict[str, Any]) -> str:
53
+ """The hash the receipt binds: over ref, sha256 and size only."""
54
+ core = {"ref": description["ref"], "sha256": description.get("sha256"), "size": description.get("size")}
55
+ return hashlib.sha256(canonical_json(core).encode("utf-8")).hexdigest()
56
+
57
+
58
+ def valid_description(d: Any) -> bool:
59
+ return (isinstance(d, dict) and isinstance(d.get("ref"), str) and bool(d["ref"])
60
+ and (d.get("sha256") is None or (isinstance(d["sha256"], str) and len(d["sha256"]) == 64))
61
+ and (d.get("size") is None or (isinstance(d["size"], int) and not isinstance(d["size"], bool) and d["size"] >= 0))
62
+ and (d.get("sha256") is None) == (d.get("size") is None)
63
+ and ("text" not in d or isinstance(d["text"], str))
64
+ and set(d) <= {"ref", "sha256", "size", "text"})
65
+
66
+
67
+ def filesystem_probe(tool: str, args: Any) -> Optional[Dict[str, Any]]:
68
+ """The reference probe: for a filesystem tool with a path, the file's
69
+ current bytes (or absence). None for tools it does not know, so the
70
+ call is not state-bound."""
71
+ if tool not in PROBE_TOOLS or not isinstance(args, dict):
72
+ return None
73
+ path = next((args[k] for k in PATH_ARGS if isinstance(args.get(k), str) and args[k]), None)
74
+ if path is None:
75
+ return None
76
+ try:
77
+ with open(path, "rb") as f:
78
+ data = f.read()
79
+ except FileNotFoundError:
80
+ data = None
81
+ except IsADirectoryError:
82
+ return describe(path, b"", with_text=False)
83
+ except OSError:
84
+ return None
85
+ return describe(path, data)
86
+
87
+
88
+ def probe_from_name(name: Optional[str]) -> Optional[Callable[[str, Any], Optional[Dict[str, Any]]]]:
89
+ """GARGOYLE_MCP_STATE_PROBE: "filesystem" (default) or "none"."""
90
+ if name in (None, "", "filesystem"):
91
+ return filesystem_probe
92
+ if name == "none":
93
+ return None
94
+ raise ValueError("unknown state probe: %s" % name)
@@ -23,6 +23,10 @@
23
23
  button { font:inherit; padding:8px 16px; border-radius:8px; border:1px solid var(--line); background:var(--card); color:var(--ink); cursor:pointer; }
24
24
  button.approve { background:var(--accent); border-color:var(--accent); color:#fff; font-weight:600; }
25
25
  button:disabled { opacity:.5; cursor:not-allowed; }
26
+ .state { margin:8px 0; }
27
+ pre.diff { max-height:320px; overflow:auto; }
28
+ .dadd { color:#1a7f37; } .ddel { color:#b42318; text-decoration:line-through; } .dctx { color:var(--muted, #777); }
29
+ .note { font-size:.9em; color:var(--muted, #777); }
26
30
  .actions { display:flex; gap:12px; align-items:center; }
27
31
  .empty { color:var(--muted); text-align:center; padding:48px 0; }
28
32
  .log { font-size:13px; color:var(--muted); margin-top:16px; }
@@ -48,7 +52,7 @@
48
52
  <div class="log" id="log"></div>
49
53
  </main>
50
54
  <script type="module">
51
- import { argsSha256, authorizeUrl, buildGrant, exchangeCode, generateApproverKey, importApproverKey, isoUtc, loadStoredKey,
55
+ import { argsSha256, authorizeUrl, buildGrant, exchangeCode, generateApproverKey, importApproverKey, isoUtc, lineDiff, loadStoredKey,
52
56
  newGrantId, pkceChallenge, publicKeyHex, randomVerifier, signGrant, storeKey } from "./approve.js";
53
57
 
54
58
  const state = { token: null, key: null, keyId: null, ttl: 300, timer: null, seen: new Map() };
@@ -150,6 +154,7 @@ async function refresh() {
150
154
  ${required > 1 ? `<dt>approvals</dt><dd class="progress">${progress}</dd>` : ""}
151
155
  ${env.approval?.approvers ? `<dt>eligible</dt><dd>${env.approval.approvers.map(esc).join(", ")}${eligible ? "" : ' <span class="badge bad">not you</span>'}</dd>` : ""}
152
156
  </dl>
157
+ ${stateBlock(item)}
153
158
  <pre>${esc(JSON.stringify(item.args, null, 2))}</pre>
154
159
  <div class="actions">
155
160
  <button class="approve" ${bound && eligible && !mine ? "" : "disabled"}>Approve</button>
@@ -194,6 +199,26 @@ async function decline(card, id) {
194
199
  } catch (err) { card.querySelector(".result").innerHTML = `<span class="bad">${esc(err.message)}</span>`; }
195
200
  }
196
201
 
202
+ // The state the executor saw when it asked (gargoyle.state), and the change
203
+ // the call would make to it. The receipt binds this state: if the file
204
+ // moves before the run, the executor refuses, so what you see here is what
205
+ // the call will act on.
206
+ function stateBlock(item) {
207
+ const st = item.state; if (!st) return "";
208
+ const args = item.args || {};
209
+ let head;
210
+ if (st.sha256 === null || st.sha256 === undefined) head = `creates <code>${esc(st.ref)}</code> (does not exist yet)`;
211
+ else head = `changes <code>${esc(st.ref)}</code> (${st.size} bytes now, state ${esc(String(st.sha256).slice(0, 12))}&hellip;)`;
212
+ let body = "";
213
+ if (typeof st.text === "string" && typeof args.content === "string") {
214
+ const rows = lineDiff(st.text, args.content).map((r) => `<span class="d${r.op === "+" ? "add" : r.op === "-" ? "del" : "ctx"}">${r.op} ${esc(r.text)}</span>`);
215
+ body = `<pre class="diff">${rows.join("\n")}</pre>`;
216
+ } else if (st.sha256 !== null && st.sha256 !== undefined && typeof args.content === "string") {
217
+ body = `<div class="note">current content is not text or is too large to show; the new content is below</div>`;
218
+ }
219
+ return `<div class="state"><strong>${head}</strong>${body}</div>`;
220
+ }
221
+
197
222
  function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])); }
198
223
  </script>
199
224
  </body>
@@ -119,3 +119,21 @@ export async function storeKey(name, keyPair) {
119
119
  tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error);
120
120
  });
121
121
  }
122
+
123
+ // ---- What a held write changes: a line diff of the state the executor saw against the proposed content ----
124
+
125
+ export function lineDiff(before, after) {
126
+ const a = before.split("\n"), b = after.split("\n");
127
+ const n = a.length, m = b.length;
128
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
129
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
130
+ const out = []; let i = 0, j = 0;
131
+ while (i < n && j < m) {
132
+ if (a[i] === b[j]) { out.push({ op: " ", text: a[i] }); i++; j++; }
133
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) { out.push({ op: "-", text: a[i] }); i++; }
134
+ else { out.push({ op: "+", text: b[j] }); j++; }
135
+ }
136
+ while (i < n) out.push({ op: "-", text: a[i++] });
137
+ while (j < m) out.push({ op: "+", text: b[j++] });
138
+ return out;
139
+ }
@@ -70,6 +70,7 @@ REF_VERIFY_VERSION_UNSUPPORTED = "REF_VERIFY_VERSION_UNSUPPORTED"
70
70
  REF_VERIFY_REASSERT_INVALIDATED = "REF_VERIFY_REASSERT_INVALIDATED"
71
71
  REF_VERIFY_REASSERT_RE_EVALUATE_REQUIRED = "REF_VERIFY_REASSERT_RE_EVALUATE_REQUIRED"
72
72
  REF_VERIFY_BINDING_MISMATCH = "REF_VERIFY_BINDING_MISMATCH"
73
+ REF_VERIFY_STATE_MISMATCH = "REF_VERIFY_STATE_MISMATCH" # the target's state moved since the receipt bound it
73
74
  # The receipt names a different principal than the one the target
74
75
  # authenticated, or the target authenticated none.
75
76
  REF_VERIFY_PRINCIPAL_MISMATCH = "REF_VERIFY_PRINCIPAL_MISMATCH"
@@ -133,7 +134,7 @@ _REQUEST_CONTEXT_KEYS = (
133
134
 
134
135
  # The live call's keys: exactly these, plus optionally on_behalf_of.
135
136
  _CALL_KEYS = ("tool", "args", "principal")
136
- _OPTIONAL_CALL_KEYS = ("on_behalf_of",)
137
+ _OPTIONAL_CALL_KEYS = ("on_behalf_of", "state_sha256")
137
138
 
138
139
 
139
140
  def _reject(reason: str) -> Dict[str, Any]:
@@ -211,6 +212,8 @@ def verify_provenance(
211
212
  return _reject(REF_VERIFY_ENVELOPE_ABSENT)
212
213
  if "on_behalf_of" in rc and not valid_principal(rc["on_behalf_of"]):
213
214
  return _reject(REF_VERIFY_ENVELOPE_ABSENT)
215
+ if "state_sha256" in rc and not (isinstance(rc["state_sha256"], str) and len(rc["state_sha256"]) == 64):
216
+ return _reject(REF_VERIFY_ENVELOPE_ABSENT)
214
217
  # A flagged receipt names the hold a grant must release; a receipt that
215
218
  # says so without naming it is unusable.
216
219
  if not isinstance(envelope["requires_approval"], bool):
@@ -405,4 +408,11 @@ def verify_envelope(
405
408
  if rc["args_sha256"] != live_args_hash:
406
409
  return _reject(REF_VERIFY_BINDING_MISMATCH)
407
410
 
411
+ # state: a receipt that binds the target's state (gargoyle.state) is
412
+ # honored only in that state. The executor supplies the hash it sees
413
+ # now; absent or different is a refusal. A live hash the receipt does
414
+ # not bind is ignored: it was not part of the decision.
415
+ if "state_sha256" in rc and call.get("state_sha256") != rc["state_sha256"]:
416
+ return _reject(REF_VERIFY_STATE_MISMATCH)
417
+
408
418
  return _accept()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.6.1.dev0
3
+ Version: 0.7.1.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.6.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.7.1.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
@@ -223,6 +223,15 @@ policy version authorized a specific call for a specific principal, a
223
223
  human's key released it, and exactly that call ran once, verifiable offline
224
224
  by anyone, in Python or TypeScript.
225
225
 
226
+ A held call is approved against the state it was shown in. Before asking
227
+ for the receipt the middleware hashes the target (a file's bytes, or its
228
+ absence); the issuer binds that hash into the signed receipt; the console
229
+ shows the proposed content as a diff against that state; after the human
230
+ signs, the middleware hashes the target again and refuses if it moved; and
231
+ the execution receipt carries the state before and after. An approval
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.
234
+
226
235
  The MCP middleware also keeps an evidence log: one hash-chained line per
227
236
  decision, refusals and declined holds included, so the trail answers "what
228
237
  did the agent try" as well as "what did it do". `gargoyle audit --log`
@@ -304,6 +313,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
304
313
  | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
305
314
  | 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 |
306
315
  | `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
+ | `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 |
307
317
  | `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 |
308
318
 
309
319
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
@@ -27,6 +27,7 @@ gargoyle/policy.py
27
27
  gargoyle/published_record_source.py
28
28
  gargoyle/replay_cache.py
29
29
  gargoyle/rules.py
30
+ gargoyle/state.py
30
31
  gargoyle/verifier.py
31
32
  gargoyle/static/approve.html
32
33
  gargoyle/static/approve.js
@@ -60,6 +61,7 @@ tests/test_opa.py
60
61
  tests/test_policy.py
61
62
  tests/test_published_record.py
62
63
  tests/test_rules.py
64
+ tests/test_state.py
63
65
  tests/test_stdio_encoding.py
64
66
  tests/test_threshold.py
65
67
  tests/test_typescript_conformance.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.6.1.dev0"
7
+ version = "0.7.1.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"
@@ -112,7 +112,6 @@ def test_init_for_a_windows_client_and_audit_log_verification(tmp_path, monkeypa
112
112
  assert "4 entries (3 honored, 1 refused), 0 failed; chain EVIDENCE_VERIFIED" in out_text
113
113
  assert out_text.count(" ok ") == 4 and "refused delete_file" in out_text
114
114
  assert "ENTRY_VERIFIED (DESTRUCTIVE_TOOL)" in out_text
115
- assert "released by approver-1" in out_text # the held write names who released it
116
115
  # A tampered entry is caught, and the hash-chain names it: editing the
117
116
  # execution receipt inside entry 2 changes its hash.
118
117
  lines = log.read_text().splitlines(); e = json.loads(lines[1]); e["execution"]["outputs_sha256"] = "0" * 64
@@ -151,3 +151,38 @@ def test_a_declined_hold_keeps_its_receipt_and_the_receipt_is_checked():
151
151
  def test_build_entry_rejects_unknown_kind():
152
152
  with pytest.raises(ValueError):
153
153
  build_entry(seq=1, prev_sha256=GENESIS, kind="maybe", tool="t", args={}, principal=PRINCIPAL, reason="x")
154
+
155
+
156
+ def test_an_honored_held_call_reports_who_released_it(tmp_path):
157
+ """verify_log names the approver key ids behind an honored held call, so
158
+ the audit can print "released by <upn>"."""
159
+ from gargoyle.approval import build_grant, sign_grant
160
+ approver = Ed25519PrivateKey.generate()
161
+
162
+ class HoldAll:
163
+ def pin(self): return PIN
164
+ def decide(self, target_url, interaction): return Decision(ELIGIBLE, None, True)
165
+ env = _issue(HoldAll(), TARGET, {"tool": "write_file", "args": {"p": 1}, "principal": PRINCIPAL},
166
+ signing_key=issuer, key_id="issuer-1", now=NOW).envelope
167
+ grant = sign_grant(build_grant(decision_sha256=env["decision_sha256"], approval_request_id=env["approval_request_id"],
168
+ grant_id="g-1", not_after=NOW + timedelta(minutes=10)), approver, "sysops@contoso.com")
169
+ ex = sign_execution_receipt(build_execution_receipt(envelope=env, outputs={"ok": True}, executed_at=RAN), executor, "executor-1")
170
+ log = EvidenceLog(str(tmp_path / "e.jsonl"))
171
+ log.append(HONORED, "write_file", {"p": 1}, PRINCIPAL, ACCEPT_CHAIN_VERIFIED, receipt=env, grant=grant, execution=ex)
172
+ log.append(REFUSED, "delete_file", {}, PRINCIPAL, "DESTRUCTIVE_TOOL")
173
+ v = verify_log(read_log(str(log.path)), approver_public_keys={"sysops@contoso.com": approver.public_key()}, **TRUST)
174
+ assert v["accepted"]
175
+ assert v["entries"][0]["released_by"] == ["sysops@contoso.com"] and v["entries"][1]["released_by"] == []
176
+ # And the CLI line carries it.
177
+ from gargoyle.cli import main
178
+ import json as _json
179
+ out = tmp_path / "deploy"; out.mkdir()
180
+ hexpub = lambda k: k.public_key().public_bytes(__import__("cryptography.hazmat.primitives.serialization", fromlist=["Encoding"]).Encoding.Raw,
181
+ __import__("cryptography.hazmat.primitives.serialization", fromlist=["PublicFormat"]).PublicFormat.Raw).hex()
182
+ (out / "public-keys.json").write_text(_json.dumps({"issuer": {"issuer-1": hexpub(issuer)}, "executor": {"executor-1": hexpub(executor)},
183
+ "approver": {"sysops@contoso.com": hexpub(approver)}}))
184
+ import io, contextlib
185
+ buf = io.StringIO()
186
+ with contextlib.redirect_stdout(buf):
187
+ assert main(["audit", "--log", str(log.path), "--out", str(out)]) == 0
188
+ assert "CHAIN_VERIFIED released by sysops@contoso.com" in buf.getvalue()
@@ -0,0 +1,180 @@
1
+ """State-bound approval: a held call is approved against the target's state
2
+ and runs only in that state. The middleware probes the file before asking,
3
+ the issuer binds the hash into the receipt, the approver sees the change,
4
+ the middleware probes again before running and refuses if the file moved,
5
+ and the execution receipt records the state before and after."""
6
+
7
+ from datetime import datetime, timedelta, timezone
8
+
9
+ import pytest
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.consumer import Consumer
15
+ from gargoyle.envelope import build_envelope
16
+ from gargoyle.execution import (ACCEPT_CHAIN_VERIFIED, REF_EXEC_BINDING_MISMATCH, build_execution_receipt,
17
+ sign_execution_receipt, verify_chain, verify_execution_receipt)
18
+ from gargoyle.issuer import issue
19
+ from gargoyle.issuer_service import build_issuer_app
20
+ from gargoyle.jws import encode
21
+ from gargoyle.policy import ELIGIBLE, Decision
22
+ from gargoyle.state import describe, filesystem_probe, probe_from_name, state_sha256, valid_description
23
+ from gargoyle.verifier import REF_VERIFY_ENVELOPE_ABSENT, REF_VERIFY_STATE_MISMATCH, verify_envelope
24
+
25
+ TARGET = "mcp://fs"
26
+ PIN = {"policy_id": "p/1", "policy_sha256": "a" * 64}
27
+ NOW = datetime(2026, 10, 19, 12, 0, tzinfo=timezone.utc)
28
+ issuer, executor, approver, idp = (Ed25519PrivateKey.generate() for _ in range(4))
29
+ ISSUER_KEYS = {"issuer-1": issuer.public_key()}
30
+
31
+
32
+ class Hold:
33
+ def pin(self): return PIN
34
+ def decide(self, target_url, interaction): return Decision(ELIGIBLE, None, True)
35
+
36
+
37
+ class Allow:
38
+ def pin(self): return PIN
39
+ def decide(self, target_url, interaction): return Decision(ELIGIBLE)
40
+
41
+
42
+ def test_state_description_and_hash_cover_bytes_not_text():
43
+ d = describe("C:\\x\\notes.md", b"hello")
44
+ assert d["sha256"] and d["size"] == 5 and d["text"] == "hello"
45
+ assert state_sha256(d) == state_sha256({k: v for k, v in d.items() if k != "text"})
46
+ assert state_sha256(describe("p", b"hello")) != state_sha256(describe("p", b"hellO"))
47
+ absent = describe("p", None)
48
+ assert absent["sha256"] is None and absent["size"] is None and "text" not in absent
49
+ assert state_sha256(absent) != state_sha256(describe("p", b""))
50
+ assert valid_description(d) and valid_description(absent)
51
+ for bad in ({"ref": ""}, {"ref": "p", "sha256": "x", "size": 1}, {"ref": "p", "sha256": "a" * 64}, {"ref": "p", "extra": 1}):
52
+ assert not valid_description(bad), bad
53
+
54
+
55
+ def test_filesystem_probe_reads_the_target_or_its_absence(tmp_path):
56
+ f = tmp_path / "n.md"; f.write_bytes(b"one\n")
57
+ d = filesystem_probe("write_file", {"path": str(f), "content": "two"})
58
+ assert d["ref"] == str(f) and d["size"] == 4 and d["text"] == "one\n"
59
+ gone = filesystem_probe("write_file", {"path": str(tmp_path / "new.md"), "content": "x"})
60
+ assert gone["sha256"] is None
61
+ assert filesystem_probe("read_file", {"path": str(f)}) is None # not a state-changing tool
62
+ assert filesystem_probe("write_file", {"content": "x"}) is None # nothing to probe
63
+ assert filesystem_probe("move_file", {"source": str(f), "destination": str(tmp_path / "m")})["size"] == 4
64
+ assert probe_from_name("none") is None and probe_from_name(None) is filesystem_probe
65
+ with pytest.raises(ValueError):
66
+ probe_from_name("magic")
67
+
68
+
69
+ def test_receipt_binds_the_state_and_the_verifier_enforces_it():
70
+ pre = state_sha256(describe("p", b"one"))
71
+ call = {"tool": "write_file", "args": {"path": "p", "content": "two"}, "principal": "agent-7", "state_sha256": pre}
72
+ env = issue(Hold(), TARGET, call, signing_key=issuer, key_id="issuer-1", now=NOW).envelope
73
+ assert env["request_context"]["state_sha256"] == pre
74
+ live = {"tool": "write_file", "args": {"path": "p", "content": "two"}, "principal": "agent-7"}
75
+ kw = dict(record_source=PIN, pinned_public_keys=ISSUER_KEYS, now=NOW)
76
+ assert verify_envelope(env, dict(live, state_sha256=pre), TARGET, **kw)["accepted"]
77
+ assert verify_envelope(env, live, TARGET, **kw)["reason"] == REF_VERIFY_STATE_MISMATCH # executor saw nothing
78
+ moved = state_sha256(describe("p", b"one and more"))
79
+ assert verify_envelope(env, dict(live, state_sha256=moved), TARGET, **kw)["reason"] == REF_VERIFY_STATE_MISMATCH
80
+ # Lowering the binding breaks the signature; a malformed hash is unusable; an unbound receipt ignores a live hash.
81
+ assert not verify_envelope(dict(env, request_context={**env["request_context"], "state_sha256": moved}),
82
+ dict(live, state_sha256=moved), TARGET, **kw)["accepted"]
83
+ bad = dict(env, request_context={**env["request_context"], "state_sha256": "zz"})
84
+ assert verify_envelope(bad, live, TARGET, **kw)["reason"] == REF_VERIFY_ENVELOPE_ABSENT
85
+ free = issue(Allow(), TARGET, live, signing_key=issuer, key_id="issuer-1", now=NOW).envelope
86
+ assert verify_envelope(free, dict(live, state_sha256=pre), TARGET, **kw)["accepted"]
87
+ with pytest.raises(ValueError):
88
+ build_envelope(decision=ELIGIBLE, target_url=TARGET, call=dict(live, state_sha256="short"), policy_pin=PIN)
89
+
90
+
91
+ def test_execution_receipt_records_pre_and_post_state():
92
+ pre = state_sha256(describe("p", b"one")); post = state_sha256(describe("p", b"two"))
93
+ call = {"tool": "write_file", "args": {"path": "p", "content": "two"}, "principal": "agent-7", "state_sha256": pre}
94
+ env = issue(Hold(), TARGET, call, signing_key=issuer, key_id="issuer-1", now=NOW).envelope
95
+ ex = sign_execution_receipt(build_execution_receipt(envelope=env, outputs={"ok": 1}, executed_at=NOW, post_state_sha256=post),
96
+ executor, "executor-1")
97
+ assert ex["pre_state_sha256"] == pre and ex["post_state_sha256"] == post
98
+ keys = {"executor-1": executor.public_key()}
99
+ assert verify_execution_receipt(ex, env, keys)["accepted"]
100
+ grant = sign_grant(build_grant(decision_sha256=env["decision_sha256"], approval_request_id=env["approval_request_id"],
101
+ grant_id="g", not_after=NOW + timedelta(minutes=5)), approver, "approver-1")
102
+ chain = verify_chain(env, ex, grant=grant, issuer_public_keys=ISSUER_KEYS, executor_public_keys=keys,
103
+ approver_public_keys={"approver-1": approver.public_key()})
104
+ assert chain["reason"] == ACCEPT_CHAIN_VERIFIED
105
+ # An execution receipt that drops or alters the pre-state does not describe this authorization.
106
+ forged = sign_execution_receipt({k: v for k, v in ex.items() if k not in ("pre_state_sha256", "executor_signature", "executor_key_id")},
107
+ executor, "executor-1")
108
+ assert verify_execution_receipt(forged, env, keys)["reason"] == REF_EXEC_BINDING_MISMATCH
109
+ with pytest.raises(ValueError):
110
+ build_execution_receipt(envelope=env, outputs={}, executed_at=NOW, post_state_sha256="nope")
111
+
112
+
113
+ def _token(sub="agent-7"):
114
+ return encode({"sub": sub, "exp": int((datetime.now(timezone.utc) + timedelta(hours=1)).timestamp())}, idp, "idp-1")
115
+
116
+
117
+ def test_middleware_refuses_when_the_file_moved_between_card_and_run(tmp_path):
118
+ """End to end: hold on a real file, the human approves, but the file was
119
+ edited meanwhile. The call does not run. Unchanged, it runs and the
120
+ execution receipt carries pre and post state."""
121
+ from gargoyle.mcp_server import IssuerClient, ServerState, _audit_meta, _gate
122
+ app = build_issuer_app(policy=Hold(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()},
123
+ approver_public_keys={"approver-1": approver.public_key()})
124
+ c = TestClient(app); h = {"Authorization": "Bearer " + _token()}
125
+
126
+ class Shim:
127
+ def post(self, url, json=None, headers=None, timeout=None): return c.post(url.replace("http://i", ""), json=json, headers=headers)
128
+ def get(self, url, headers=None, timeout=None): return c.get(url.replace("http://i", ""), headers=headers)
129
+
130
+ target = tmp_path / "notes.md"; target.write_text("one\n", encoding="utf-8")
131
+ params = {"name": "write_file", "arguments": {"path": str(target), "content": "two\n"}}
132
+ seen = {}
133
+
134
+ def approve_after_edit(seconds):
135
+ hold = c.get("/pending", headers=h).json()["pending"][0]
136
+ seen["state"] = hold["state"]
137
+ if seen.get("edit"):
138
+ target.write_text("someone else wrote this\n", encoding="utf-8") # the world moved
139
+ env = hold["envelope"]
140
+ g = sign_grant(build_grant(decision_sha256=env["decision_sha256"], approval_request_id=env["approval_request_id"],
141
+ grant_id="g-" + env["approval_request_id"], not_after=datetime.now(timezone.utc) + timedelta(minutes=5)),
142
+ approver, "approver-1")
143
+ c.post("/grants", json=g, headers=h)
144
+
145
+ def mw():
146
+ return ServerState({"pinned_public_keys": ISSUER_KEYS, "record": PIN, "target_id": TARGET, "principal": "agent-7",
147
+ "approver_public_keys": {"approver-1": approver.public_key()},
148
+ "executor_signing_key": executor, "executor_key_id": "executor-1"},
149
+ issuer_client=IssuerClient("http://i", _token(), http=Shim(), approval_timeout=30, poll_seconds=0.01,
150
+ sleep=approve_after_edit))
151
+
152
+ seen["edit"] = True
153
+ gate = _gate(mw(), params)
154
+ assert not gate["executed"] and gate["reason"] == REF_VERIFY_STATE_MISMATCH
155
+ assert seen["state"]["text"] == "one\n" and seen["state"]["size"] == 4 # what the approver was shown
156
+ assert target.read_text(encoding="utf-8") == "someone else wrote this\n" # untouched by the gate
157
+
158
+ seen["edit"] = False
159
+ target.write_text("one\n", encoding="utf-8")
160
+ state = mw()
161
+ gate = _gate(state, params)
162
+ assert gate["executed"], gate["reason"]
163
+ target.write_text("two\n", encoding="utf-8") # the upstream's effect, simulated
164
+ meta = _audit_meta(state, gate, {"ok": True}, params)
165
+ ex = meta["gargoyle/execution"]
166
+ assert ex["pre_state_sha256"] == state_sha256(describe(str(target), b"one\n"))
167
+ assert ex["post_state_sha256"] == state_sha256(describe(str(target), b"two\n"))
168
+ chain = verify_chain(gate["envelope"], ex, grant=gate["grant"], issuer_public_keys=ISSUER_KEYS,
169
+ executor_public_keys={"executor-1": executor.public_key()}, approver_public_keys={"approver-1": approver.public_key()})
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"]}