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.
- {gargoyle_receipts-0.6.1.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.7.1.dev0}/PKG-INFO +12 -2
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/README.md +11 -1
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/cli.py +4 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/consumer.py +3 -2
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/envelope.py +5 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/evidence.py +8 -3
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/execution.py +20 -2
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/issuer_service.py +16 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/mcp_server.py +43 -13
- gargoyle_receipts-0.7.1.dev0/gargoyle/state.py +94 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/static/approve.html +26 -1
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/static/approve.js +18 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/verifier.py +11 -1
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0/gargoyle_receipts.egg-info}/PKG-INFO +12 -2
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_cli.py +0 -1
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_evidence.py +35 -0
- gargoyle_receipts-0.7.1.dev0/tests/test_state.py +180 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.6.1.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.7.1.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.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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
23
23
|
|
|
24
|
-
Status: pre-release (`0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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))}…)`;
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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()
|
{gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.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.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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
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
|
{gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/gargoyle_receipts.egg-info/SOURCES.txt
RENAMED
|
@@ -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.
|
|
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"]}
|
|
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.6.1.dev0 → gargoyle_receipts-0.7.1.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
|
{gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.6.1.dev0 → gargoyle_receipts-0.7.1.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|